@sublang/playbook 7.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -4
- package/docs/cli.md +74 -29
- package/docs/configuration.md +209 -112
- package/docs/embedding.md +71 -25
- package/package.json +4 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
- package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
- package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +27 -23
- package/slc/link.md +113 -93
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +20 -16
- package/src/runtime.ts +19 -23
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +241 -149
- package/src/xstate-playbook-runtime.ts +331 -178
- package/src/xstate-runtime.js +63 -24
- package/src/xstate-runtime.ts +96 -28
|
@@ -5,12 +5,17 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import { isDeepStrictEqual } from 'node:util';
|
|
6
6
|
import PQueue from 'p-queue';
|
|
7
7
|
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
import {
|
|
9
|
+
isAgentCallSettingsError,
|
|
10
|
+
type AgentCallSettings,
|
|
11
|
+
type BossTurn,
|
|
12
|
+
type Captain,
|
|
13
|
+
type CaptainContext,
|
|
14
|
+
type CaptainSession,
|
|
15
|
+
type PlayerAdapterName,
|
|
16
|
+
type TuningSelection,
|
|
13
17
|
} from '@sublang/cligent/tmux-play';
|
|
18
|
+
import type { Effort, PermissionPolicy } from '@sublang/cligent';
|
|
14
19
|
import type {
|
|
15
20
|
JsonValue,
|
|
16
21
|
NormalizedError,
|
|
@@ -22,6 +27,7 @@ import type {
|
|
|
22
27
|
PlaybookRunResult,
|
|
23
28
|
PlaybookRuntime,
|
|
24
29
|
PlaybookRuntimeSnapshot,
|
|
30
|
+
PlayerResult,
|
|
25
31
|
PlayerSessionStore,
|
|
26
32
|
PlaybookState,
|
|
27
33
|
} from '@sublang/playbook/runtime';
|
|
@@ -30,6 +36,7 @@ import {
|
|
|
30
36
|
hiddenControlEnvelope,
|
|
31
37
|
registerPlaybookAbortCleanup,
|
|
32
38
|
snapshotJsonValue,
|
|
39
|
+
validatePlayerResult,
|
|
33
40
|
} from '../../../src/xstate-runtime.js';
|
|
34
41
|
import createDefaultCaptainRuntime, {
|
|
35
42
|
type CaptainControllerPort,
|
|
@@ -37,13 +44,37 @@ import createDefaultCaptainRuntime, {
|
|
|
37
44
|
type CaptainParsedResolution,
|
|
38
45
|
type SettlementEvidence,
|
|
39
46
|
} from '../captain.playbook/captain.playbook.js';
|
|
40
|
-
import type { PlaybookSummaryPolicy
|
|
47
|
+
import type { PlaybookSummaryPolicy } from './code.registry.js';
|
|
48
|
+
|
|
49
|
+
interface SessionAgent {
|
|
50
|
+
readonly adapter: string;
|
|
51
|
+
readonly model: TuningSelection;
|
|
52
|
+
readonly effort: TuningSelection<Effort>;
|
|
53
|
+
readonly instruction?: string;
|
|
54
|
+
readonly permissions?: PermissionPolicy;
|
|
55
|
+
}
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
interface PlayerLedgerEntry {
|
|
58
|
+
readonly adapter: string;
|
|
59
|
+
readonly instruction?: string;
|
|
60
|
+
readonly permissions?: PermissionPolicy;
|
|
61
|
+
resumeToken?: string;
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
type DeepReadonly<T> = T extends (...args: never[]) => unknown
|
|
65
|
+
? T
|
|
66
|
+
: T extends readonly (infer Element)[]
|
|
67
|
+
? readonly DeepReadonly<Element>[]
|
|
68
|
+
: T extends object
|
|
69
|
+
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
|
70
|
+
: T;
|
|
71
|
+
|
|
72
|
+
type SnapshotAgentEnvelope = DeepReadonly<
|
|
73
|
+
Omit<SessionAgent, 'model' | 'effort'>
|
|
74
|
+
>;
|
|
75
|
+
|
|
76
|
+
type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
|
|
77
|
+
|
|
47
78
|
export interface PlaybookCaptainDeps {
|
|
48
79
|
loadModule?: (specifier: string) => Promise<unknown>;
|
|
49
80
|
createSessionId?: () => string;
|
|
@@ -61,15 +92,22 @@ export interface PlaybookCaptainRegistryEntry {
|
|
|
61
92
|
id: string;
|
|
62
93
|
command: string;
|
|
63
94
|
intent: string;
|
|
95
|
+
artifactSchema: 2;
|
|
64
96
|
requiredRoleIds: readonly string[];
|
|
97
|
+
concurrentRoleSets: readonly (readonly string[])[];
|
|
65
98
|
summaryPolicy?: PlaybookSummaryPolicy;
|
|
66
|
-
validateOptions(
|
|
67
|
-
createRuntime(options:
|
|
99
|
+
validateOptions(optionSlice: unknown): unknown;
|
|
100
|
+
createRuntime(options: unknown): PlaybookRuntime;
|
|
68
101
|
}
|
|
69
102
|
|
|
70
103
|
type PlaybookCaptainConversationSnapshot =
|
|
71
104
|
| { readonly kind: 'unopened' }
|
|
72
105
|
| { readonly kind: 'pinned'; readonly token: string }
|
|
106
|
+
| {
|
|
107
|
+
readonly kind: 'needsCatchUp';
|
|
108
|
+
readonly resume: string | false;
|
|
109
|
+
readonly afterJournalSeq: number;
|
|
110
|
+
}
|
|
73
111
|
| { readonly kind: 'needsSeeding' };
|
|
74
112
|
|
|
75
113
|
interface PlaybookCaptainJournalRecord {
|
|
@@ -86,16 +124,20 @@ interface PlaybookCaptainFrameSnapshot {
|
|
|
86
124
|
readonly depth: number;
|
|
87
125
|
readonly parentSessionId?: string;
|
|
88
126
|
readonly parentCallId?: string;
|
|
89
|
-
readonly
|
|
127
|
+
readonly options: JsonValue;
|
|
128
|
+
readonly roleBindings: Readonly<Record<string, string>>;
|
|
129
|
+
readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
|
|
90
130
|
}
|
|
91
131
|
|
|
92
132
|
interface PlaybookCaptainShellSnapshotFields {
|
|
93
|
-
readonly schemaVersion:
|
|
133
|
+
readonly schemaVersion: 3;
|
|
94
134
|
readonly captain: {
|
|
95
135
|
readonly sessionId: string;
|
|
96
|
-
readonly runtime: PlaybookRuntimeSnapshot
|
|
136
|
+
readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
|
|
137
|
+
readonly agent: SnapshotAgentEnvelope;
|
|
97
138
|
readonly conversation: PlaybookCaptainConversationSnapshot;
|
|
98
139
|
};
|
|
140
|
+
readonly playerSessions: Readonly<Record<string, PlayerLedgerSnapshotEntry>>;
|
|
99
141
|
/** Every Captain and engagement UUID issued during this logical session. */
|
|
100
142
|
readonly issuedSessionIds: readonly string[];
|
|
101
143
|
readonly sequences: {
|
|
@@ -117,13 +159,12 @@ interface PlaybookCaptainShellSnapshotFields {
|
|
|
117
159
|
* Complete JSON-safe durable state for one Captain shell between Boss turns.
|
|
118
160
|
* The discriminated mode keeps chat snapshots free of stale engagement data.
|
|
119
161
|
*/
|
|
120
|
-
|
|
162
|
+
type PlaybookCaptainShellSnapshotValue =
|
|
121
163
|
PlaybookCaptainShellSnapshotFields &
|
|
122
164
|
(
|
|
123
165
|
| {
|
|
124
166
|
readonly mode: 'chat';
|
|
125
167
|
readonly frames?: never;
|
|
126
|
-
readonly rootPlayerResumeTokens?: never;
|
|
127
168
|
readonly pendingBossQuestions?: never;
|
|
128
169
|
readonly lastError?: never;
|
|
129
170
|
}
|
|
@@ -131,13 +172,14 @@ export type PlaybookCaptainShellSnapshot =
|
|
|
131
172
|
readonly mode: 'engaged.parked';
|
|
132
173
|
/** Root-to-leaf engagement order. */
|
|
133
174
|
readonly frames: readonly PlaybookCaptainFrameSnapshot[];
|
|
134
|
-
/** Root-owned continuation, keyed by effective host-player id. */
|
|
135
|
-
readonly rootPlayerResumeTokens: Readonly<Record<string, string>>;
|
|
136
175
|
readonly pendingBossQuestions?: JsonValue;
|
|
137
176
|
readonly lastError?: { readonly name: string; readonly message: string };
|
|
138
177
|
}
|
|
139
178
|
);
|
|
140
179
|
|
|
180
|
+
export type PlaybookCaptainShellSnapshot =
|
|
181
|
+
DeepReadonly<PlaybookCaptainShellSnapshotValue>;
|
|
182
|
+
|
|
141
183
|
/** tmux and headless front ends share this one durable Captain shell API. */
|
|
142
184
|
export interface PlaybookCaptainShell extends Captain {
|
|
143
185
|
exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
|
|
@@ -147,22 +189,45 @@ export interface PlaybookCaptainShell extends Captain {
|
|
|
147
189
|
): Promise<void>;
|
|
148
190
|
}
|
|
149
191
|
|
|
150
|
-
// Per-enabled-playbook binding the shell resolves at init from
|
|
151
|
-
// `captain.options.playbooks
|
|
152
|
-
// `<id>-<role>` host players and carries the generated visible set.
|
|
192
|
+
// Per-enabled-playbook binding the shell resolves at init from the exact
|
|
193
|
+
// normalized `captain.options.playbooks.<id>` role map.
|
|
153
194
|
interface Enablement {
|
|
154
195
|
entry: PlaybookCaptainRegistryEntry;
|
|
155
196
|
command: string;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
hostPlayerId: (localRole: string) => string;
|
|
197
|
+
options: JsonValue;
|
|
198
|
+
roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
|
|
159
199
|
}
|
|
160
200
|
|
|
161
201
|
interface EffectivePlayerBinding {
|
|
162
|
-
readonly
|
|
163
|
-
readonly
|
|
202
|
+
readonly playerId: string;
|
|
203
|
+
readonly model: TuningSelection;
|
|
204
|
+
readonly effort: TuningSelection<Effort>;
|
|
205
|
+
readonly agent: SessionAgent;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface PlayerTransactionOwner {
|
|
209
|
+
readonly frame: EngagementFrame;
|
|
210
|
+
readonly roleId: string;
|
|
211
|
+
readonly turnId: number;
|
|
212
|
+
readonly signal: AbortSignal;
|
|
213
|
+
readonly scope: object;
|
|
164
214
|
}
|
|
165
215
|
|
|
216
|
+
type PlayerTransaction =
|
|
217
|
+
| (PlayerTransactionOwner & {
|
|
218
|
+
readonly phase: 'calling';
|
|
219
|
+
abandoned: boolean;
|
|
220
|
+
})
|
|
221
|
+
| (PlayerTransactionOwner & {
|
|
222
|
+
readonly phase: 'awaitingCommit';
|
|
223
|
+
readonly status: PlayerResult['status'];
|
|
224
|
+
readonly expectedToken: string | undefined;
|
|
225
|
+
})
|
|
226
|
+
| (PlayerTransactionOwner & {
|
|
227
|
+
readonly phase: 'quarantined';
|
|
228
|
+
readonly reason: string;
|
|
229
|
+
});
|
|
230
|
+
|
|
166
231
|
interface EngagementFrame {
|
|
167
232
|
entry: PlaybookCaptainRegistryEntry;
|
|
168
233
|
enablement: Enablement;
|
|
@@ -171,7 +236,6 @@ interface EngagementFrame {
|
|
|
171
236
|
rootSessionId: string;
|
|
172
237
|
depth: number;
|
|
173
238
|
playerBindings: ReadonlyMap<string, EffectivePlayerBinding>;
|
|
174
|
-
playerResumeTokens: Map<string, string>;
|
|
175
239
|
parent?: {
|
|
176
240
|
frame: EngagementFrame;
|
|
177
241
|
callId: string;
|
|
@@ -180,6 +244,7 @@ interface EngagementFrame {
|
|
|
180
244
|
abortListener?: () => void;
|
|
181
245
|
invocationSignal?: AbortSignal;
|
|
182
246
|
inFlightHostCalls: Set<Promise<unknown>>;
|
|
247
|
+
playerCallScope?: object;
|
|
183
248
|
// Set synchronously before this frame's runtime is asked to dispose, so a
|
|
184
249
|
// telemetry payload emitted during disposal is never mistaken for evidence
|
|
185
250
|
// about a live leaf. `disposePromise` cannot serve: it is assigned after
|
|
@@ -204,6 +269,26 @@ class VisibilityControlError extends Error {
|
|
|
204
269
|
}
|
|
205
270
|
}
|
|
206
271
|
|
|
272
|
+
class AgentSettingsPreflightError extends Error {
|
|
273
|
+
constructor(readonly rejection: unknown) {
|
|
274
|
+
super('agent rejected supplied complete call settings', {
|
|
275
|
+
cause: rejection,
|
|
276
|
+
});
|
|
277
|
+
this.name = 'AgentSettingsPreflightError';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function classifySettingsCall<T>(call: () => Promise<T>): Promise<T> {
|
|
282
|
+
try {
|
|
283
|
+
return await call();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (isAgentCallSettingsError(error)) {
|
|
286
|
+
throw new AgentSettingsPreflightError(error);
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
207
292
|
type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
|
|
208
293
|
|
|
209
294
|
interface ControlLedger {
|
|
@@ -245,15 +330,19 @@ interface JournalRecord {
|
|
|
245
330
|
type DurableCallKind = 'decision' | 'commandReply' | 'closingReply';
|
|
246
331
|
|
|
247
332
|
/**
|
|
248
|
-
* CAPTAIN-35: the
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* failed reseed starting a bare conversation with no session memory at all.
|
|
333
|
+
* CAPTAIN-35: the durable conversation distinguishes first use, healthy
|
|
334
|
+
* continuity, a settings-preflight catch-up on retained continuity, and a
|
|
335
|
+
* full reseed after continuity becomes suspect. Modeling those states keeps
|
|
336
|
+
* each recovery path explicit instead of overloading one presence boolean.
|
|
253
337
|
*/
|
|
254
338
|
type DurableConversation =
|
|
255
339
|
| { readonly kind: 'unopened' }
|
|
256
340
|
| { readonly kind: 'pinned'; readonly token: string }
|
|
341
|
+
| {
|
|
342
|
+
readonly kind: 'needsCatchUp';
|
|
343
|
+
readonly resume: string | false;
|
|
344
|
+
readonly afterJournalSeq: number;
|
|
345
|
+
}
|
|
257
346
|
| { readonly kind: 'needsSeeding' };
|
|
258
347
|
|
|
259
348
|
/**
|
|
@@ -275,6 +364,8 @@ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
|
275
364
|
const INTERNAL_CAPTAIN_ID = 'captain';
|
|
276
365
|
const UUID_PATTERN =
|
|
277
366
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
367
|
+
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
368
|
+
const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
278
369
|
|
|
279
370
|
interface TurnSummaryCounts {
|
|
280
371
|
interruptions: number;
|
|
@@ -290,6 +381,8 @@ interface ActiveTurnSummary {
|
|
|
290
381
|
/** The shell state of one Boss turn (DR-029). */
|
|
291
382
|
interface ActiveTurn {
|
|
292
383
|
readonly id: number;
|
|
384
|
+
/** Latest journal record represented in a successful Captain call. */
|
|
385
|
+
captainSyncedJournalSeq: number;
|
|
293
386
|
/** The exact Boss text of the turn; never rewritten (CAPTAIN-31). */
|
|
294
387
|
readonly bossText: string;
|
|
295
388
|
/**
|
|
@@ -318,7 +411,9 @@ interface ActiveTurn {
|
|
|
318
411
|
* with the Boss-appropriate failure reply instead of propagating, so the
|
|
319
412
|
* Boss's next message settles normally.
|
|
320
413
|
*/
|
|
321
|
-
|
|
414
|
+
readonly controlFailures: Set<unknown>;
|
|
415
|
+
/** Exact settings-preflight rejections that performed no provider work. */
|
|
416
|
+
readonly settingsPreflightFailures: Set<unknown>;
|
|
322
417
|
/**
|
|
323
418
|
* Every value that escaped an effect invocation this turn — a runtime
|
|
324
419
|
* driven, an engagement constructed, a stack disposed, an advertised action
|
|
@@ -492,7 +587,16 @@ function pendingQuestionLines(pending: unknown): string[] {
|
|
|
492
587
|
: typeof record.id === 'string'
|
|
493
588
|
? record.id
|
|
494
589
|
: undefined;
|
|
495
|
-
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;
|
|
496
600
|
const text =
|
|
497
601
|
typeof record.question === 'string'
|
|
498
602
|
? record.question
|
|
@@ -504,9 +608,9 @@ function pendingQuestionLines(pending: unknown): string[] {
|
|
|
504
608
|
// second time would cut the line at the seam's limit and drop whatever
|
|
505
609
|
// the shell had already written after the long part.
|
|
506
610
|
const asked =
|
|
507
|
-
|
|
611
|
+
askerLabel === undefined
|
|
508
612
|
? digestLine`${quoteEvidence(text)}`
|
|
509
|
-
: digestLine`${quoteEvidence(
|
|
613
|
+
: digestLine`${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
|
|
510
614
|
const marker = id === undefined ? '' : digestLine`(${quoteEvidence(id)}) `;
|
|
511
615
|
lines.push(`- ${marker}${asked}`);
|
|
512
616
|
}
|
|
@@ -548,7 +652,10 @@ function renderJournalPayload(payload: JsonValue): string {
|
|
|
548
652
|
return raw ?? 'null';
|
|
549
653
|
}
|
|
550
654
|
|
|
551
|
-
function
|
|
655
|
+
function renderJournalDigest(
|
|
656
|
+
records: readonly JournalRecord[],
|
|
657
|
+
heading: string,
|
|
658
|
+
): string {
|
|
552
659
|
const lines = records.map(
|
|
553
660
|
(record) =>
|
|
554
661
|
`${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(
|
|
@@ -556,12 +663,26 @@ function renderReseedDigest(records: readonly JournalRecord[]): string {
|
|
|
556
663
|
)}`,
|
|
557
664
|
);
|
|
558
665
|
return [
|
|
559
|
-
|
|
666
|
+
heading,
|
|
560
667
|
'The labeled ControlView and catalog digest blocks outrank conversation memory.',
|
|
561
668
|
...(lines.length === 0 ? ['(no earlier turns)'] : lines),
|
|
562
669
|
].join('\n');
|
|
563
670
|
}
|
|
564
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
|
+
|
|
565
686
|
// DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
|
|
566
687
|
// internal control vocabulary.
|
|
567
688
|
const CONTROL_VOCABULARY: readonly RegExp[] = [
|
|
@@ -681,16 +802,32 @@ function proseRejection(
|
|
|
681
802
|
return undefined;
|
|
682
803
|
}
|
|
683
804
|
|
|
805
|
+
type CaptainToolIsolation = 'provider-enforced' | 'prompt-only';
|
|
806
|
+
|
|
684
807
|
// DR-013 A1: adapters with no provider-enforced tool-restriction surface.
|
|
685
|
-
// Cligent's Codex
|
|
686
|
-
// empty list that expresses tool-free — because
|
|
687
|
-
// cannot enforce one, so requesting it fails
|
|
688
|
-
// model is reached. Omitting the option is the
|
|
689
|
-
// run a control call at all; its isolation then
|
|
690
|
-
// hidden-judge envelope below rather than on provider
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
+
}
|
|
694
831
|
|
|
695
832
|
// The tool half of a control call's options. An empty allowlist means "no
|
|
696
833
|
// tools available" and is distinct from omission, which grants the adapter's
|
|
@@ -701,7 +838,7 @@ function controlCallToolOptions(
|
|
|
701
838
|
): { allowedTools?: readonly string[] } {
|
|
702
839
|
if (
|
|
703
840
|
captainAdapter !== undefined &&
|
|
704
|
-
|
|
841
|
+
requiresPromptOnlyToolIsolation(captainAdapter)
|
|
705
842
|
) {
|
|
706
843
|
return {};
|
|
707
844
|
}
|
|
@@ -721,14 +858,6 @@ function forwardedToolOptions(
|
|
|
721
858
|
return { allowedTools: requested };
|
|
722
859
|
}
|
|
723
860
|
|
|
724
|
-
function readCaptainAdapter(options: unknown): string | undefined {
|
|
725
|
-
if (typeof options !== 'object' || options === null) return undefined;
|
|
726
|
-
const adapter = (options as Record<string, unknown>).captainAdapter;
|
|
727
|
-
return typeof adapter === 'string' && adapter.length > 0
|
|
728
|
-
? adapter
|
|
729
|
-
: undefined;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
861
|
const hiddenJudgeEnvelope = hiddenControlEnvelope;
|
|
733
862
|
|
|
734
863
|
interface OutcomeReport {
|
|
@@ -839,11 +968,39 @@ function isValidRegistryEntry(
|
|
|
839
968
|
): value is PlaybookCaptainRegistryEntry {
|
|
840
969
|
if (typeof value !== 'object' || value === null) return false;
|
|
841
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
|
+
}
|
|
842
999
|
return (
|
|
843
1000
|
typeof e.id === 'string' &&
|
|
844
1001
|
typeof e.command === 'string' &&
|
|
845
1002
|
typeof e.intent === 'string' &&
|
|
846
|
-
|
|
1003
|
+
e.artifactSchema === 2 &&
|
|
847
1004
|
typeof e.validateOptions === 'function' &&
|
|
848
1005
|
typeof e.createRuntime === 'function'
|
|
849
1006
|
);
|
|
@@ -925,8 +1082,207 @@ function snapshotUuid(value: JsonValue | undefined, path: string): string {
|
|
|
925
1082
|
return id;
|
|
926
1083
|
}
|
|
927
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
|
+
|
|
928
1284
|
/** Validate, detach, and freeze one untrusted shell snapshot. */
|
|
929
|
-
function assertPlaybookCaptainShellSnapshot(
|
|
1285
|
+
export function assertPlaybookCaptainShellSnapshot(
|
|
930
1286
|
value: unknown,
|
|
931
1287
|
): PlaybookCaptainShellSnapshot {
|
|
932
1288
|
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
@@ -935,6 +1291,7 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
935
1291
|
const commonKeys = [
|
|
936
1292
|
'schemaVersion',
|
|
937
1293
|
'captain',
|
|
1294
|
+
'playerSessions',
|
|
938
1295
|
'issuedSessionIds',
|
|
939
1296
|
'sequences',
|
|
940
1297
|
'journal',
|
|
@@ -950,7 +1307,6 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
950
1307
|
[
|
|
951
1308
|
...commonKeys,
|
|
952
1309
|
'frames',
|
|
953
|
-
'rootPlayerResumeTokens',
|
|
954
1310
|
'pendingBossQuestions',
|
|
955
1311
|
'lastError',
|
|
956
1312
|
],
|
|
@@ -961,9 +1317,9 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
961
1317
|
'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
|
|
962
1318
|
);
|
|
963
1319
|
}
|
|
964
|
-
if (snapshot.schemaVersion !==
|
|
1320
|
+
if (snapshot.schemaVersion !== 3) {
|
|
965
1321
|
throw new TypeError(
|
|
966
|
-
`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected
|
|
1322
|
+
`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
|
|
967
1323
|
);
|
|
968
1324
|
}
|
|
969
1325
|
|
|
@@ -973,7 +1329,7 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
973
1329
|
);
|
|
974
1330
|
rejectSnapshotKeys(
|
|
975
1331
|
captain,
|
|
976
|
-
['sessionId', 'runtime', 'conversation'],
|
|
1332
|
+
['sessionId', 'runtime', 'agent', 'conversation'],
|
|
977
1333
|
'Captain shell snapshot.captain',
|
|
978
1334
|
);
|
|
979
1335
|
const captainSessionId = snapshotUuid(
|
|
@@ -984,6 +1340,10 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
984
1340
|
captain.runtime,
|
|
985
1341
|
INTERNAL_CAPTAIN_ID,
|
|
986
1342
|
);
|
|
1343
|
+
const captainAgent = snapshotFixedAgent(
|
|
1344
|
+
captain.agent,
|
|
1345
|
+
'Captain shell snapshot.captain.agent',
|
|
1346
|
+
);
|
|
987
1347
|
const conversation = snapshotRecord(
|
|
988
1348
|
captain.conversation,
|
|
989
1349
|
'Captain shell snapshot.captain.conversation',
|
|
@@ -1000,7 +1360,26 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1000
1360
|
token: snapshotString(
|
|
1001
1361
|
conversation.token,
|
|
1002
1362
|
'Captain shell snapshot.captain.conversation.token',
|
|
1003
|
-
|
|
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',
|
|
1004
1383
|
),
|
|
1005
1384
|
};
|
|
1006
1385
|
} else if (
|
|
@@ -1131,6 +1510,29 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1131
1510
|
'Captain shell snapshot sequences do not match the complete journal',
|
|
1132
1511
|
);
|
|
1133
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
|
+
}
|
|
1134
1536
|
|
|
1135
1537
|
let lastAction: PlaybookCaptainShellSnapshotFields['lastAction'];
|
|
1136
1538
|
if (snapshot.lastAction !== undefined) {
|
|
@@ -1162,13 +1564,19 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1162
1564
|
typeof lastSettlementStatus
|
|
1163
1565
|
>;
|
|
1164
1566
|
}
|
|
1567
|
+
const playerSessions = snapshotPlayerSessions(
|
|
1568
|
+
snapshot.playerSessions,
|
|
1569
|
+
'Captain shell snapshot.playerSessions',
|
|
1570
|
+
);
|
|
1165
1571
|
const common: PlaybookCaptainShellSnapshotFields = {
|
|
1166
|
-
schemaVersion:
|
|
1572
|
+
schemaVersion: 3,
|
|
1167
1573
|
captain: {
|
|
1168
1574
|
sessionId: captainSessionId,
|
|
1169
1575
|
runtime: captainRuntime,
|
|
1576
|
+
agent: captainAgent,
|
|
1170
1577
|
conversation: normalizedConversation,
|
|
1171
1578
|
},
|
|
1579
|
+
playerSessions,
|
|
1172
1580
|
issuedSessionIds: issued,
|
|
1173
1581
|
sequences: { turn: turnSequence, journal: journalSequence },
|
|
1174
1582
|
journal: normalizedJournal,
|
|
@@ -1177,6 +1585,23 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1177
1585
|
? {}
|
|
1178
1586
|
: { lastSettlementStatus }),
|
|
1179
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
|
+
}
|
|
1180
1605
|
if (mode === 'chat') {
|
|
1181
1606
|
return snapshotJsonValue(
|
|
1182
1607
|
{ ...common, mode },
|
|
@@ -1204,6 +1629,8 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1204
1629
|
'depth',
|
|
1205
1630
|
'parentSessionId',
|
|
1206
1631
|
'parentCallId',
|
|
1632
|
+
'options',
|
|
1633
|
+
'roleBindings',
|
|
1207
1634
|
'runtime',
|
|
1208
1635
|
],
|
|
1209
1636
|
`Captain shell snapshot.frames[${index}]`,
|
|
@@ -1243,6 +1670,11 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1243
1670
|
playbookId,
|
|
1244
1671
|
{ allowSuspendedCall: true },
|
|
1245
1672
|
);
|
|
1673
|
+
const options = frame.options as JsonValue;
|
|
1674
|
+
const roleBindings = snapshotFrameRoleBindings(
|
|
1675
|
+
frame.roleBindings,
|
|
1676
|
+
`Captain shell snapshot.frames[${index}].roleBindings`,
|
|
1677
|
+
);
|
|
1246
1678
|
normalizedFrames.push({
|
|
1247
1679
|
playbookId,
|
|
1248
1680
|
sessionId,
|
|
@@ -1250,23 +1682,11 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1250
1682
|
depth,
|
|
1251
1683
|
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
1252
1684
|
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
1685
|
+
options,
|
|
1686
|
+
roleBindings,
|
|
1253
1687
|
runtime,
|
|
1254
1688
|
});
|
|
1255
1689
|
}
|
|
1256
|
-
|
|
1257
|
-
const rootTokens = snapshotRecord(
|
|
1258
|
-
snapshot.rootPlayerResumeTokens,
|
|
1259
|
-
'Captain shell snapshot.rootPlayerResumeTokens',
|
|
1260
|
-
);
|
|
1261
|
-
const normalizedRootTokens = Object.fromEntries(
|
|
1262
|
-
Object.entries(rootTokens).map(([playerId, token]) => [
|
|
1263
|
-
playerId,
|
|
1264
|
-
snapshotString(
|
|
1265
|
-
token,
|
|
1266
|
-
`Captain shell snapshot.rootPlayerResumeTokens.${playerId}`,
|
|
1267
|
-
),
|
|
1268
|
-
]),
|
|
1269
|
-
);
|
|
1270
1690
|
let normalizedLastError:
|
|
1271
1691
|
| { readonly name: string; readonly message: string }
|
|
1272
1692
|
| undefined;
|
|
@@ -1293,12 +1713,123 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1293
1713
|
),
|
|
1294
1714
|
};
|
|
1295
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
|
+
}
|
|
1808
|
+
}
|
|
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
|
+
}
|
|
1296
1828
|
return snapshotJsonValue(
|
|
1297
1829
|
{
|
|
1298
1830
|
...common,
|
|
1299
1831
|
mode,
|
|
1300
1832
|
frames: normalizedFrames,
|
|
1301
|
-
rootPlayerResumeTokens: normalizedRootTokens,
|
|
1302
1833
|
...(snapshot.pendingBossQuestions === undefined
|
|
1303
1834
|
? {}
|
|
1304
1835
|
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
@@ -1310,30 +1841,120 @@ function assertPlaybookCaptainShellSnapshot(
|
|
|
1310
1841
|
) as unknown as PlaybookCaptainShellSnapshot;
|
|
1311
1842
|
}
|
|
1312
1843
|
|
|
1313
|
-
function readPlaybooksConfig(
|
|
1314
|
-
options: unknown,
|
|
1315
|
-
): Record<string, unknown> | undefined {
|
|
1316
|
-
if (typeof options !== 'object' || options === null) return undefined;
|
|
1317
|
-
const pb = (options as Record<string, unknown>).playbooks;
|
|
1318
|
-
if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
|
|
1319
|
-
return undefined;
|
|
1320
|
-
}
|
|
1321
|
-
return pb as Record<string, unknown>;
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
1844
|
interface BuiltRegistry {
|
|
1325
1845
|
entries: readonly PlaybookCaptainRegistryEntry[];
|
|
1326
1846
|
byCommand: Map<string, PlaybookCaptainRegistryEntry>;
|
|
1327
1847
|
byId: Map<string, PlaybookCaptainRegistryEntry>;
|
|
1328
1848
|
enablementById: Map<string, Enablement>;
|
|
1849
|
+
captainAgent: SessionAgent;
|
|
1850
|
+
playerAgents: Map<string, SessionAgent>;
|
|
1329
1851
|
}
|
|
1330
1852
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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>;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
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.
|
|
1334
1956
|
async function buildEnablements(
|
|
1335
1957
|
options: unknown,
|
|
1336
|
-
players: readonly RegistryPlayer[],
|
|
1337
1958
|
loadModule: (specifier: string) => Promise<unknown>,
|
|
1338
1959
|
): Promise<BuiltRegistry> {
|
|
1339
1960
|
const entries: PlaybookCaptainRegistryEntry[] = [];
|
|
@@ -1341,10 +1962,63 @@ async function buildEnablements(
|
|
|
1341
1962
|
const byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
1342
1963
|
const enablementById = new Map<string, Enablement>();
|
|
1343
1964
|
|
|
1344
|
-
const
|
|
1345
|
-
|
|
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
|
+
) {
|
|
1346
1978
|
throw new Error('captain.options.playbooks is required');
|
|
1347
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
|
+
}
|
|
1348
2022
|
|
|
1349
2023
|
const ids = Object.keys(config);
|
|
1350
2024
|
if (ids.length === 0) {
|
|
@@ -1362,7 +2036,12 @@ async function buildEnablements(
|
|
|
1362
2036
|
if (typeof block !== 'object' || block === null || Array.isArray(block)) {
|
|
1363
2037
|
throw new Error(`captain.options.playbooks.${id} must be an object`);
|
|
1364
2038
|
}
|
|
1365
|
-
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
|
+
);
|
|
1366
2045
|
const from = record.from;
|
|
1367
2046
|
if (typeof from !== 'string' || from.length === 0) {
|
|
1368
2047
|
throw new Error(
|
|
@@ -1409,26 +2088,86 @@ async function buildEnablements(
|
|
|
1409
2088
|
`captain.options.playbooks has a duplicate effective command "${command}"`,
|
|
1410
2089
|
);
|
|
1411
2090
|
}
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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
|
+
);
|
|
1420
2142
|
entries.push(entry);
|
|
1421
2143
|
byId.set(entry.id, entry);
|
|
1422
2144
|
byCommand.set(command, entry);
|
|
1423
2145
|
enablementById.set(entry.id, {
|
|
1424
2146
|
entry,
|
|
1425
2147
|
command,
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
2148
|
+
options: validatedOptions,
|
|
2149
|
+
roleBindings,
|
|
1429
2150
|
});
|
|
1430
2151
|
}
|
|
1431
|
-
|
|
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
|
+
};
|
|
1432
2171
|
}
|
|
1433
2172
|
|
|
1434
2173
|
export function createPlaybookCaptainShell(
|
|
@@ -1441,10 +2180,11 @@ export function createPlaybookCaptainShell(
|
|
|
1441
2180
|
const createCaptainRuntime: NonNullable<
|
|
1442
2181
|
PlaybookCaptainDeps['createCaptainRuntime']
|
|
1443
2182
|
> = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
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>();
|
|
1448
2188
|
let entries: readonly PlaybookCaptainRegistryEntry[] = [];
|
|
1449
2189
|
let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
1450
2190
|
let byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
@@ -1460,7 +2200,6 @@ export function createPlaybookCaptainShell(
|
|
|
1460
2200
|
| 'disposing'
|
|
1461
2201
|
| 'closed' = 'fresh';
|
|
1462
2202
|
let terminallyDisposed = false;
|
|
1463
|
-
let players: readonly RegistryPlayer[] = [];
|
|
1464
2203
|
let activeContext: CaptainContext | undefined;
|
|
1465
2204
|
const frames: EngagementFrame[] = [];
|
|
1466
2205
|
let mode: ShellMode = 'chat';
|
|
@@ -1512,15 +2251,19 @@ export function createPlaybookCaptainShell(
|
|
|
1512
2251
|
// --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
|
|
1513
2252
|
let captainRuntime: PlaybookRuntime | undefined;
|
|
1514
2253
|
let captainSessionId: string | undefined;
|
|
1515
|
-
// CAPTAIN-35:
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
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.
|
|
1518
2257
|
let conversation: DurableConversation = { kind: 'unopened' };
|
|
1519
2258
|
let shuttingDown = false;
|
|
1520
2259
|
const journal: JournalRecord[] = [];
|
|
1521
2260
|
let journalSeq = 0;
|
|
1522
2261
|
let turnSequence = 0;
|
|
1523
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;
|
|
1524
2267
|
// The durable call the runtime is about to make, taken from the paired
|
|
1525
2268
|
// `captain.call.started` boundary the engine emits before the port call
|
|
1526
2269
|
// (CAPTAIN-9): the shell never infers a call's kind from its prose.
|
|
@@ -1609,7 +2352,10 @@ export function createPlaybookCaptainShell(
|
|
|
1609
2352
|
// (CAPTAIN-5/CAPTAIN-6).
|
|
1610
2353
|
...(captainRuntime
|
|
1611
2354
|
? {
|
|
1612
|
-
durableConversation:
|
|
2355
|
+
durableConversation:
|
|
2356
|
+
conversation.kind === 'pinned' ||
|
|
2357
|
+
(conversation.kind === 'needsCatchUp' &&
|
|
2358
|
+
conversation.resume !== false),
|
|
1613
2359
|
sessionJournal: true,
|
|
1614
2360
|
}
|
|
1615
2361
|
: {}),
|
|
@@ -1826,42 +2572,146 @@ export function createPlaybookCaptainShell(
|
|
|
1826
2572
|
) => Promise<PlaybookCallStart>;
|
|
1827
2573
|
|
|
1828
2574
|
const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
|
|
1829
|
-
callPlayer: async (
|
|
2575
|
+
callPlayer: async (roleId, prompt, signal, options) => {
|
|
1830
2576
|
admitHostBoundary();
|
|
1831
|
-
if (!activeContext) {
|
|
2577
|
+
if (!activeContext || !activeTurn || !frame.playerCallScope) {
|
|
1832
2578
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
1833
2579
|
}
|
|
1834
2580
|
const context = activeContext;
|
|
2581
|
+
const admittedTurn = activeTurn;
|
|
2582
|
+
const scope = frame.playerCallScope;
|
|
1835
2583
|
signal.throwIfAborted();
|
|
1836
|
-
const
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
);
|
|
1843
|
-
// CaptainContext is turn-scoped and cannot accept a narrower XState
|
|
1844
|
-
// invocation signal. Recheck after the host call so a sibling
|
|
1845
|
-
// cancellation is still reported as aborted and cannot rotate a
|
|
1846
|
-
// stopped branch's player token in the linked runtime.
|
|
1847
|
-
signal.throwIfAborted();
|
|
1848
|
-
// CAPTAIN-20: only a player call that actually produced work is an
|
|
1849
|
-
// interruption the Boss was spared. A call that errored or aborted
|
|
1850
|
-
// saved nothing, so it never feeds the saved-counts gate.
|
|
1851
|
-
const summary = activeTurnSummary;
|
|
1852
|
-
if (summary && summaryIncludes(frame) && result.status === 'ok') {
|
|
1853
|
-
summary.counts.interruptions++;
|
|
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
|
+
);
|
|
1854
2590
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
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,
|
|
1864
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;
|
|
1865
2715
|
},
|
|
1866
2716
|
callCaptain: async (prompt, signal, options) => {
|
|
1867
2717
|
admitHostBoundary();
|
|
@@ -1962,14 +2812,16 @@ export function createPlaybookCaptainShell(
|
|
|
1962
2812
|
});
|
|
1963
2813
|
|
|
1964
2814
|
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
1965
|
-
// visibility for that playbook's
|
|
2815
|
+
// visibility for that playbook's explicitly bound session players. A pane
|
|
1966
2816
|
// reconciliation failure is display-only in tmux-play and does not
|
|
1967
2817
|
// reject; the legacy path carries no generated set and skips this.
|
|
1968
2818
|
const requestVisibility = async (frame: EngagementFrame): Promise<void> => {
|
|
1969
2819
|
const ids = [...new Set(
|
|
1970
|
-
[...frame.playerBindings.values()].map(({
|
|
2820
|
+
[...frame.playerBindings.values()].map(({ playerId }) => playerId),
|
|
1971
2821
|
)];
|
|
1972
|
-
|
|
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;
|
|
1973
2825
|
try {
|
|
1974
2826
|
await activeContext.setVisiblePlayers(ids);
|
|
1975
2827
|
} catch (error) {
|
|
@@ -2009,32 +2861,8 @@ export function createPlaybookCaptainShell(
|
|
|
2009
2861
|
|
|
2010
2862
|
const makePlayerBindings = (
|
|
2011
2863
|
enablement: Enablement,
|
|
2012
|
-
parent?: { frame: EngagementFrame; callId: string },
|
|
2013
2864
|
): ReadonlyMap<string, EffectivePlayerBinding> => {
|
|
2014
|
-
|
|
2015
|
-
const playerBindings = new Map<string, EffectivePlayerBinding>();
|
|
2016
|
-
for (const role of entry.requiredRoleIds) {
|
|
2017
|
-
let inherited: EffectivePlayerBinding | undefined;
|
|
2018
|
-
for (
|
|
2019
|
-
let ancestor = parent?.frame;
|
|
2020
|
-
ancestor && inherited === undefined;
|
|
2021
|
-
ancestor = ancestor.parent?.frame
|
|
2022
|
-
) {
|
|
2023
|
-
inherited = ancestor.playerBindings.get(role);
|
|
2024
|
-
}
|
|
2025
|
-
if (inherited) {
|
|
2026
|
-
playerBindings.set(role, inherited);
|
|
2027
|
-
continue;
|
|
2028
|
-
}
|
|
2029
|
-
const configured = enablement.boundPlayers.find(
|
|
2030
|
-
(player) => player.id === role,
|
|
2031
|
-
) ?? { id: role };
|
|
2032
|
-
playerBindings.set(role, {
|
|
2033
|
-
hostPlayerId: enablement.hostPlayerId(role),
|
|
2034
|
-
player: configured,
|
|
2035
|
-
});
|
|
2036
|
-
}
|
|
2037
|
-
return playerBindings;
|
|
2865
|
+
return new Map(enablement.roleBindings);
|
|
2038
2866
|
};
|
|
2039
2867
|
|
|
2040
2868
|
const makeFrame = (
|
|
@@ -2043,17 +2871,8 @@ export function createPlaybookCaptainShell(
|
|
|
2043
2871
|
): EngagementFrame => {
|
|
2044
2872
|
const entry = enablement.entry;
|
|
2045
2873
|
const sessionId = allocateSessionId();
|
|
2046
|
-
const playerBindings = makePlayerBindings(enablement
|
|
2047
|
-
const
|
|
2048
|
-
parent?.frame.playerResumeTokens ?? new Map<string, string>();
|
|
2049
|
-
const runtime = entry.createRuntime({
|
|
2050
|
-
captainOptions: enablement.optionInput,
|
|
2051
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
2052
|
-
id: role,
|
|
2053
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
2054
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
2055
|
-
})),
|
|
2056
|
-
});
|
|
2874
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
2875
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
2057
2876
|
return {
|
|
2058
2877
|
entry,
|
|
2059
2878
|
enablement,
|
|
@@ -2062,7 +2881,6 @@ export function createPlaybookCaptainShell(
|
|
|
2062
2881
|
rootSessionId: parent?.frame.rootSessionId ?? sessionId,
|
|
2063
2882
|
depth: parent ? parent.frame.depth + 1 : 0,
|
|
2064
2883
|
playerBindings,
|
|
2065
|
-
playerResumeTokens,
|
|
2066
2884
|
...(parent ? { parent } : {}),
|
|
2067
2885
|
inFlightHostCalls: new Set(),
|
|
2068
2886
|
};
|
|
@@ -2071,19 +2889,11 @@ export function createPlaybookCaptainShell(
|
|
|
2071
2889
|
const makeRestoredFrame = (
|
|
2072
2890
|
enablement: Enablement,
|
|
2073
2891
|
snapshot: PlaybookCaptainFrameSnapshot,
|
|
2074
|
-
rootPlayerResumeTokens: Map<string, string>,
|
|
2075
2892
|
parent?: { frame: EngagementFrame; callId: string },
|
|
2076
2893
|
): EngagementFrame => {
|
|
2077
2894
|
const entry = enablement.entry;
|
|
2078
|
-
const playerBindings = makePlayerBindings(enablement
|
|
2079
|
-
const runtime = entry.createRuntime(
|
|
2080
|
-
captainOptions: enablement.optionInput,
|
|
2081
|
-
players: [...playerBindings].map(([role, { player }]) => ({
|
|
2082
|
-
id: role,
|
|
2083
|
-
...(player.adapter === undefined ? {} : { adapter: player.adapter }),
|
|
2084
|
-
...(player.model === undefined ? {} : { model: player.model }),
|
|
2085
|
-
})),
|
|
2086
|
-
});
|
|
2895
|
+
const playerBindings = makePlayerBindings(enablement);
|
|
2896
|
+
const runtime = entry.createRuntime(enablement.options);
|
|
2087
2897
|
return {
|
|
2088
2898
|
entry,
|
|
2089
2899
|
enablement,
|
|
@@ -2092,7 +2902,6 @@ export function createPlaybookCaptainShell(
|
|
|
2092
2902
|
rootSessionId: snapshot.rootSessionId,
|
|
2093
2903
|
depth: snapshot.depth,
|
|
2094
2904
|
playerBindings,
|
|
2095
|
-
playerResumeTokens: rootPlayerResumeTokens,
|
|
2096
2905
|
...(parent ? { parent } : {}),
|
|
2097
2906
|
state: snapshot.runtime.state,
|
|
2098
2907
|
inFlightHostCalls: new Set(),
|
|
@@ -2102,35 +2911,159 @@ export function createPlaybookCaptainShell(
|
|
|
2102
2911
|
const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
|
|
2103
2912
|
select(playerId) {
|
|
2104
2913
|
const binding = bindingFor(frame, playerId);
|
|
2105
|
-
return
|
|
2914
|
+
return playerLedger.get(binding.playerId)?.resumeToken ?? false;
|
|
2106
2915
|
},
|
|
2107
2916
|
update(playerId, resumeToken) {
|
|
2108
2917
|
const binding = bindingFor(frame, playerId);
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
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);
|
|
2113
2965
|
}
|
|
2114
2966
|
},
|
|
2115
2967
|
snapshot() {
|
|
2116
2968
|
const tokens: Record<string, string> = {};
|
|
2117
2969
|
for (const [playerId, binding] of frame.playerBindings) {
|
|
2118
|
-
const token =
|
|
2970
|
+
const token = playerLedger.get(binding.playerId)?.resumeToken;
|
|
2119
2971
|
if (token !== undefined) tokens[playerId] = token;
|
|
2120
2972
|
}
|
|
2121
2973
|
return tokens;
|
|
2122
2974
|
},
|
|
2123
2975
|
restore(tokens) {
|
|
2124
|
-
|
|
2125
|
-
|
|
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
|
+
);
|
|
2126
2983
|
}
|
|
2984
|
+
const byPlayer = new Map<string, string | undefined>();
|
|
2127
2985
|
for (const [playerId, token] of Object.entries(tokens)) {
|
|
2128
2986
|
const binding = bindingFor(frame, playerId);
|
|
2129
|
-
|
|
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;
|
|
2130
3007
|
}
|
|
2131
3008
|
},
|
|
2132
3009
|
});
|
|
2133
3010
|
|
|
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
|
+
|
|
2134
3067
|
const frameSession = (frame: EngagementFrame) => ({
|
|
2135
3068
|
sessionId: frame.sessionId,
|
|
2136
3069
|
playbookId: frame.entry.id,
|
|
@@ -2142,6 +3075,15 @@ export function createPlaybookCaptainShell(
|
|
|
2142
3075
|
}
|
|
2143
3076
|
: {}),
|
|
2144
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
|
+
),
|
|
2145
3087
|
playerSessions: playerSessionStore(frame),
|
|
2146
3088
|
ports: createPorts(frame),
|
|
2147
3089
|
});
|
|
@@ -2465,8 +3407,10 @@ export function createPlaybookCaptainShell(
|
|
|
2465
3407
|
// exception filed against an effect that never ran.
|
|
2466
3408
|
await requestVisibility(frame);
|
|
2467
3409
|
await setMode('engaged.driving', 'submit');
|
|
2468
|
-
const result = await
|
|
2469
|
-
|
|
3410
|
+
const result = await runFrameOperation(frame, () =>
|
|
3411
|
+
runEffect(() =>
|
|
3412
|
+
frame.runtime.handleBossInput({ text, signal }),
|
|
3413
|
+
),
|
|
2470
3414
|
);
|
|
2471
3415
|
frame.state = result.state;
|
|
2472
3416
|
return result;
|
|
@@ -2520,12 +3464,14 @@ export function createPlaybookCaptainShell(
|
|
|
2520
3464
|
}
|
|
2521
3465
|
let result: PlaybookRunResult;
|
|
2522
3466
|
try {
|
|
2523
|
-
result = await
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
3467
|
+
result = await runFrameOperation(parent, () =>
|
|
3468
|
+
runEffect(() =>
|
|
3469
|
+
parent.runtime.resumePlaybookCall({
|
|
3470
|
+
callId: parentLink.callId,
|
|
3471
|
+
result: effectiveResult,
|
|
3472
|
+
signal: context.signal,
|
|
3473
|
+
}),
|
|
3474
|
+
),
|
|
2529
3475
|
);
|
|
2530
3476
|
} catch (error) {
|
|
2531
3477
|
if (disposing || invocationSignal?.aborted) return;
|
|
@@ -2949,7 +3895,9 @@ export function createPlaybookCaptainShell(
|
|
|
2949
3895
|
const pending = view.pendingQuestions.map(
|
|
2950
3896
|
(question) =>
|
|
2951
3897
|
digestLine`- (${quoteEvidence(question.questionId)}) ${quoteEvidence(
|
|
2952
|
-
question.
|
|
3898
|
+
question.asker.kind === 'captain'
|
|
3899
|
+
? 'Captain'
|
|
3900
|
+
: question.asker.roleId,
|
|
2953
3901
|
)} asks: ${quoteEvidence(question.question)}`,
|
|
2954
3902
|
);
|
|
2955
3903
|
lines.push(
|
|
@@ -3073,6 +4021,7 @@ export function createPlaybookCaptainShell(
|
|
|
3073
4021
|
try {
|
|
3074
4022
|
await trackTurnCall(settlement.context.emitReply(settlement.text));
|
|
3075
4023
|
} catch (error) {
|
|
4024
|
+
conversation = { kind: 'needsSeeding' };
|
|
3076
4025
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
3077
4026
|
name: 'Error',
|
|
3078
4027
|
message: String(error),
|
|
@@ -3179,8 +4128,11 @@ export function createPlaybookCaptainShell(
|
|
|
3179
4128
|
* text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
|
|
3180
4129
|
* the Captain may need to repeat.
|
|
3181
4130
|
*/
|
|
3182
|
-
const
|
|
3183
|
-
|
|
4131
|
+
const conversationDigest = (
|
|
4132
|
+
records: readonly JournalRecord[],
|
|
4133
|
+
render: (records: readonly JournalRecord[]) => string,
|
|
4134
|
+
): string => {
|
|
4135
|
+
for (const record of records) {
|
|
3184
4136
|
if (
|
|
3185
4137
|
record.kind === 'action' &&
|
|
3186
4138
|
typeof record.payload === 'object' &&
|
|
@@ -3191,14 +4143,47 @@ export function createPlaybookCaptainShell(
|
|
|
3191
4143
|
if (typeof actionId === 'string') recordSuppliedIdentifier(actionId);
|
|
3192
4144
|
}
|
|
3193
4145
|
}
|
|
3194
|
-
return
|
|
4146
|
+
return render(records);
|
|
3195
4147
|
};
|
|
3196
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
|
+
|
|
3197
4158
|
const markControlFailure = <E>(error: E): E => {
|
|
3198
|
-
|
|
4159
|
+
activeTurn?.controlFailures.add(error);
|
|
3199
4160
|
return error;
|
|
3200
4161
|
};
|
|
3201
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
|
+
|
|
3202
4187
|
/**
|
|
3203
4188
|
* CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
|
|
3204
4189
|
* engagement constructed, a stack disposed, an advertised action applied.
|
|
@@ -3253,6 +4238,7 @@ export function createPlaybookCaptainShell(
|
|
|
3253
4238
|
context: CaptainContext,
|
|
3254
4239
|
prompt: string,
|
|
3255
4240
|
resume: string | false,
|
|
4241
|
+
attempt: { providerBoundaryEntered: boolean },
|
|
3256
4242
|
): Promise<{
|
|
3257
4243
|
status: string;
|
|
3258
4244
|
finalText?: string;
|
|
@@ -3261,11 +4247,15 @@ export function createPlaybookCaptainShell(
|
|
|
3261
4247
|
}> => {
|
|
3262
4248
|
const queued = captainQueue.add(async () => {
|
|
3263
4249
|
context.signal.throwIfAborted();
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
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
|
+
);
|
|
3269
4259
|
context.signal.throwIfAborted();
|
|
3270
4260
|
return result;
|
|
3271
4261
|
});
|
|
@@ -3286,8 +4276,17 @@ export function createPlaybookCaptainShell(
|
|
|
3286
4276
|
context: CaptainContext,
|
|
3287
4277
|
compose: (options: { reseedDigest?: string }) => string,
|
|
3288
4278
|
): Promise<DurableCallOutcome> => {
|
|
3289
|
-
const
|
|
4279
|
+
const startingConversation = conversation;
|
|
4280
|
+
const resume =
|
|
4281
|
+
startingConversation.kind === 'pinned'
|
|
4282
|
+
? startingConversation.token
|
|
4283
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
4284
|
+
? startingConversation.resume
|
|
4285
|
+
: false;
|
|
3290
4286
|
const seedFirstCall = conversation.kind === 'needsSeeding';
|
|
4287
|
+
const catchUpFirstCall = conversation.kind === 'needsCatchUp';
|
|
4288
|
+
const representedJournalSeq = journalSeq;
|
|
4289
|
+
const firstAttempt = { providerBoundaryEntered: false };
|
|
3291
4290
|
let result:
|
|
3292
4291
|
| { status: string; finalText?: string; resumeToken?: string; error?: string }
|
|
3293
4292
|
| undefined;
|
|
@@ -3296,14 +4295,42 @@ export function createPlaybookCaptainShell(
|
|
|
3296
4295
|
result = await rawDurableCall(
|
|
3297
4296
|
context,
|
|
3298
4297
|
compose(
|
|
3299
|
-
seedFirstCall
|
|
4298
|
+
seedFirstCall
|
|
4299
|
+
? { reseedDigest: reseedDigest() }
|
|
4300
|
+
: startingConversation.kind === 'needsCatchUp'
|
|
4301
|
+
? {
|
|
4302
|
+
reseedDigest: catchUpDigest(
|
|
4303
|
+
startingConversation.afterJournalSeq,
|
|
4304
|
+
),
|
|
4305
|
+
}
|
|
4306
|
+
: {},
|
|
3300
4307
|
),
|
|
3301
4308
|
resume,
|
|
4309
|
+
firstAttempt,
|
|
3302
4310
|
);
|
|
3303
4311
|
} catch (error) {
|
|
3304
4312
|
if (context.signal.aborted) {
|
|
3305
|
-
|
|
3306
|
-
|
|
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);
|
|
3307
4334
|
}
|
|
3308
4335
|
failure = error;
|
|
3309
4336
|
}
|
|
@@ -3314,11 +4341,14 @@ export function createPlaybookCaptainShell(
|
|
|
3314
4341
|
result.resumeToken === undefined;
|
|
3315
4342
|
if (!unsynchronized) {
|
|
3316
4343
|
conversation = { kind: 'pinned', token: result!.resumeToken! };
|
|
4344
|
+
if (activeTurn) {
|
|
4345
|
+
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
4346
|
+
}
|
|
3317
4347
|
return {
|
|
3318
4348
|
...(result!.finalText !== undefined
|
|
3319
4349
|
? { finalText: result!.finalText }
|
|
3320
4350
|
: {}),
|
|
3321
|
-
correctiveSpent: seedFirstCall,
|
|
4351
|
+
correctiveSpent: seedFirstCall || catchUpFirstCall,
|
|
3322
4352
|
};
|
|
3323
4353
|
}
|
|
3324
4354
|
// Only the model-side conversation is replaced: the stack, player
|
|
@@ -3330,16 +4360,21 @@ export function createPlaybookCaptainShell(
|
|
|
3330
4360
|
let reissued:
|
|
3331
4361
|
| { status: string; finalText?: string; resumeToken?: string; error?: string }
|
|
3332
4362
|
| undefined;
|
|
4363
|
+
const reissueAttempt = { providerBoundaryEntered: false };
|
|
3333
4364
|
try {
|
|
3334
4365
|
reissued = await rawDurableCall(
|
|
3335
4366
|
context,
|
|
3336
4367
|
compose({ reseedDigest: recap }),
|
|
3337
4368
|
false,
|
|
4369
|
+
reissueAttempt,
|
|
3338
4370
|
);
|
|
3339
4371
|
} catch (error) {
|
|
3340
4372
|
if (context.signal.aborted) {
|
|
3341
4373
|
conversation = { kind: 'needsSeeding' };
|
|
3342
|
-
throw error;
|
|
4374
|
+
throw context.signal.reason ?? error;
|
|
4375
|
+
}
|
|
4376
|
+
if (error instanceof AgentSettingsPreflightError) {
|
|
4377
|
+
throw rememberSettingsPreflight(error.rejection);
|
|
3343
4378
|
}
|
|
3344
4379
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
3345
4380
|
}
|
|
@@ -3352,6 +4387,7 @@ export function createPlaybookCaptainShell(
|
|
|
3352
4387
|
);
|
|
3353
4388
|
}
|
|
3354
4389
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
4390
|
+
if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
3355
4391
|
return {
|
|
3356
4392
|
...(reissued.finalText !== undefined
|
|
3357
4393
|
? { finalText: reissued.finalText }
|
|
@@ -3758,7 +4794,7 @@ export function createPlaybookCaptainShell(
|
|
|
3758
4794
|
message: String(error),
|
|
3759
4795
|
};
|
|
3760
4796
|
if (aborted) {
|
|
3761
|
-
|
|
4797
|
+
markConversationUnsynchronized();
|
|
3762
4798
|
if (turn?.outcomePending) {
|
|
3763
4799
|
turn.settlementFacts.push(
|
|
3764
4800
|
`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`,
|
|
@@ -4158,7 +5194,11 @@ export function createPlaybookCaptainShell(
|
|
|
4158
5194
|
// receipt rather than acting twice.
|
|
4159
5195
|
const key = `turn-${turn.id}-apply-${actionId}`;
|
|
4160
5196
|
const outcome = await withCounting(leaf, async () =>
|
|
4161
|
-
|
|
5197
|
+
runFrameOperation(leaf, () =>
|
|
5198
|
+
runEffect(() =>
|
|
5199
|
+
leaf.runtime.apply!({ actionId, key, signal }),
|
|
5200
|
+
),
|
|
5201
|
+
),
|
|
4162
5202
|
);
|
|
4163
5203
|
if (outcome.error !== undefined) throw outcome.error;
|
|
4164
5204
|
const receipt = outcome.result!;
|
|
@@ -4322,7 +5362,11 @@ export function createPlaybookCaptainShell(
|
|
|
4322
5362
|
// The durable Captain conversation did not receive the shell-authored
|
|
4323
5363
|
// fallback. Force its next call through the journal so it cannot interpret
|
|
4324
5364
|
// the Boss's follow-up without the reply the Boss was given this turn.
|
|
4325
|
-
|
|
5365
|
+
if (activeTurn?.settingsPreflightFailures.has(error)) {
|
|
5366
|
+
markConversationCatchUp();
|
|
5367
|
+
} else {
|
|
5368
|
+
markConversationUnsynchronized();
|
|
5369
|
+
}
|
|
4326
5370
|
// A rejected presentation may already have emitted bytes. It is therefore
|
|
4327
5371
|
// final for this turn even though the Promise did not prove it was shown.
|
|
4328
5372
|
if (activeTurn?.presentationAttempted === true) return;
|
|
@@ -4353,164 +5397,93 @@ export function createPlaybookCaptainShell(
|
|
|
4353
5397
|
playbookId: INTERNAL_CAPTAIN_ID,
|
|
4354
5398
|
rootSessionId: id,
|
|
4355
5399
|
depth: 0,
|
|
5400
|
+
roleBindings: {},
|
|
4356
5401
|
ports: captainPorts(),
|
|
4357
5402
|
});
|
|
4358
5403
|
|
|
4359
|
-
const
|
|
4360
|
-
|
|
4361
|
-
|
|
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
|
+
);
|
|
4362
5424
|
|
|
4363
5425
|
const assertSnapshotMatchesEnablements = (
|
|
4364
5426
|
snapshot: PlaybookCaptainShellSnapshot,
|
|
4365
5427
|
enabled: ReadonlyMap<string, Enablement>,
|
|
4366
5428
|
): void => {
|
|
4367
|
-
const captain = snapshot.captain.runtime;
|
|
4368
5429
|
if (
|
|
4369
|
-
|
|
4370
|
-
captain.
|
|
4371
|
-
!captain.state.quiescent ||
|
|
4372
|
-
!captain.state.tags.includes('playbook.parked') ||
|
|
4373
|
-
captain.suspendedCall !== undefined ||
|
|
4374
|
-
Object.keys(captain.playerResumeTokens).length > 0 ||
|
|
4375
|
-
captain.pendingBossQuestions.length > 0
|
|
5430
|
+
captainAgent === undefined ||
|
|
5431
|
+
!isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))
|
|
4376
5432
|
) {
|
|
4377
5433
|
throw new TypeError(
|
|
4378
|
-
'Captain shell snapshot Captain
|
|
4379
|
-
);
|
|
4380
|
-
}
|
|
4381
|
-
if (captain.sequences.turn !== snapshot.sequences.turn) {
|
|
4382
|
-
throw new TypeError(
|
|
4383
|
-
'Captain shell snapshot Captain and shell turn sequences must match',
|
|
5434
|
+
'Captain shell snapshot Captain agent is incompatible with current config',
|
|
4384
5435
|
);
|
|
4385
5436
|
}
|
|
4386
|
-
const
|
|
4387
|
-
|
|
4388
|
-
if (
|
|
4389
|
-
(snapshot.captain.conversation.kind === 'unopened') !== emptyHistory
|
|
4390
|
-
) {
|
|
5437
|
+
const configuredPlayerIds = [...playerAgents.keys()].sort();
|
|
5438
|
+
const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
|
|
5439
|
+
if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
|
|
4391
5440
|
throw new TypeError(
|
|
4392
|
-
'Captain shell snapshot
|
|
5441
|
+
'Captain shell snapshot player ledger does not match current referenced players',
|
|
4393
5442
|
);
|
|
4394
5443
|
}
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
if (!
|
|
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))) {
|
|
4408
5457
|
throw new TypeError(
|
|
4409
|
-
`Captain shell snapshot
|
|
5458
|
+
`Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`,
|
|
4410
5459
|
);
|
|
4411
5460
|
}
|
|
4412
5461
|
}
|
|
4413
|
-
|
|
4414
|
-
const
|
|
4415
|
-
const rootSessionId = snapshot.frames[0]!.sessionId;
|
|
4416
|
-
for (const [index, frame] of snapshot.frames.entries()) {
|
|
5462
|
+
if (snapshot.mode === 'chat') return;
|
|
5463
|
+
for (const frame of snapshot.frames) {
|
|
4417
5464
|
const enablement = enabled.get(frame.playbookId);
|
|
4418
5465
|
if (!enablement) {
|
|
4419
5466
|
throw new TypeError(
|
|
4420
5467
|
`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`,
|
|
4421
5468
|
);
|
|
4422
5469
|
}
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
if (
|
|
4430
|
-
throw new TypeError(
|
|
4431
|
-
'Captain shell snapshot frame session ids must be unique',
|
|
4432
|
-
);
|
|
4433
|
-
}
|
|
4434
|
-
activeSessionIds.add(frame.sessionId);
|
|
4435
|
-
if (!issuedIds.has(frame.sessionId)) {
|
|
4436
|
-
throw new TypeError(
|
|
4437
|
-
'Captain shell snapshot frame session id was not historically issued',
|
|
4438
|
-
);
|
|
4439
|
-
}
|
|
4440
|
-
if (
|
|
4441
|
-
frame.depth !== index ||
|
|
4442
|
-
frame.rootSessionId !== rootSessionId ||
|
|
4443
|
-
frame.runtime.state.status !== 'active' ||
|
|
4444
|
-
!frame.runtime.state.quiescent
|
|
4445
|
-
) {
|
|
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)) {
|
|
4446
5477
|
throw new TypeError(
|
|
4447
|
-
|
|
5478
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`,
|
|
4448
5479
|
);
|
|
4449
5480
|
}
|
|
4450
|
-
if (
|
|
4451
|
-
if (
|
|
4452
|
-
frame.sessionId !== frame.rootSessionId ||
|
|
4453
|
-
frame.parentSessionId !== undefined ||
|
|
4454
|
-
frame.parentCallId !== undefined
|
|
4455
|
-
) {
|
|
4456
|
-
throw new TypeError(
|
|
4457
|
-
'Captain shell snapshot root frame has child-only identity fields',
|
|
4458
|
-
);
|
|
4459
|
-
}
|
|
4460
|
-
} else {
|
|
4461
|
-
const parent = snapshot.frames[index - 1]!;
|
|
4462
|
-
if (
|
|
4463
|
-
frame.parentSessionId !== parent.sessionId ||
|
|
4464
|
-
frame.parentCallId === undefined
|
|
4465
|
-
) {
|
|
4466
|
-
throw new TypeError(
|
|
4467
|
-
'Captain shell snapshot child frame does not identify its immediate parent',
|
|
4468
|
-
);
|
|
4469
|
-
}
|
|
4470
|
-
const pending = parent.runtime.suspendedCall;
|
|
4471
|
-
if (
|
|
4472
|
-
!pending ||
|
|
4473
|
-
pending.callId !== frame.parentCallId ||
|
|
4474
|
-
pending.playbookId !== frame.playbookId ||
|
|
4475
|
-
pending.childSessionId !== frame.sessionId
|
|
4476
|
-
) {
|
|
4477
|
-
throw new TypeError(
|
|
4478
|
-
'Captain shell snapshot parent suspended call does not match its child edge',
|
|
4479
|
-
);
|
|
4480
|
-
}
|
|
4481
|
-
}
|
|
4482
|
-
|
|
4483
|
-
const roleBindings = new Map<string, string>();
|
|
4484
|
-
for (const role of enablement.entry.requiredRoleIds) {
|
|
4485
|
-
let inherited: string | undefined;
|
|
4486
|
-
for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
|
|
4487
|
-
inherited = bindingMaps[ancestor]?.get(role);
|
|
4488
|
-
if (inherited !== undefined) break;
|
|
4489
|
-
}
|
|
4490
|
-
roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
|
|
4491
|
-
}
|
|
4492
|
-
bindingMaps.push(roleBindings);
|
|
4493
|
-
const projectedTokens = Object.fromEntries(
|
|
4494
|
-
[...roleBindings].flatMap(([role, hostPlayerId]) => {
|
|
4495
|
-
const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
|
|
4496
|
-
return token === undefined ? [] : [[role, token] as const];
|
|
4497
|
-
}),
|
|
4498
|
-
);
|
|
4499
|
-
if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
|
|
5481
|
+
if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
|
|
4500
5482
|
throw new TypeError(
|
|
4501
|
-
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)}
|
|
5483
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
|
|
4502
5484
|
);
|
|
4503
5485
|
}
|
|
4504
5486
|
}
|
|
4505
|
-
const leafRuntime = snapshot.frames.at(-1)!.runtime;
|
|
4506
|
-
if (
|
|
4507
|
-
leafRuntime.suspendedCall !== undefined ||
|
|
4508
|
-
!leafRuntime.state.tags.includes('playbook.parked')
|
|
4509
|
-
) {
|
|
4510
|
-
throw new TypeError(
|
|
4511
|
-
'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
|
|
4512
|
-
);
|
|
4513
|
-
}
|
|
4514
5487
|
};
|
|
4515
5488
|
|
|
4516
5489
|
const safeCapturePoint = (): boolean => {
|
|
@@ -4530,6 +5503,7 @@ export function createPlaybookCaptainShell(
|
|
|
4530
5503
|
runFailureFacts !== undefined ||
|
|
4531
5504
|
servingCall !== undefined ||
|
|
4532
5505
|
decisionCall !== undefined ||
|
|
5506
|
+
playerTransactions.size !== 0 ||
|
|
4533
5507
|
captainQueue.pending !== 0 ||
|
|
4534
5508
|
captainQueue.size !== 0 ||
|
|
4535
5509
|
(mode !== 'chat' && mode !== 'engaged.parked')
|
|
@@ -4575,7 +5549,12 @@ export function createPlaybookCaptainShell(
|
|
|
4575
5549
|
};
|
|
4576
5550
|
|
|
4577
5551
|
const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
|
|
4578
|
-
if (
|
|
5552
|
+
if (
|
|
5553
|
+
!safeCapturePoint() ||
|
|
5554
|
+
!captainRuntime ||
|
|
5555
|
+
!captainSessionId ||
|
|
5556
|
+
!captainAgent
|
|
5557
|
+
) {
|
|
4579
5558
|
return undefined;
|
|
4580
5559
|
}
|
|
4581
5560
|
try {
|
|
@@ -4613,16 +5592,25 @@ export function createPlaybookCaptainShell(
|
|
|
4613
5592
|
parentCallId: frame.parent.callId,
|
|
4614
5593
|
}
|
|
4615
5594
|
: {}),
|
|
5595
|
+
options: frame.enablement.options,
|
|
5596
|
+
roleBindings: Object.fromEntries(
|
|
5597
|
+
[...frame.playerBindings].map(([role, binding]) => [
|
|
5598
|
+
role,
|
|
5599
|
+
binding.playerId,
|
|
5600
|
+
]),
|
|
5601
|
+
),
|
|
4616
5602
|
runtime,
|
|
4617
5603
|
});
|
|
4618
5604
|
}
|
|
4619
5605
|
const common = {
|
|
4620
|
-
schemaVersion:
|
|
5606
|
+
schemaVersion: 3 as const,
|
|
4621
5607
|
captain: {
|
|
4622
5608
|
sessionId: captainSessionId,
|
|
4623
5609
|
runtime: captainSnapshot,
|
|
5610
|
+
agent: fixedAgent(captainAgent),
|
|
4624
5611
|
conversation,
|
|
4625
5612
|
},
|
|
5613
|
+
playerSessions: playerLedgerRecord(),
|
|
4626
5614
|
issuedSessionIds: [...issuedSessionIds],
|
|
4627
5615
|
sequences: { turn: turnSequence, journal: journalSeq },
|
|
4628
5616
|
journal,
|
|
@@ -4638,9 +5626,6 @@ export function createPlaybookCaptainShell(
|
|
|
4638
5626
|
...common,
|
|
4639
5627
|
mode: 'engaged.parked',
|
|
4640
5628
|
frames: frameSnapshots,
|
|
4641
|
-
rootPlayerResumeTokens: tokenRecord(
|
|
4642
|
-
rootFrame()!.playerResumeTokens,
|
|
4643
|
-
),
|
|
4644
5629
|
...(pendingBossQuestions === undefined
|
|
4645
5630
|
? {}
|
|
4646
5631
|
: { pendingBossQuestions: pendingBossQuestions as JsonValue }),
|
|
@@ -4673,7 +5658,7 @@ export function createPlaybookCaptainShell(
|
|
|
4673
5658
|
);
|
|
4674
5659
|
for (const key of [
|
|
4675
5660
|
'state',
|
|
4676
|
-
'
|
|
5661
|
+
'roleResumeTokens',
|
|
4677
5662
|
'sequences',
|
|
4678
5663
|
'pendingBossQuestions',
|
|
4679
5664
|
'suspendedCall',
|
|
@@ -4712,7 +5697,11 @@ export function createPlaybookCaptainShell(
|
|
|
4712
5697
|
byCommand = new Map();
|
|
4713
5698
|
byId = new Map();
|
|
4714
5699
|
enablementById = new Map();
|
|
4715
|
-
|
|
5700
|
+
captainAgent = undefined;
|
|
5701
|
+
captainAdapter = undefined;
|
|
5702
|
+
playerAgents = new Map();
|
|
5703
|
+
playerLedger.clear();
|
|
5704
|
+
playerTransactions.clear();
|
|
4716
5705
|
session = undefined;
|
|
4717
5706
|
sessionEmissionsOpen = false;
|
|
4718
5707
|
closedGateAttempted = false;
|
|
@@ -4749,22 +5738,31 @@ export function createPlaybookCaptainShell(
|
|
|
4749
5738
|
lifecycle = 'restoring';
|
|
4750
5739
|
try {
|
|
4751
5740
|
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
4752
|
-
const built = await buildEnablements(
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
);
|
|
4757
|
-
for (const enablement of built.enablementById.values()) {
|
|
4758
|
-
enablement.entry.validateOptions(enablement.optionInput);
|
|
4759
|
-
}
|
|
5741
|
+
const built = await buildEnablements(options, loadModule);
|
|
5742
|
+
captainAgent = built.captainAgent;
|
|
5743
|
+
captainAdapter = captainAgent.adapter;
|
|
5744
|
+
playerAgents = built.playerAgents;
|
|
4760
5745
|
assertSnapshotMatchesEnablements(snapshot, built.enablementById);
|
|
4761
5746
|
|
|
4762
5747
|
installSession(initSession, false);
|
|
4763
|
-
players = initSession.players;
|
|
4764
5748
|
entries = built.entries;
|
|
4765
5749
|
byCommand = built.byCommand;
|
|
4766
5750
|
byId = built.byId;
|
|
4767
5751
|
enablementById = built.enablementById;
|
|
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
|
+
}
|
|
4768
5766
|
|
|
4769
5767
|
captainRuntime = createCaptainRuntime({
|
|
4770
5768
|
enabledPlaybooks: enabledCatalog(),
|
|
@@ -4775,15 +5773,11 @@ export function createPlaybookCaptainShell(
|
|
|
4775
5773
|
}
|
|
4776
5774
|
|
|
4777
5775
|
if (snapshot.mode === 'engaged.parked') {
|
|
4778
|
-
const rootTokens = new Map(
|
|
4779
|
-
Object.entries(snapshot.rootPlayerResumeTokens),
|
|
4780
|
-
);
|
|
4781
5776
|
for (const [index, frameSnapshot] of snapshot.frames.entries()) {
|
|
4782
5777
|
const parentFrame = frames.at(-1);
|
|
4783
5778
|
const frame = makeRestoredFrame(
|
|
4784
5779
|
enablementById.get(frameSnapshot.playbookId)!,
|
|
4785
5780
|
frameSnapshot,
|
|
4786
|
-
rootTokens,
|
|
4787
5781
|
index === 0
|
|
4788
5782
|
? undefined
|
|
4789
5783
|
: {
|
|
@@ -4807,10 +5801,15 @@ export function createPlaybookCaptainShell(
|
|
|
4807
5801
|
);
|
|
4808
5802
|
if (snapshot.mode === 'engaged.parked') {
|
|
4809
5803
|
for (const [index, frame] of frames.entries()) {
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
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
|
+
}
|
|
4814
5813
|
}
|
|
4815
5814
|
}
|
|
4816
5815
|
if (closedGateAttempted) {
|
|
@@ -4832,13 +5831,10 @@ export function createPlaybookCaptainShell(
|
|
|
4832
5831
|
);
|
|
4833
5832
|
}
|
|
4834
5833
|
if (
|
|
4835
|
-
!isDeepStrictEqual(
|
|
4836
|
-
tokenRecord(rootFrame()!.playerResumeTokens),
|
|
4837
|
-
snapshot.rootPlayerResumeTokens,
|
|
4838
|
-
)
|
|
5834
|
+
!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)
|
|
4839
5835
|
) {
|
|
4840
5836
|
throw new Error(
|
|
4841
|
-
'restored
|
|
5837
|
+
'restored Captain-session player continuation changed during restore',
|
|
4842
5838
|
);
|
|
4843
5839
|
}
|
|
4844
5840
|
}
|
|
@@ -4887,14 +5883,16 @@ export function createPlaybookCaptainShell(
|
|
|
4887
5883
|
lifecycle = 'initializing';
|
|
4888
5884
|
try {
|
|
4889
5885
|
installSession(initSession, true);
|
|
4890
|
-
|
|
4891
|
-
const built = await buildEnablements(options, players, loadModule);
|
|
5886
|
+
const built = await buildEnablements(options, loadModule);
|
|
4892
5887
|
entries = built.entries;
|
|
4893
5888
|
byCommand = built.byCommand;
|
|
4894
5889
|
byId = built.byId;
|
|
4895
5890
|
enablementById = built.enablementById;
|
|
4896
|
-
|
|
4897
|
-
|
|
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));
|
|
4898
5896
|
}
|
|
4899
5897
|
await setMode('chat', 'init');
|
|
4900
5898
|
// CAPTAIN-16: the session Captain exists from `init`, outside the
|
|
@@ -4942,6 +5940,7 @@ export function createPlaybookCaptainShell(
|
|
|
4942
5940
|
const parsed = resolveCommandTurn(turn.prompt);
|
|
4943
5941
|
activeTurn = {
|
|
4944
5942
|
id: ++turnSequence,
|
|
5943
|
+
captainSyncedJournalSeq: journalSeq,
|
|
4945
5944
|
bossText: turn.prompt,
|
|
4946
5945
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
4947
5946
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
@@ -4949,6 +5948,8 @@ export function createPlaybookCaptainShell(
|
|
|
4949
5948
|
presentationAttempted: false,
|
|
4950
5949
|
settlementFacts: [],
|
|
4951
5950
|
effectThrows: new Set<unknown>(),
|
|
5951
|
+
controlFailures: new Set<unknown>(),
|
|
5952
|
+
settingsPreflightFailures: new Set<unknown>(),
|
|
4952
5953
|
suppliedIdentifiers: new Set<string>(),
|
|
4953
5954
|
outcomeRecorded: false,
|
|
4954
5955
|
};
|
|
@@ -4969,13 +5970,16 @@ export function createPlaybookCaptainShell(
|
|
|
4969
5970
|
new Error('the session Captain turn failed at its boundary'),
|
|
4970
5971
|
);
|
|
4971
5972
|
} else if (result.outcome === 'aborted') {
|
|
4972
|
-
|
|
5973
|
+
markConversationUnsynchronized();
|
|
4973
5974
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
4974
5975
|
activeTurn.settlementFacts.push(
|
|
4975
5976
|
'The Boss turn was aborted before it settled; no action was repeated automatically.',
|
|
4976
5977
|
);
|
|
4977
5978
|
journalOutcome([...activeTurn.settlementFacts]);
|
|
4978
5979
|
}
|
|
5980
|
+
if (context.signal.aborted) {
|
|
5981
|
+
throw context.signal.reason;
|
|
5982
|
+
}
|
|
4979
5983
|
} else if (
|
|
4980
5984
|
result.outcome !== 'suspended' &&
|
|
4981
5985
|
!context.signal.aborted &&
|
|
@@ -4994,10 +5998,10 @@ export function createPlaybookCaptainShell(
|
|
|
4994
5998
|
}
|
|
4995
5999
|
} catch (error) {
|
|
4996
6000
|
if (context.signal.aborted) {
|
|
4997
|
-
|
|
6001
|
+
markConversationUnsynchronized();
|
|
4998
6002
|
throw error;
|
|
4999
6003
|
}
|
|
5000
|
-
const controlFailure = activeTurn?.
|
|
6004
|
+
const controlFailure = activeTurn?.controlFailures.has(error) === true;
|
|
5001
6005
|
await settleTurnFailure(context, error);
|
|
5002
6006
|
if (activeTurn?.presentationError !== undefined) {
|
|
5003
6007
|
throw activeTurn.presentationError;
|
|
@@ -5007,7 +6011,7 @@ export function createPlaybookCaptainShell(
|
|
|
5007
6011
|
if (!controlFailure) throw error;
|
|
5008
6012
|
} finally {
|
|
5009
6013
|
if (context.signal.aborted) {
|
|
5010
|
-
|
|
6014
|
+
markConversationUnsynchronized();
|
|
5011
6015
|
if (activeTurn && !activeTurn.outcomeRecorded) {
|
|
5012
6016
|
activeTurn.settlementFacts.push(
|
|
5013
6017
|
'The Boss turn was aborted before it settled; no action was repeated automatically.',
|
|
@@ -5064,6 +6068,9 @@ export function createPlaybookCaptainShell(
|
|
|
5064
6068
|
failure ??= error;
|
|
5065
6069
|
}
|
|
5066
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();
|
|
5067
6074
|
lifecycle = 'closed';
|
|
5068
6075
|
if (failure !== undefined) throw failure;
|
|
5069
6076
|
}
|