@sublang/playbook 9.0.0 → 10.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/docs/cli.md +51 -8
- package/docs/embedding.md +38 -12
- package/package.json +7 -3
- package/reference/sdlc/captain.md +14 -10
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
- package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
- package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
- package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
- package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
- package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
- package/reference/sdlc/code.md +0 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
- package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
- package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
- package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
- package/reference/sdlc/code.playbook/bin/run.js +365 -63
- package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
- package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
- package/reference/sdlc/code.playbook/code.fsm.js +74 -25
- package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
- package/reference/sdlc/code.playbook/code.gears.md +0 -2
- package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
- package/reference/sdlc/code.playbook/code.playbook.js +54 -2
- package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
- package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
- package/reference/sdlc/code.playbook/code.registry.js +10 -3
- package/reference/sdlc/code.playbook/code.registry.ts +23 -5
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
- package/reference/sdlc/code.playbook/playbook-captain.js +1850 -72
- package/reference/sdlc/code.playbook/playbook-captain.ts +2759 -96
- package/reference/sdlc/decide.md +0 -1
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
- package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
- package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
- package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
- package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
- package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
- package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
- package/reference/sdlc/review.playbook/review.fsm.js +133 -12
- package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
- package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
- package/reference/sdlc/review.playbook/review.playbook.js +65 -2
- package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
- package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
- package/reference/sdlc/review.playbook/review.registry.js +10 -3
- package/reference/sdlc/review.playbook/review.registry.ts +23 -5
- package/slc/gears2fsm.md +6 -5
- package/slc/link.md +544 -41
- package/src/accepted-outcome.d.ts +18 -0
- package/src/accepted-outcome.js +94 -0
- package/src/accepted-outcome.ts +140 -0
- package/src/runtime.d.ts +164 -3
- package/src/runtime.ts +213 -2
- package/src/xstate-playbook-runtime.d.ts +149 -10
- package/src/xstate-playbook-runtime.js +2569 -270
- package/src/xstate-playbook-runtime.ts +4133 -490
- package/src/xstate-runtime.d.ts +59 -1
- package/src/xstate-runtime.js +866 -7
- package/src/xstate-runtime.ts +1397 -7
|
@@ -19,6 +19,8 @@ import type { Effort, PermissionPolicy } from '@sublang/cligent';
|
|
|
19
19
|
import type {
|
|
20
20
|
JsonValue,
|
|
21
21
|
NormalizedError,
|
|
22
|
+
PlaybookEffectLedger,
|
|
23
|
+
PlaybookEffectLedgerCommandBatch,
|
|
22
24
|
PlaybookCallRequest,
|
|
23
25
|
PlaybookCallResult,
|
|
24
26
|
PlaybookCallStart,
|
|
@@ -33,7 +35,10 @@ import type {
|
|
|
33
35
|
} from '@sublang/playbook/runtime';
|
|
34
36
|
import {
|
|
35
37
|
assertPlaybookRuntimeSnapshot,
|
|
38
|
+
assertPlaybookEffectLedger,
|
|
39
|
+
emptyPlaybookEffectLedger,
|
|
36
40
|
hiddenControlEnvelope,
|
|
41
|
+
isPlaybookEffectLedgerMonotonicExtension,
|
|
37
42
|
registerPlaybookAbortCleanup,
|
|
38
43
|
snapshotJsonValue,
|
|
39
44
|
validatePlayerResult,
|
|
@@ -69,6 +74,25 @@ type DeepReadonly<T> = T extends (...args: never[]) => unknown
|
|
|
69
74
|
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
|
70
75
|
: T;
|
|
71
76
|
|
|
77
|
+
export interface PlaybookCaptainUnresolvedEffect {
|
|
78
|
+
readonly classification:
|
|
79
|
+
| 'one-descendant-commit'
|
|
80
|
+
| 'multiple-commits'
|
|
81
|
+
| 'rewritten-or-non-descendant'
|
|
82
|
+
| 'worktree-only-change'
|
|
83
|
+
| 'concurrent-or-foreign-change'
|
|
84
|
+
| 'observation-ambiguous'
|
|
85
|
+
| 'incomplete';
|
|
86
|
+
readonly baselineHead: string;
|
|
87
|
+
readonly afterHead?: string;
|
|
88
|
+
readonly commitOid?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface PlaybookCaptainUnresolvedEffectSettlementInput {
|
|
92
|
+
readonly rootPlaybookId: string;
|
|
93
|
+
readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
|
|
94
|
+
}
|
|
95
|
+
|
|
72
96
|
type SnapshotAgentEnvelope = DeepReadonly<
|
|
73
97
|
Omit<SessionAgent, 'model' | 'effort'>
|
|
74
98
|
>;
|
|
@@ -78,6 +102,9 @@ type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
|
|
|
78
102
|
export interface PlaybookCaptainDeps {
|
|
79
103
|
loadModule?: (specifier: string) => Promise<unknown>;
|
|
80
104
|
createSessionId?: () => string;
|
|
105
|
+
hostCapabilities?: Readonly<
|
|
106
|
+
Record<string, PlaybookHostConstructionCapabilities>
|
|
107
|
+
>;
|
|
81
108
|
createCaptainRuntime?: (options: {
|
|
82
109
|
readonly enabledPlaybooks: readonly {
|
|
83
110
|
readonly id: string;
|
|
@@ -86,20 +113,81 @@ export interface PlaybookCaptainDeps {
|
|
|
86
113
|
}[];
|
|
87
114
|
readonly controller: CaptainControllerPort;
|
|
88
115
|
}) => PlaybookRuntime;
|
|
116
|
+
unresolvedEffectSettlement?: {
|
|
117
|
+
begin(input: PlaybookCaptainUnresolvedEffectSettlementInput): Promise<void>;
|
|
118
|
+
complete(input: PlaybookCaptainUnresolvedEffectSettlementInput): Promise<void>;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Live, artifact-typed host facilities supplied outside configured options. */
|
|
123
|
+
export interface PlaybookHostConstructionCapabilities {
|
|
124
|
+
readonly authority: {
|
|
125
|
+
readonly playbookId: string;
|
|
126
|
+
readonly artifactSchema: 3;
|
|
127
|
+
readonly cwd: string;
|
|
128
|
+
readonly sessionId: string;
|
|
129
|
+
readonly leaseOwnerToken: string;
|
|
130
|
+
readonly canonicalWorktree: {
|
|
131
|
+
readonly worktree: string;
|
|
132
|
+
readonly gitDir: string;
|
|
133
|
+
};
|
|
134
|
+
readonly requiredRoleIds: readonly string[];
|
|
135
|
+
readonly concurrentRoleSets: readonly (readonly string[])[];
|
|
136
|
+
};
|
|
137
|
+
readonly repository: {
|
|
138
|
+
readonly identity: {
|
|
139
|
+
readonly worktree: string;
|
|
140
|
+
readonly gitDir: string;
|
|
141
|
+
};
|
|
142
|
+
readonly observe: (options?: unknown) => Promise<unknown>;
|
|
143
|
+
readonly acquire: (options?: unknown) => Promise<unknown>;
|
|
144
|
+
readonly runExclusive: (options: unknown) => Promise<unknown>;
|
|
145
|
+
readonly runCohort: (options: unknown) => Promise<unknown>;
|
|
146
|
+
readonly runDeferred: (options: unknown) => Promise<unknown>;
|
|
147
|
+
};
|
|
148
|
+
readonly effectLedger: {
|
|
149
|
+
readonly snapshot: () => PlaybookEffectLedger;
|
|
150
|
+
readonly writeAhead: (
|
|
151
|
+
commands: PlaybookEffectLedgerCommandBatch,
|
|
152
|
+
) => Promise<PlaybookEffectLedger>;
|
|
153
|
+
};
|
|
89
154
|
}
|
|
90
155
|
|
|
91
|
-
export
|
|
156
|
+
export type PlaybookCaptainRuntimeProfile =
|
|
157
|
+
| {
|
|
158
|
+
readonly kind: 'shared-factory';
|
|
159
|
+
readonly compat: {
|
|
160
|
+
readonly artifactSchema: 3;
|
|
161
|
+
readonly runtimeAbi: number;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
| {
|
|
165
|
+
readonly kind: 'bespoke';
|
|
166
|
+
readonly artifactSchema: 3;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
interface PlaybookCaptainRegistryEntryBase {
|
|
92
170
|
id: string;
|
|
93
171
|
command: string;
|
|
94
172
|
intent: string;
|
|
95
|
-
|
|
173
|
+
runtimeProfile: PlaybookCaptainRuntimeProfile;
|
|
96
174
|
requiredRoleIds: readonly string[];
|
|
97
175
|
concurrentRoleSets: readonly (readonly string[])[];
|
|
98
176
|
summaryPolicy?: PlaybookSummaryPolicy;
|
|
99
177
|
validateOptions(optionSlice: unknown): unknown;
|
|
100
|
-
createRuntime(options: unknown): PlaybookRuntime;
|
|
101
178
|
}
|
|
102
179
|
|
|
180
|
+
export interface PlaybookCaptainRegistryEntryV3
|
|
181
|
+
extends PlaybookCaptainRegistryEntryBase {
|
|
182
|
+
artifactSchema: 3;
|
|
183
|
+
createRuntime(
|
|
184
|
+
configuredOptions: unknown,
|
|
185
|
+
hostCapabilities: PlaybookHostConstructionCapabilities,
|
|
186
|
+
): PlaybookRuntime;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type PlaybookCaptainRegistryEntry = PlaybookCaptainRegistryEntryV3;
|
|
190
|
+
|
|
103
191
|
type PlaybookCaptainConversationSnapshot =
|
|
104
192
|
| { readonly kind: 'unopened' }
|
|
105
193
|
| { readonly kind: 'pinned'; readonly token: string }
|
|
@@ -117,7 +205,7 @@ interface PlaybookCaptainJournalRecord {
|
|
|
117
205
|
readonly payload: JsonValue;
|
|
118
206
|
}
|
|
119
207
|
|
|
120
|
-
interface PlaybookCaptainFrameSnapshot {
|
|
208
|
+
export interface PlaybookCaptainFrameSnapshot {
|
|
121
209
|
readonly playbookId: string;
|
|
122
210
|
readonly sessionId: string;
|
|
123
211
|
readonly rootSessionId: string;
|
|
@@ -130,7 +218,8 @@ interface PlaybookCaptainFrameSnapshot {
|
|
|
130
218
|
}
|
|
131
219
|
|
|
132
220
|
interface PlaybookCaptainShellSnapshotFields {
|
|
133
|
-
readonly schemaVersion:
|
|
221
|
+
readonly schemaVersion: 4;
|
|
222
|
+
readonly effectLedger: DeepReadonly<PlaybookEffectLedger>;
|
|
134
223
|
readonly captain: {
|
|
135
224
|
readonly sessionId: string;
|
|
136
225
|
readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
|
|
@@ -149,6 +238,7 @@ interface PlaybookCaptainShellSnapshotFields {
|
|
|
149
238
|
| 'respond'
|
|
150
239
|
| 'start'
|
|
151
240
|
| 'switch'
|
|
241
|
+
| 'resume'
|
|
152
242
|
| 'dismiss'
|
|
153
243
|
| 'deliver'
|
|
154
244
|
| 'runtime';
|
|
@@ -172,6 +262,7 @@ type PlaybookCaptainShellSnapshotValue =
|
|
|
172
262
|
readonly mode: 'engaged.parked';
|
|
173
263
|
/** Root-to-leaf engagement order. */
|
|
174
264
|
readonly frames: readonly PlaybookCaptainFrameSnapshot[];
|
|
265
|
+
readonly retainedEffectReconciliation?: PlaybookCaptainRetainedEffectReconciliation;
|
|
175
266
|
readonly pendingBossQuestions?: JsonValue;
|
|
176
267
|
readonly lastError?: { readonly name: string; readonly message: string };
|
|
177
268
|
}
|
|
@@ -180,9 +271,76 @@ type PlaybookCaptainShellSnapshotValue =
|
|
|
180
271
|
export type PlaybookCaptainShellSnapshot =
|
|
181
272
|
DeepReadonly<PlaybookCaptainShellSnapshotValue>;
|
|
182
273
|
|
|
274
|
+
export interface PlaybookCaptainRetainedGeneration {
|
|
275
|
+
/** Repository-effect checkpoint reflected by the retained machine state. */
|
|
276
|
+
readonly effectLedger: DeepReadonly<PlaybookEffectLedger>;
|
|
277
|
+
readonly frames: readonly PlaybookCaptainFrameSnapshot[];
|
|
278
|
+
readonly retainedEffectReconciliation?: {
|
|
279
|
+
readonly sourceGenerationId: string;
|
|
280
|
+
};
|
|
281
|
+
/** Boss-facing description published for the retained root state, if any. */
|
|
282
|
+
readonly rootStateDescription?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface PlaybookCaptainRetainedEffectReconciliation {
|
|
286
|
+
readonly sourceGenerationId: string;
|
|
287
|
+
readonly checkpoint: DeepReadonly<PlaybookEffectLedger>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function retainedEffectLedgerCanRebase(
|
|
291
|
+
checkpoint: PlaybookEffectLedger,
|
|
292
|
+
current: PlaybookEffectLedger,
|
|
293
|
+
): boolean {
|
|
294
|
+
if (
|
|
295
|
+
checkpoint.boundaries.some(
|
|
296
|
+
({ physicalReceipt }) => physicalReceipt === undefined,
|
|
297
|
+
)
|
|
298
|
+
) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
if (!isPlaybookEffectLedgerMonotonicExtension(checkpoint, current)) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
if (
|
|
305
|
+
!isDeepStrictEqual(
|
|
306
|
+
current.boundaries.slice(0, checkpoint.boundaries.length),
|
|
307
|
+
checkpoint.boundaries,
|
|
308
|
+
) ||
|
|
309
|
+
!isDeepStrictEqual(current.logicalOperations, checkpoint.logicalOperations)
|
|
310
|
+
) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return current.boundaries
|
|
314
|
+
.slice(checkpoint.boundaries.length)
|
|
315
|
+
.every(
|
|
316
|
+
({ physicalReceipt }) =>
|
|
317
|
+
physicalReceipt?.classification === 'unchanged',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type PlaybookCaptainRetentionUpdate =
|
|
322
|
+
| {
|
|
323
|
+
readonly kind: 'retain';
|
|
324
|
+
readonly rootPlaybookId: string;
|
|
325
|
+
readonly generation: PlaybookCaptainRetainedGeneration;
|
|
326
|
+
}
|
|
327
|
+
| { readonly kind: 'clear'; readonly rootPlaybookId: string };
|
|
328
|
+
|
|
329
|
+
export interface PlaybookCaptainSettlement {
|
|
330
|
+
readonly snapshot: PlaybookCaptainShellSnapshot;
|
|
331
|
+
readonly retentionUpdates: readonly PlaybookCaptainRetentionUpdate[];
|
|
332
|
+
readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
|
|
333
|
+
}
|
|
334
|
+
|
|
183
335
|
/** tmux and headless front ends share this one durable Captain shell API. */
|
|
184
336
|
export interface PlaybookCaptainShell extends Captain {
|
|
337
|
+
installRetainedGenerations(
|
|
338
|
+
generations: Readonly<
|
|
339
|
+
Record<string, PlaybookCaptainRetainedGeneration>
|
|
340
|
+
>,
|
|
341
|
+
): Promise<void>;
|
|
185
342
|
exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
|
|
343
|
+
exportSettlement(): PlaybookCaptainSettlement | undefined;
|
|
186
344
|
restore(
|
|
187
345
|
session: CaptainSession,
|
|
188
346
|
snapshot: PlaybookCaptainShellSnapshot,
|
|
@@ -193,11 +351,28 @@ export interface PlaybookCaptainShell extends Captain {
|
|
|
193
351
|
// normalized `captain.options.playbooks.<id>` role map.
|
|
194
352
|
interface Enablement {
|
|
195
353
|
entry: PlaybookCaptainRegistryEntry;
|
|
354
|
+
artifactSchema: 3;
|
|
196
355
|
command: string;
|
|
197
356
|
options: JsonValue;
|
|
198
357
|
roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
|
|
199
358
|
}
|
|
200
359
|
|
|
360
|
+
function createRuntimeForEnablement(
|
|
361
|
+
enablement: Enablement,
|
|
362
|
+
hostCapabilitiesById: ReadonlyMap<
|
|
363
|
+
string,
|
|
364
|
+
PlaybookHostConstructionCapabilities
|
|
365
|
+
>,
|
|
366
|
+
): PlaybookRuntime {
|
|
367
|
+
const hostCapabilities = hostCapabilitiesById.get(enablement.entry.id);
|
|
368
|
+
if (hostCapabilities === undefined) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`/${enablement.command} schema-3 runtime requires current-host construction capabilities`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return enablement.entry.createRuntime(enablement.options, hostCapabilities);
|
|
374
|
+
}
|
|
375
|
+
|
|
201
376
|
interface EffectivePlayerBinding {
|
|
202
377
|
readonly playerId: string;
|
|
203
378
|
readonly model: TuningSelection;
|
|
@@ -289,7 +464,12 @@ async function classifySettingsCall<T>(call: () => Promise<T>): Promise<T> {
|
|
|
289
464
|
}
|
|
290
465
|
}
|
|
291
466
|
|
|
292
|
-
type DisposalReason =
|
|
467
|
+
type DisposalReason =
|
|
468
|
+
| 'dismiss'
|
|
469
|
+
| 'final'
|
|
470
|
+
| 'dispose'
|
|
471
|
+
| 'failure'
|
|
472
|
+
| 'unresolved-effect';
|
|
293
473
|
|
|
294
474
|
interface ControlLedger {
|
|
295
475
|
activePlaybookId?: string;
|
|
@@ -366,6 +546,12 @@ const UUID_PATTERN =
|
|
|
366
546
|
/^[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
547
|
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
368
548
|
const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
549
|
+
const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
|
|
550
|
+
const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
|
|
551
|
+
'reconcile:unresolved-effect';
|
|
552
|
+
const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
|
|
553
|
+
const RESUMPTION_DUPLICATE_EFFECT_WARNING =
|
|
554
|
+
'Warning: resumption may duplicate external effects attempted after the retained boundary; verify the current world before continuing.';
|
|
369
555
|
|
|
370
556
|
interface TurnSummaryCounts {
|
|
371
557
|
interruptions: number;
|
|
@@ -376,6 +562,7 @@ interface ActiveTurnSummary {
|
|
|
376
562
|
owner: EngagementFrame;
|
|
377
563
|
counts: TurnSummaryCounts;
|
|
378
564
|
stateCounts: Map<string, number>;
|
|
565
|
+
acceptedOutcomeTraceKeys: Set<string>;
|
|
379
566
|
}
|
|
380
567
|
|
|
381
568
|
/** The shell state of one Boss turn (DR-029). */
|
|
@@ -402,6 +589,8 @@ interface ActiveTurn {
|
|
|
402
589
|
presentationAttempted: boolean;
|
|
403
590
|
/** The exact rejected presentation boundary, propagated without retry. */
|
|
404
591
|
presentationError?: unknown;
|
|
592
|
+
/** Shell-authored safety text that must accompany the one visible reply. */
|
|
593
|
+
mandatoryPresentationSuffix?: string;
|
|
405
594
|
/** Facts accumulated while the selected action runs, including partial work. */
|
|
406
595
|
readonly settlementFacts: string[];
|
|
407
596
|
report?: OutcomeReport;
|
|
@@ -416,8 +605,8 @@ interface ActiveTurn {
|
|
|
416
605
|
readonly settingsPreflightFailures: Set<unknown>;
|
|
417
606
|
/**
|
|
418
607
|
* Every value that escaped an effect invocation this turn — a runtime
|
|
419
|
-
* driven, an engagement constructed, a stack disposed, an
|
|
420
|
-
* applied — recorded by `runEffect` at the throw itself.
|
|
608
|
+
* driven or adopted, an engagement constructed, a stack disposed, an
|
|
609
|
+
* advertised action applied — recorded by `runEffect` at the throw itself.
|
|
421
610
|
*
|
|
422
611
|
* Attribution follows the operation that threw rather than a latch set
|
|
423
612
|
* before it. A latch is turn-scoped, so once any effect has been attempted
|
|
@@ -446,6 +635,8 @@ interface ActiveTurn {
|
|
|
446
635
|
outcomePending?: boolean;
|
|
447
636
|
/** Whether this turn already has a closing journal outcome. */
|
|
448
637
|
outcomeRecorded: boolean;
|
|
638
|
+
/** Canonical host evidence frozen before the controller result phase. */
|
|
639
|
+
unresolvedEffects?: readonly PlaybookCaptainUnresolvedEffect[];
|
|
449
640
|
}
|
|
450
641
|
|
|
451
642
|
function parseRegisteredCommand(
|
|
@@ -905,11 +1096,62 @@ interface OutcomeReport {
|
|
|
905
1096
|
savedLine?: string;
|
|
906
1097
|
}
|
|
907
1098
|
|
|
1099
|
+
type ControllerSettlementDraft = Omit<
|
|
1100
|
+
SettlementEvidence,
|
|
1101
|
+
'unresolvedEffects'
|
|
1102
|
+
>;
|
|
1103
|
+
|
|
1104
|
+
function unresolvedEffectReportLines(
|
|
1105
|
+
unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
|
|
1106
|
+
): readonly string[] {
|
|
1107
|
+
return unresolvedEffects.map((effect, index) => {
|
|
1108
|
+
const merelyPossible =
|
|
1109
|
+
effect.classification === 'observation-ambiguous' ||
|
|
1110
|
+
effect.classification === 'incomplete';
|
|
1111
|
+
return [
|
|
1112
|
+
`${index + 1}. ${merelyPossible ? 'Possible repository effect; a change could not be excluded' : 'Observed repository change'} (${effect.classification})`,
|
|
1113
|
+
`baseline HEAD ${effect.baselineHead}`,
|
|
1114
|
+
effect.afterHead === undefined
|
|
1115
|
+
? 'after HEAD was not available'
|
|
1116
|
+
: `after HEAD ${effect.afterHead}`,
|
|
1117
|
+
...(effect.commitOid === undefined
|
|
1118
|
+
? []
|
|
1119
|
+
: [`proven commit OID ${effect.commitOid}`]),
|
|
1120
|
+
].join('; ') + '.';
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function unresolvedEffectBossReport(
|
|
1125
|
+
unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
|
|
1126
|
+
): string | undefined {
|
|
1127
|
+
if (unresolvedEffects.length === 0) return undefined;
|
|
1128
|
+
return [
|
|
1129
|
+
'Repository-effect evidence:',
|
|
1130
|
+
...unresolvedEffectReportLines(unresolvedEffects).map((line) => `- ${line}`),
|
|
1131
|
+
'This evidence does not establish workflow completion or attribute any repository change or commit to this workflow.',
|
|
1132
|
+
].join('\n');
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function appendMandatoryPresentationSuffix(
|
|
1136
|
+
turn: ActiveTurn,
|
|
1137
|
+
suffix: string,
|
|
1138
|
+
): void {
|
|
1139
|
+
const current = turn.mandatoryPresentationSuffix;
|
|
1140
|
+
if (current === undefined) {
|
|
1141
|
+
turn.mandatoryPresentationSuffix = suffix;
|
|
1142
|
+
} else if (!current.includes(suffix)) {
|
|
1143
|
+
turn.mandatoryPresentationSuffix = `${current}\n\n${suffix}`;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
908
1147
|
// CAPTAIN-20: the result-phase block the shell supplies inside the closing
|
|
909
1148
|
// reply call's envelope — the settlement's outcome-report facts verbatim, the
|
|
910
1149
|
// exact counts, and the saved-counts line only when counted activity is
|
|
911
1150
|
// nonzero.
|
|
912
|
-
function outcomeReportBlock(
|
|
1151
|
+
function outcomeReportBlock(
|
|
1152
|
+
report: OutcomeReport,
|
|
1153
|
+
unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
|
|
1154
|
+
): string {
|
|
913
1155
|
const lines: string[] = [
|
|
914
1156
|
`Settlement status: ${report.status}`,
|
|
915
1157
|
...(report.playbookId === undefined
|
|
@@ -935,6 +1177,14 @@ function outcomeReportBlock(report: OutcomeReport): string {
|
|
|
935
1177
|
if (report.leafStateSummary !== undefined) {
|
|
936
1178
|
lines.push(`Resulting leaf state: ${report.leafStateSummary}`);
|
|
937
1179
|
}
|
|
1180
|
+
const effectLines = unresolvedEffectReportLines(unresolvedEffects);
|
|
1181
|
+
if (effectLines.length > 0) {
|
|
1182
|
+
lines.push('Repository-effect evidence (canonical, in ledger order):');
|
|
1183
|
+
lines.push(...effectLines.map((line) => `- ${line}`));
|
|
1184
|
+
lines.push(
|
|
1185
|
+
'Report this evidence without claiming workflow completion or attributing any repository change or commit to the workflow.',
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
938
1188
|
lines.push(`Progress counts: ${report.progressPhrase}`);
|
|
939
1189
|
lines.push(
|
|
940
1190
|
`Counts: ${JSON.stringify({
|
|
@@ -984,12 +1234,209 @@ function summaryProgressRoundCount(
|
|
|
984
1234
|
return [...stateCounts.values()].reduce((total, count) => total + count, 0);
|
|
985
1235
|
}
|
|
986
1236
|
|
|
987
|
-
|
|
988
|
-
|
|
1237
|
+
interface ValidatedRuntimeProfile {
|
|
1238
|
+
readonly kind: 'shared-factory' | 'bespoke';
|
|
1239
|
+
readonly artifactSchema: 3;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function captureRegistryEntry(value: unknown): unknown {
|
|
1243
|
+
if (value === null || typeof value !== 'object') return value;
|
|
1244
|
+
const source = value as Record<string, unknown>;
|
|
1245
|
+
return {
|
|
1246
|
+
id: source.id,
|
|
1247
|
+
command: source.command,
|
|
1248
|
+
intent: source.intent,
|
|
1249
|
+
artifactSchema: source.artifactSchema,
|
|
1250
|
+
runtimeProfile: source.runtimeProfile,
|
|
1251
|
+
requiredRoleIds: source.requiredRoleIds,
|
|
1252
|
+
concurrentRoleSets: source.concurrentRoleSets,
|
|
1253
|
+
summaryPolicy: source.summaryPolicy,
|
|
1254
|
+
validateOptions: source.validateOptions,
|
|
1255
|
+
createRuntime: source.createRuntime,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function exactOwnDataRecord(
|
|
1260
|
+
value: unknown,
|
|
1261
|
+
keys: readonly string[],
|
|
1262
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
1263
|
+
if (
|
|
1264
|
+
value === null ||
|
|
1265
|
+
typeof value !== 'object' ||
|
|
1266
|
+
Array.isArray(value) ||
|
|
1267
|
+
(Object.getPrototypeOf(value) !== Object.prototype &&
|
|
1268
|
+
Object.getPrototypeOf(value) !== null)
|
|
1269
|
+
) {
|
|
1270
|
+
return undefined;
|
|
1271
|
+
}
|
|
1272
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1273
|
+
if (
|
|
1274
|
+
Reflect.ownKeys(descriptors).length !== keys.length ||
|
|
1275
|
+
keys.some(
|
|
1276
|
+
(key) =>
|
|
1277
|
+
!Object.hasOwn(descriptors, key) ||
|
|
1278
|
+
!Object.hasOwn(descriptors[key]!, 'value') ||
|
|
1279
|
+
descriptors[key]!.enumerable !== true,
|
|
1280
|
+
)
|
|
1281
|
+
) {
|
|
1282
|
+
return undefined;
|
|
1283
|
+
}
|
|
1284
|
+
return Object.fromEntries(
|
|
1285
|
+
keys.map((key) => [key, descriptors[key]!.value]),
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function captureHostCapabilityRecord(
|
|
1290
|
+
value: PlaybookCaptainDeps['hostCapabilities'],
|
|
1291
|
+
): Readonly<Record<string, PlaybookHostConstructionCapabilities>> {
|
|
1292
|
+
if (value === undefined) return Object.freeze({});
|
|
1293
|
+
const captured = exactOwnDataRecord(value, Object.keys(value));
|
|
1294
|
+
if (captured === undefined) {
|
|
1295
|
+
throw new TypeError(
|
|
1296
|
+
'current-host construction capabilities must be an exact data-property record',
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
return captured as Readonly<
|
|
1300
|
+
Record<string, PlaybookHostConstructionCapabilities>
|
|
1301
|
+
>;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function validateHostCapabilities(
|
|
1305
|
+
value: unknown,
|
|
1306
|
+
entry: PlaybookCaptainRegistryEntryV3,
|
|
1307
|
+
command: string,
|
|
1308
|
+
): PlaybookHostConstructionCapabilities {
|
|
1309
|
+
if (value === undefined) {
|
|
1310
|
+
throw new Error(
|
|
1311
|
+
`/${command} schema-3 runtime requires current-host construction capabilities`,
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
const capability = exactOwnDataRecord(value, [
|
|
1315
|
+
'authority',
|
|
1316
|
+
'repository',
|
|
1317
|
+
'effectLedger',
|
|
1318
|
+
]);
|
|
1319
|
+
const authority = exactOwnDataRecord(capability?.authority, [
|
|
1320
|
+
'playbookId',
|
|
1321
|
+
'artifactSchema',
|
|
1322
|
+
'cwd',
|
|
1323
|
+
'sessionId',
|
|
1324
|
+
'leaseOwnerToken',
|
|
1325
|
+
'canonicalWorktree',
|
|
1326
|
+
'requiredRoleIds',
|
|
1327
|
+
'concurrentRoleSets',
|
|
1328
|
+
]);
|
|
1329
|
+
const canonicalWorktree = exactOwnDataRecord(
|
|
1330
|
+
authority?.canonicalWorktree,
|
|
1331
|
+
['worktree', 'gitDir'],
|
|
1332
|
+
);
|
|
1333
|
+
const repository = exactOwnDataRecord(capability?.repository, [
|
|
1334
|
+
'identity',
|
|
1335
|
+
'observe',
|
|
1336
|
+
'acquire',
|
|
1337
|
+
'runExclusive',
|
|
1338
|
+
'runCohort',
|
|
1339
|
+
'runDeferred',
|
|
1340
|
+
]);
|
|
1341
|
+
const identity = exactOwnDataRecord(repository?.identity, [
|
|
1342
|
+
'worktree',
|
|
1343
|
+
'gitDir',
|
|
1344
|
+
]);
|
|
1345
|
+
const effectLedger = exactOwnDataRecord(capability?.effectLedger, [
|
|
1346
|
+
'snapshot',
|
|
1347
|
+
'writeAhead',
|
|
1348
|
+
]);
|
|
1349
|
+
if (
|
|
1350
|
+
authority?.playbookId !== entry.id ||
|
|
1351
|
+
authority.artifactSchema !== 3 ||
|
|
1352
|
+
typeof authority.cwd !== 'string' ||
|
|
1353
|
+
authority.cwd.length === 0 ||
|
|
1354
|
+
typeof authority.sessionId !== 'string' ||
|
|
1355
|
+
authority.sessionId.length === 0 ||
|
|
1356
|
+
typeof authority.leaseOwnerToken !== 'string' ||
|
|
1357
|
+
authority.leaseOwnerToken.length === 0 ||
|
|
1358
|
+
canonicalWorktree === undefined ||
|
|
1359
|
+
typeof canonicalWorktree.worktree !== 'string' ||
|
|
1360
|
+
canonicalWorktree.worktree.length === 0 ||
|
|
1361
|
+
typeof canonicalWorktree.gitDir !== 'string' ||
|
|
1362
|
+
canonicalWorktree.gitDir.length === 0 ||
|
|
1363
|
+
!isDeepStrictEqual(authority.requiredRoleIds, entry.requiredRoleIds) ||
|
|
1364
|
+
!isDeepStrictEqual(
|
|
1365
|
+
authority.concurrentRoleSets,
|
|
1366
|
+
entry.concurrentRoleSets,
|
|
1367
|
+
) ||
|
|
1368
|
+
identity === undefined ||
|
|
1369
|
+
!isDeepStrictEqual(identity, canonicalWorktree) ||
|
|
1370
|
+
typeof repository?.observe !== 'function' ||
|
|
1371
|
+
typeof repository.acquire !== 'function' ||
|
|
1372
|
+
typeof repository.runExclusive !== 'function' ||
|
|
1373
|
+
typeof repository.runCohort !== 'function' ||
|
|
1374
|
+
typeof repository.runDeferred !== 'function' ||
|
|
1375
|
+
typeof effectLedger?.snapshot !== 'function' ||
|
|
1376
|
+
typeof effectLedger.writeAhead !== 'function'
|
|
1377
|
+
) {
|
|
1378
|
+
throw new Error(
|
|
1379
|
+
`/${command} schema-3 current-host capability authority does not match its imported artifact`,
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
return value as PlaybookHostConstructionCapabilities;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function effectLedgerMirrorFromCapabilities(
|
|
1386
|
+
capabilities: ReadonlyMap<string, PlaybookHostConstructionCapabilities>,
|
|
1387
|
+
): PlaybookEffectLedger {
|
|
1388
|
+
const values = [...capabilities.values()];
|
|
1389
|
+
if (values.length === 0) return emptyPlaybookEffectLedger();
|
|
1390
|
+
const mirror = assertPlaybookEffectLedger(values[0]!.effectLedger.snapshot());
|
|
1391
|
+
for (const capability of values.slice(1)) {
|
|
1392
|
+
if (
|
|
1393
|
+
!isDeepStrictEqual(
|
|
1394
|
+
assertPlaybookEffectLedger(capability.effectLedger.snapshot()),
|
|
1395
|
+
mirror,
|
|
1396
|
+
)
|
|
1397
|
+
) {
|
|
1398
|
+
throw new Error(
|
|
1399
|
+
'schema-3 current-host capabilities disagree on their effect ledger',
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return mirror;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function validateRuntimeProfile(
|
|
1407
|
+
value: unknown,
|
|
1408
|
+
): ValidatedRuntimeProfile | undefined {
|
|
1409
|
+
const shared = exactOwnDataRecord(value, ['kind', 'compat']);
|
|
1410
|
+
if (shared?.kind === 'shared-factory') {
|
|
1411
|
+
const compat = exactOwnDataRecord(shared.compat, [
|
|
1412
|
+
'artifactSchema',
|
|
1413
|
+
'runtimeAbi',
|
|
1414
|
+
]);
|
|
1415
|
+
if (
|
|
1416
|
+
compat?.artifactSchema === 3 &&
|
|
1417
|
+
typeof compat.runtimeAbi === 'number' &&
|
|
1418
|
+
Number.isSafeInteger(compat.runtimeAbi)
|
|
1419
|
+
) {
|
|
1420
|
+
return {
|
|
1421
|
+
kind: 'shared-factory',
|
|
1422
|
+
artifactSchema: compat.artifactSchema,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
return undefined;
|
|
1426
|
+
}
|
|
1427
|
+
const bespoke = exactOwnDataRecord(value, ['kind', 'artifactSchema']);
|
|
1428
|
+
if (
|
|
1429
|
+
bespoke?.kind === 'bespoke' &&
|
|
1430
|
+
bespoke.artifactSchema === 3
|
|
1431
|
+
) {
|
|
1432
|
+
return { kind: 'bespoke', artifactSchema: bespoke.artifactSchema };
|
|
1433
|
+
}
|
|
1434
|
+
return undefined;
|
|
989
1435
|
}
|
|
990
1436
|
|
|
991
1437
|
function isValidRegistryEntry(
|
|
992
1438
|
value: unknown,
|
|
1439
|
+
artifactSchema: unknown,
|
|
993
1440
|
): value is PlaybookCaptainRegistryEntry {
|
|
994
1441
|
if (typeof value !== 'object' || value === null) return false;
|
|
995
1442
|
const e = value as Record<string, unknown>;
|
|
@@ -1025,7 +1472,7 @@ function isValidRegistryEntry(
|
|
|
1025
1472
|
typeof e.id === 'string' &&
|
|
1026
1473
|
typeof e.command === 'string' &&
|
|
1027
1474
|
typeof e.intent === 'string' &&
|
|
1028
|
-
|
|
1475
|
+
artifactSchema === 3 &&
|
|
1029
1476
|
typeof e.validateOptions === 'function' &&
|
|
1030
1477
|
typeof e.createRuntime === 'function'
|
|
1031
1478
|
);
|
|
@@ -1035,6 +1482,7 @@ const SNAPSHOT_ACTIONS = new Set([
|
|
|
1035
1482
|
'respond',
|
|
1036
1483
|
'start',
|
|
1037
1484
|
'switch',
|
|
1485
|
+
'resume',
|
|
1038
1486
|
'dismiss',
|
|
1039
1487
|
'deliver',
|
|
1040
1488
|
'runtime',
|
|
@@ -1051,6 +1499,93 @@ const SNAPSHOT_JOURNAL_KINDS = new Set([
|
|
|
1051
1499
|
'action',
|
|
1052
1500
|
'outcome',
|
|
1053
1501
|
] as const);
|
|
1502
|
+
const UNRESOLVED_EFFECT_CLASSIFICATIONS = new Set<
|
|
1503
|
+
PlaybookCaptainUnresolvedEffect['classification']
|
|
1504
|
+
>([
|
|
1505
|
+
'one-descendant-commit',
|
|
1506
|
+
'multiple-commits',
|
|
1507
|
+
'rewritten-or-non-descendant',
|
|
1508
|
+
'worktree-only-change',
|
|
1509
|
+
'concurrent-or-foreign-change',
|
|
1510
|
+
'observation-ambiguous',
|
|
1511
|
+
'incomplete',
|
|
1512
|
+
]);
|
|
1513
|
+
const GIT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
1514
|
+
|
|
1515
|
+
export function assertPlaybookCaptainUnresolvedEffects(
|
|
1516
|
+
value: unknown,
|
|
1517
|
+
): readonly PlaybookCaptainUnresolvedEffect[] {
|
|
1518
|
+
const detached = snapshotJsonValue(
|
|
1519
|
+
value,
|
|
1520
|
+
'Captain unresolved effects',
|
|
1521
|
+
);
|
|
1522
|
+
if (!Array.isArray(detached)) {
|
|
1523
|
+
throw new TypeError('Captain unresolved effects must be an array');
|
|
1524
|
+
}
|
|
1525
|
+
for (const [index, raw] of detached.entries()) {
|
|
1526
|
+
const path = `Captain unresolved effects[${index}]`;
|
|
1527
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
1528
|
+
throw new TypeError(`${path} must be an object`);
|
|
1529
|
+
}
|
|
1530
|
+
const entry = raw as Record<string, JsonValue>;
|
|
1531
|
+
const allowed = new Set([
|
|
1532
|
+
'classification',
|
|
1533
|
+
'baselineHead',
|
|
1534
|
+
'afterHead',
|
|
1535
|
+
'commitOid',
|
|
1536
|
+
]);
|
|
1537
|
+
const unknown = Object.keys(entry).find((key) => !allowed.has(key));
|
|
1538
|
+
if (unknown !== undefined) {
|
|
1539
|
+
throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown)}`);
|
|
1540
|
+
}
|
|
1541
|
+
if (
|
|
1542
|
+
typeof entry.classification !== 'string' ||
|
|
1543
|
+
!UNRESOLVED_EFFECT_CLASSIFICATIONS.has(
|
|
1544
|
+
entry.classification as PlaybookCaptainUnresolvedEffect['classification'],
|
|
1545
|
+
)
|
|
1546
|
+
) {
|
|
1547
|
+
throw new TypeError(`${path}.classification is not supported`);
|
|
1548
|
+
}
|
|
1549
|
+
if (
|
|
1550
|
+
typeof entry.baselineHead !== 'string' ||
|
|
1551
|
+
!GIT_OID_PATTERN.test(entry.baselineHead)
|
|
1552
|
+
) {
|
|
1553
|
+
throw new TypeError(`${path}.baselineHead must be a Git OID`);
|
|
1554
|
+
}
|
|
1555
|
+
if (
|
|
1556
|
+
entry.afterHead !== undefined &&
|
|
1557
|
+
(typeof entry.afterHead !== 'string' ||
|
|
1558
|
+
!GIT_OID_PATTERN.test(entry.afterHead))
|
|
1559
|
+
) {
|
|
1560
|
+
throw new TypeError(`${path}.afterHead must be a Git OID`);
|
|
1561
|
+
}
|
|
1562
|
+
if (
|
|
1563
|
+
entry.classification !== 'observation-ambiguous' &&
|
|
1564
|
+
entry.classification !== 'incomplete' &&
|
|
1565
|
+
entry.afterHead === undefined
|
|
1566
|
+
) {
|
|
1567
|
+
throw new TypeError(
|
|
1568
|
+
`${path}.afterHead is required for ${entry.classification}`,
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
if (entry.classification === 'one-descendant-commit') {
|
|
1572
|
+
if (
|
|
1573
|
+
typeof entry.commitOid !== 'string' ||
|
|
1574
|
+
!GIT_OID_PATTERN.test(entry.commitOid) ||
|
|
1575
|
+
entry.commitOid !== entry.afterHead
|
|
1576
|
+
) {
|
|
1577
|
+
throw new TypeError(
|
|
1578
|
+
`${path}.commitOid must equal afterHead for one-descendant-commit`,
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
} else if (entry.commitOid !== undefined) {
|
|
1582
|
+
throw new TypeError(
|
|
1583
|
+
`${path}.commitOid is permitted only for one-descendant-commit`,
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
return detached as unknown as readonly PlaybookCaptainUnresolvedEffect[];
|
|
1588
|
+
}
|
|
1054
1589
|
|
|
1055
1590
|
function snapshotRecord(
|
|
1056
1591
|
value: JsonValue | undefined,
|
|
@@ -1312,9 +1847,20 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
1312
1847
|
): PlaybookCaptainShellSnapshot {
|
|
1313
1848
|
const detached = snapshotJsonValue(value, 'Captain shell snapshot');
|
|
1314
1849
|
const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
|
|
1850
|
+
if (snapshot.schemaVersion !== 4) {
|
|
1851
|
+
if (snapshot.schemaVersion === 1) {
|
|
1852
|
+
throw new TypeError(
|
|
1853
|
+
'Captain shell snapshot schemaVersion 1 has incompatible player identity; schema 4 is required',
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
throw new TypeError(
|
|
1857
|
+
`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 4)`,
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1315
1860
|
const mode = snapshot.mode;
|
|
1316
1861
|
const commonKeys = [
|
|
1317
1862
|
'schemaVersion',
|
|
1863
|
+
'effectLedger',
|
|
1318
1864
|
'captain',
|
|
1319
1865
|
'playerSessions',
|
|
1320
1866
|
'issuedSessionIds',
|
|
@@ -1332,6 +1878,7 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
1332
1878
|
[
|
|
1333
1879
|
...commonKeys,
|
|
1334
1880
|
'frames',
|
|
1881
|
+
'retainedEffectReconciliation',
|
|
1335
1882
|
'pendingBossQuestions',
|
|
1336
1883
|
'lastError',
|
|
1337
1884
|
],
|
|
@@ -1342,12 +1889,6 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
1342
1889
|
'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
|
|
1343
1890
|
);
|
|
1344
1891
|
}
|
|
1345
|
-
if (snapshot.schemaVersion !== 3) {
|
|
1346
|
-
throw new TypeError(
|
|
1347
|
-
`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
|
|
1348
|
-
);
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
1892
|
const captain = snapshotRecord(
|
|
1352
1893
|
snapshot.captain,
|
|
1353
1894
|
'Captain shell snapshot.captain',
|
|
@@ -1365,6 +1906,50 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
1365
1906
|
captain.runtime,
|
|
1366
1907
|
INTERNAL_CAPTAIN_ID,
|
|
1367
1908
|
);
|
|
1909
|
+
const effectLedger = assertPlaybookEffectLedger(snapshot.effectLedger);
|
|
1910
|
+
let retainedEffectReconciliation:
|
|
1911
|
+
| PlaybookCaptainRetainedEffectReconciliation
|
|
1912
|
+
| undefined;
|
|
1913
|
+
if (snapshot.retainedEffectReconciliation !== undefined) {
|
|
1914
|
+
const reconciliation = snapshotRecord(
|
|
1915
|
+
snapshot.retainedEffectReconciliation,
|
|
1916
|
+
'Captain shell snapshot.retainedEffectReconciliation',
|
|
1917
|
+
);
|
|
1918
|
+
rejectSnapshotKeys(
|
|
1919
|
+
reconciliation,
|
|
1920
|
+
['sourceGenerationId', 'checkpoint'],
|
|
1921
|
+
'Captain shell snapshot.retainedEffectReconciliation',
|
|
1922
|
+
);
|
|
1923
|
+
const checkpoint = assertPlaybookEffectLedger(
|
|
1924
|
+
reconciliation.checkpoint,
|
|
1925
|
+
'Captain shell snapshot retained-effect checkpoint',
|
|
1926
|
+
);
|
|
1927
|
+
if (
|
|
1928
|
+
isDeepStrictEqual(checkpoint, effectLedger) ||
|
|
1929
|
+
!isPlaybookEffectLedgerMonotonicExtension(checkpoint, effectLedger)
|
|
1930
|
+
) {
|
|
1931
|
+
throw new TypeError(
|
|
1932
|
+
'Captain shell retained-effect checkpoint must be a strict monotonic prefix of its current mirror',
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
retainedEffectReconciliation = {
|
|
1936
|
+
sourceGenerationId: snapshotUuid(
|
|
1937
|
+
reconciliation.sourceGenerationId,
|
|
1938
|
+
'Captain shell snapshot.retainedEffectReconciliation.sourceGenerationId',
|
|
1939
|
+
),
|
|
1940
|
+
checkpoint,
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
if (
|
|
1944
|
+
!isDeepStrictEqual(
|
|
1945
|
+
captainRuntime.effectLedger,
|
|
1946
|
+
emptyPlaybookEffectLedger(),
|
|
1947
|
+
)
|
|
1948
|
+
) {
|
|
1949
|
+
throw new TypeError(
|
|
1950
|
+
'Captain shell snapshot internal Captain runtime effect ledger must be empty',
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1368
1953
|
const captainAgent = snapshotFixedAgent(
|
|
1369
1954
|
captain.agent,
|
|
1370
1955
|
'Captain shell snapshot.captain.agent',
|
|
@@ -1594,7 +2179,8 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
1594
2179
|
'Captain shell snapshot.playerSessions',
|
|
1595
2180
|
);
|
|
1596
2181
|
const common: PlaybookCaptainShellSnapshotFields = {
|
|
1597
|
-
schemaVersion:
|
|
2182
|
+
schemaVersion: 4,
|
|
2183
|
+
effectLedger,
|
|
1598
2184
|
captain: {
|
|
1599
2185
|
sessionId: captainSessionId,
|
|
1600
2186
|
runtime: captainRuntime,
|
|
@@ -1743,6 +2329,51 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
1743
2329
|
const issuedIds = new Set(issued);
|
|
1744
2330
|
const rootSessionId = normalizedFrames[0]!.sessionId;
|
|
1745
2331
|
for (const [index, frame] of normalizedFrames.entries()) {
|
|
2332
|
+
const frameLedger = frame.runtime.effectLedger;
|
|
2333
|
+
if (
|
|
2334
|
+
!isDeepStrictEqual(frameLedger, emptyPlaybookEffectLedger()) &&
|
|
2335
|
+
!isDeepStrictEqual(frameLedger, effectLedger)
|
|
2336
|
+
) {
|
|
2337
|
+
throw new TypeError(
|
|
2338
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger is neither empty nor the shell mirror`,
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
const frameReconciliation = frame.runtime.retainedEffectReconciliation;
|
|
2342
|
+
if (retainedEffectReconciliation === undefined) {
|
|
2343
|
+
if (frameReconciliation !== undefined) {
|
|
2344
|
+
throw new TypeError(
|
|
2345
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} carries an unmirrored retained-effect fence`,
|
|
2346
|
+
);
|
|
2347
|
+
}
|
|
2348
|
+
} else if (isDeepStrictEqual(frameLedger, effectLedger)) {
|
|
2349
|
+
if (
|
|
2350
|
+
frameReconciliation === undefined ||
|
|
2351
|
+
!isDeepStrictEqual(
|
|
2352
|
+
frameReconciliation.checkpoint,
|
|
2353
|
+
retainedEffectReconciliation.checkpoint,
|
|
2354
|
+
)
|
|
2355
|
+
) {
|
|
2356
|
+
throw new TypeError(
|
|
2357
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} does not mirror the root retained-effect fence`,
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
if (
|
|
2361
|
+
index === 0 &&
|
|
2362
|
+
frameReconciliation.sourceSessionId !==
|
|
2363
|
+
retainedEffectReconciliation.sourceGenerationId
|
|
2364
|
+
) {
|
|
2365
|
+
throw new TypeError(
|
|
2366
|
+
'Captain shell snapshot retained-effect root source identity differs from its generation',
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
} else if (
|
|
2370
|
+
frameReconciliation !== undefined ||
|
|
2371
|
+
frame.runtime.retainedEffectSourceSessionId !== undefined
|
|
2372
|
+
) {
|
|
2373
|
+
throw new TypeError(
|
|
2374
|
+
`Captain shell snapshot empty-ledger frame ${JSON.stringify(frame.playbookId)} carries retained-effect adoption state`,
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
1746
2377
|
if (activePlaybooks.has(frame.playbookId)) {
|
|
1747
2378
|
throw new TypeError(
|
|
1748
2379
|
'Captain shell snapshot engagement path must not contain a playbook cycle',
|
|
@@ -1840,14 +2471,20 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
1840
2471
|
'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
|
|
1841
2472
|
);
|
|
1842
2473
|
}
|
|
1843
|
-
if (
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
2474
|
+
if (retainedEffectReconciliation === undefined) {
|
|
2475
|
+
if (
|
|
2476
|
+
!isDeepStrictEqual(
|
|
2477
|
+
snapshot.pendingBossQuestions ?? [],
|
|
2478
|
+
leafRuntime.pendingBossQuestions,
|
|
2479
|
+
)
|
|
2480
|
+
) {
|
|
2481
|
+
throw new TypeError(
|
|
2482
|
+
'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
|
|
2483
|
+
);
|
|
2484
|
+
}
|
|
2485
|
+
} else if (snapshot.pendingBossQuestions !== undefined) {
|
|
1849
2486
|
throw new TypeError(
|
|
1850
|
-
'Captain shell snapshot pending Boss questions
|
|
2487
|
+
'Captain shell snapshot must withhold pending Boss questions behind retained-effect reconciliation',
|
|
1851
2488
|
);
|
|
1852
2489
|
}
|
|
1853
2490
|
return snapshotJsonValue(
|
|
@@ -1855,6 +2492,9 @@ export function assertPlaybookCaptainShellSnapshot(
|
|
|
1855
2492
|
...common,
|
|
1856
2493
|
mode,
|
|
1857
2494
|
frames: normalizedFrames,
|
|
2495
|
+
...(retainedEffectReconciliation === undefined
|
|
2496
|
+
? {}
|
|
2497
|
+
: { retainedEffectReconciliation }),
|
|
1858
2498
|
...(snapshot.pendingBossQuestions === undefined
|
|
1859
2499
|
? {}
|
|
1860
2500
|
: { pendingBossQuestions: snapshot.pendingBossQuestions }),
|
|
@@ -1871,6 +2511,10 @@ interface BuiltRegistry {
|
|
|
1871
2511
|
byCommand: Map<string, PlaybookCaptainRegistryEntry>;
|
|
1872
2512
|
byId: Map<string, PlaybookCaptainRegistryEntry>;
|
|
1873
2513
|
enablementById: Map<string, Enablement>;
|
|
2514
|
+
hostCapabilitiesById: ReadonlyMap<
|
|
2515
|
+
string,
|
|
2516
|
+
PlaybookHostConstructionCapabilities
|
|
2517
|
+
>;
|
|
1874
2518
|
captainAgent: SessionAgent;
|
|
1875
2519
|
playerAgents: Map<string, SessionAgent>;
|
|
1876
2520
|
}
|
|
@@ -1975,17 +2619,38 @@ function promptIdentity(binding: EffectivePlayerBinding): string {
|
|
|
1975
2619
|
: binding.agent.adapter;
|
|
1976
2620
|
}
|
|
1977
2621
|
|
|
2622
|
+
function rejectConfiguredHostCapabilities(value: JsonValue | undefined, path: string): void {
|
|
2623
|
+
if (
|
|
2624
|
+
value !== null &&
|
|
2625
|
+
typeof value === 'object' &&
|
|
2626
|
+
!Array.isArray(value) &&
|
|
2627
|
+
Object.prototype.hasOwnProperty.call(value, HOST_CAPABILITIES_OPTION_KEY)
|
|
2628
|
+
) {
|
|
2629
|
+
throw new Error(
|
|
2630
|
+
`${path}.${HOST_CAPABILITIES_OPTION_KEY} is host-owned and cannot be configured`,
|
|
2631
|
+
);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
|
|
1978
2635
|
// Resolve the active registry at init from exact normalized role and session
|
|
1979
2636
|
// agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
|
|
1980
2637
|
// exists at this boundary.
|
|
1981
2638
|
async function buildEnablements(
|
|
1982
2639
|
options: unknown,
|
|
1983
2640
|
loadModule: (specifier: string) => Promise<unknown>,
|
|
2641
|
+
hostCapabilities: PlaybookCaptainDeps['hostCapabilities'],
|
|
1984
2642
|
): Promise<BuiltRegistry> {
|
|
1985
2643
|
const entries: PlaybookCaptainRegistryEntry[] = [];
|
|
1986
2644
|
const byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
1987
2645
|
const byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
1988
2646
|
const enablementById = new Map<string, Enablement>();
|
|
2647
|
+
const hostCapabilitiesById = new Map<
|
|
2648
|
+
string,
|
|
2649
|
+
PlaybookHostConstructionCapabilities
|
|
2650
|
+
>();
|
|
2651
|
+
const suppliedHostCapabilities =
|
|
2652
|
+
captureHostCapabilityRecord(hostCapabilities);
|
|
2653
|
+
const expectedHostCapabilityIds: string[] = [];
|
|
1989
2654
|
|
|
1990
2655
|
const detached = snapshotJsonValue(options, 'captain.options');
|
|
1991
2656
|
const top = snapshotRecord(detached, 'captain.options');
|
|
@@ -2067,6 +2732,10 @@ async function buildEnablements(
|
|
|
2067
2732
|
['from', 'command', 'roles', 'options'],
|
|
2068
2733
|
`captain.options.playbooks.${id}`,
|
|
2069
2734
|
);
|
|
2735
|
+
rejectConfiguredHostCapabilities(
|
|
2736
|
+
record.options,
|
|
2737
|
+
`captain.options.playbooks.${id}.options`,
|
|
2738
|
+
);
|
|
2070
2739
|
const from = record.from;
|
|
2071
2740
|
if (typeof from !== 'string' || from.length === 0) {
|
|
2072
2741
|
throw new Error(
|
|
@@ -2083,12 +2752,44 @@ async function buildEnablements(
|
|
|
2083
2752
|
)}`,
|
|
2084
2753
|
);
|
|
2085
2754
|
}
|
|
2086
|
-
const
|
|
2087
|
-
|
|
2755
|
+
const capturedEntry = captureRegistryEntry(
|
|
2756
|
+
(mod as { default?: unknown })?.default,
|
|
2757
|
+
);
|
|
2758
|
+
const artifactSchema = (
|
|
2759
|
+
capturedEntry as { artifactSchema?: unknown } | null
|
|
2760
|
+
)
|
|
2761
|
+
?.artifactSchema;
|
|
2762
|
+
const runtimeProfile = validateRuntimeProfile(
|
|
2763
|
+
(capturedEntry as { runtimeProfile?: unknown } | null)?.runtimeProfile,
|
|
2764
|
+
);
|
|
2765
|
+
if (
|
|
2766
|
+
artifactSchema !== 3 ||
|
|
2767
|
+
runtimeProfile === undefined ||
|
|
2768
|
+
!isValidRegistryEntry(capturedEntry, artifactSchema)
|
|
2769
|
+
) {
|
|
2088
2770
|
throw new Error(
|
|
2089
2771
|
`captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`,
|
|
2090
2772
|
);
|
|
2091
2773
|
}
|
|
2774
|
+
const entry = Object.freeze({
|
|
2775
|
+
...capturedEntry,
|
|
2776
|
+
requiredRoleIds: Object.freeze([...capturedEntry.requiredRoleIds]),
|
|
2777
|
+
concurrentRoleSets: Object.freeze(
|
|
2778
|
+
capturedEntry.concurrentRoleSets.map((roles) =>
|
|
2779
|
+
Object.freeze([...roles]),
|
|
2780
|
+
),
|
|
2781
|
+
),
|
|
2782
|
+
}) as PlaybookCaptainRegistryEntry;
|
|
2783
|
+
if (runtimeProfile.artifactSchema !== artifactSchema) {
|
|
2784
|
+
const implementation =
|
|
2785
|
+
runtimeProfile.kind === 'shared-factory'
|
|
2786
|
+
? 'shared factory'
|
|
2787
|
+
: 'bespoke runtime';
|
|
2788
|
+
throw new Error(
|
|
2789
|
+
`captain.options.playbooks.${id}.from "${from}" advertises artifact schema ${artifactSchema} ` +
|
|
2790
|
+
`but its ${implementation} implements schema ${runtimeProfile.artifactSchema}`,
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2092
2793
|
if (entry.id !== id) {
|
|
2093
2794
|
throw new Error(
|
|
2094
2795
|
`captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`,
|
|
@@ -2164,17 +2865,43 @@ async function buildEnablements(
|
|
|
2164
2865
|
entry.validateOptions(record.options),
|
|
2165
2866
|
`captain.options.playbooks.${id}.options`,
|
|
2166
2867
|
);
|
|
2868
|
+
rejectConfiguredHostCapabilities(
|
|
2869
|
+
validatedOptions,
|
|
2870
|
+
`captain.options.playbooks.${id}.options`,
|
|
2871
|
+
);
|
|
2167
2872
|
entries.push(entry);
|
|
2168
2873
|
byId.set(entry.id, entry);
|
|
2169
2874
|
byCommand.set(command, entry);
|
|
2875
|
+
const hostCapability = validateHostCapabilities(
|
|
2876
|
+
suppliedHostCapabilities[entry.id],
|
|
2877
|
+
entry,
|
|
2878
|
+
command,
|
|
2879
|
+
);
|
|
2880
|
+
expectedHostCapabilityIds.push(entry.id);
|
|
2881
|
+
hostCapabilitiesById.set(entry.id, hostCapability);
|
|
2170
2882
|
enablementById.set(entry.id, {
|
|
2171
2883
|
entry,
|
|
2884
|
+
artifactSchema,
|
|
2172
2885
|
command,
|
|
2173
2886
|
options: validatedOptions,
|
|
2174
2887
|
roleBindings,
|
|
2175
2888
|
});
|
|
2176
2889
|
}
|
|
2177
|
-
const
|
|
2890
|
+
const suppliedHostCapabilityIds = Object.keys(
|
|
2891
|
+
suppliedHostCapabilities,
|
|
2892
|
+
).sort();
|
|
2893
|
+
expectedHostCapabilityIds.sort();
|
|
2894
|
+
if (
|
|
2895
|
+
!isDeepStrictEqual(
|
|
2896
|
+
suppliedHostCapabilityIds,
|
|
2897
|
+
expectedHostCapabilityIds,
|
|
2898
|
+
)
|
|
2899
|
+
) {
|
|
2900
|
+
throw new Error(
|
|
2901
|
+
'current-host construction capabilities must exactly cover schema-3 playbooks',
|
|
2902
|
+
);
|
|
2903
|
+
}
|
|
2904
|
+
const referenced = new Set(
|
|
2178
2905
|
[...enablementById.values()].flatMap((enablement) =>
|
|
2179
2906
|
[...enablement.roleBindings.values()].map((binding) => binding.playerId),
|
|
2180
2907
|
),
|
|
@@ -2190,6 +2917,7 @@ async function buildEnablements(
|
|
|
2190
2917
|
byCommand,
|
|
2191
2918
|
byId,
|
|
2192
2919
|
enablementById,
|
|
2920
|
+
hostCapabilitiesById,
|
|
2193
2921
|
captainAgent,
|
|
2194
2922
|
playerAgents,
|
|
2195
2923
|
};
|
|
@@ -2205,6 +2933,17 @@ export function createPlaybookCaptainShell(
|
|
|
2205
2933
|
const createCaptainRuntime: NonNullable<
|
|
2206
2934
|
PlaybookCaptainDeps['createCaptainRuntime']
|
|
2207
2935
|
> = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
2936
|
+
const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
|
|
2937
|
+
let pendingHostCapabilities = deps.hostCapabilities;
|
|
2938
|
+
let currentEffectLedger = () => emptyPlaybookEffectLedger();
|
|
2939
|
+
// The returned shell must not retain the caller's aggregate dependency
|
|
2940
|
+
// object after its one live capability input has moved to a clearable slot.
|
|
2941
|
+
deps = {};
|
|
2942
|
+
const buildCurrentEnablements = async (): Promise<BuiltRegistry> => {
|
|
2943
|
+
const hostCapabilities = pendingHostCapabilities;
|
|
2944
|
+
pendingHostCapabilities = undefined;
|
|
2945
|
+
return buildEnablements(options, loadModule, hostCapabilities);
|
|
2946
|
+
};
|
|
2208
2947
|
let captainAgent: SessionAgent | undefined;
|
|
2209
2948
|
let captainAdapter: string | undefined;
|
|
2210
2949
|
let playerAgents = new Map<string, SessionAgent>();
|
|
@@ -2214,6 +2953,10 @@ export function createPlaybookCaptainShell(
|
|
|
2214
2953
|
let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
2215
2954
|
let byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
2216
2955
|
let enablementById = new Map<string, Enablement>();
|
|
2956
|
+
let hostCapabilitiesById = new Map<
|
|
2957
|
+
string,
|
|
2958
|
+
PlaybookHostConstructionCapabilities
|
|
2959
|
+
>();
|
|
2217
2960
|
let session: CaptainSession | undefined;
|
|
2218
2961
|
let sessionEmissionsOpen = false;
|
|
2219
2962
|
let closedGateAttempted = false;
|
|
@@ -2228,6 +2971,9 @@ export function createPlaybookCaptainShell(
|
|
|
2228
2971
|
let activeContext: CaptainContext | undefined;
|
|
2229
2972
|
const frames: EngagementFrame[] = [];
|
|
2230
2973
|
let mode: ShellMode = 'chat';
|
|
2974
|
+
let retainedEffectReconciliation:
|
|
2975
|
+
| PlaybookCaptainRetainedEffectReconciliation
|
|
2976
|
+
| undefined;
|
|
2231
2977
|
let pendingBossQuestions: unknown;
|
|
2232
2978
|
let lastError: { name: string; message: string } | undefined;
|
|
2233
2979
|
let activeTurnSummary: ActiveTurnSummary | undefined;
|
|
@@ -2308,6 +3054,50 @@ export function createPlaybookCaptainShell(
|
|
|
2308
3054
|
| undefined;
|
|
2309
3055
|
let lastAction: ControllerAction | undefined;
|
|
2310
3056
|
let lastSettlementStatus: SettlementEvidence['status'] | undefined;
|
|
3057
|
+
// DR-038 §2: a turn can dispose its root before the durable caller asks
|
|
3058
|
+
// for settlement. Keep only that turn's latest safe pre-terminal generation
|
|
3059
|
+
// and its per-root retain/clear decisions until `exportSettlement()` pairs
|
|
3060
|
+
// them with the complete shell snapshot.
|
|
3061
|
+
type RetainedGenerationCandidate =
|
|
3062
|
+
| {
|
|
3063
|
+
readonly status: 'captured';
|
|
3064
|
+
readonly generation: PlaybookCaptainRetainedGeneration;
|
|
3065
|
+
}
|
|
3066
|
+
| { readonly status: 'incapable' }
|
|
3067
|
+
| { readonly status: 'unsafe' };
|
|
3068
|
+
const retainedGenerationCandidates = new Map<
|
|
3069
|
+
string,
|
|
3070
|
+
RetainedGenerationCandidate
|
|
3071
|
+
>();
|
|
3072
|
+
const pendingRetentionUpdates = new Map<
|
|
3073
|
+
string,
|
|
3074
|
+
PlaybookCaptainRetentionUpdate
|
|
3075
|
+
>();
|
|
3076
|
+
interface RetainedGenerationOffer {
|
|
3077
|
+
readonly generation: PlaybookCaptainRetainedGeneration;
|
|
3078
|
+
readonly requiresEffectReconciliation: boolean;
|
|
3079
|
+
/** Fresh, uninitialized runtimes reserved for one adoption attempt. */
|
|
3080
|
+
readonly runtimes: readonly PlaybookRuntime[];
|
|
3081
|
+
}
|
|
3082
|
+
const retainedGenerations = new Map<
|
|
3083
|
+
string,
|
|
3084
|
+
PlaybookCaptainRetainedGeneration
|
|
3085
|
+
>();
|
|
3086
|
+
const retainedGenerationOffers = new Map<
|
|
3087
|
+
string,
|
|
3088
|
+
RetainedGenerationOffer
|
|
3089
|
+
>();
|
|
3090
|
+
const ineligibleRetainedGenerations = new Set<string>();
|
|
3091
|
+
const retainedGenerationRootClears = new Set<string>();
|
|
3092
|
+
const retiredRetainedRuntimes: PlaybookRuntime[] = [];
|
|
3093
|
+
let retainedGenerationsInstalled = false;
|
|
3094
|
+
let retainedGenerationInstallationInProgress = false;
|
|
3095
|
+
let retainedGenerationInstallationClosed = false;
|
|
3096
|
+
let retentionSettlementReady = false;
|
|
3097
|
+
let abandonmentSettlementUnsafe = false;
|
|
3098
|
+
let settledTurnUnresolvedEffects:
|
|
3099
|
+
| readonly PlaybookCaptainUnresolvedEffect[]
|
|
3100
|
+
| undefined;
|
|
2311
3101
|
// DR-029: a run that lands in the runtime's own failure state
|
|
2312
3102
|
// is an outcome the report must name. `processFrameResult` records it here
|
|
2313
3103
|
// and the settling selection folds it into its facts, so the grounding the
|
|
@@ -2319,6 +3109,826 @@ export function createPlaybookCaptainShell(
|
|
|
2319
3109
|
const frameLabel = (frame: EngagementFrame): string =>
|
|
2320
3110
|
`/${frame.enablement.command}`;
|
|
2321
3111
|
|
|
3112
|
+
type UnresolvedEnvelopeReference =
|
|
3113
|
+
| { readonly kind: 'boundary'; readonly boundaryId: string }
|
|
3114
|
+
| { readonly kind: 'logical-operation'; readonly operationId: string };
|
|
3115
|
+
|
|
3116
|
+
const capturedUnresolvedEnvelopeReferences = (
|
|
3117
|
+
frame: EngagementFrame,
|
|
3118
|
+
): readonly UnresolvedEnvelopeReference[] => {
|
|
3119
|
+
let advertisesUnresolved = false;
|
|
3120
|
+
try {
|
|
3121
|
+
advertisesUnresolved =
|
|
3122
|
+
frame.runtime
|
|
3123
|
+
.describe?.()
|
|
3124
|
+
.actions.some(
|
|
3125
|
+
({ id }) =>
|
|
3126
|
+
id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
|
|
3127
|
+
id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
|
|
3128
|
+
) === true;
|
|
3129
|
+
} catch {
|
|
3130
|
+
advertisesUnresolved = true;
|
|
3131
|
+
}
|
|
3132
|
+
if (typeof frame.runtime.unresolvedEffectEnvelopes !== 'function') {
|
|
3133
|
+
if (advertisesUnresolved) {
|
|
3134
|
+
throw new Error(
|
|
3135
|
+
`${frameLabel(frame)} unresolved-effect runtime exposes no envelope identities`,
|
|
3136
|
+
);
|
|
3137
|
+
}
|
|
3138
|
+
return [];
|
|
3139
|
+
}
|
|
3140
|
+
const detached = snapshotJsonValue(
|
|
3141
|
+
frame.runtime.unresolvedEffectEnvelopes(),
|
|
3142
|
+
`${frameLabel(frame)} unresolved effect envelope identities`,
|
|
3143
|
+
);
|
|
3144
|
+
if (!Array.isArray(detached)) {
|
|
3145
|
+
throw new TypeError(
|
|
3146
|
+
`${frameLabel(frame)} unresolved effect envelope identities must be an array`,
|
|
3147
|
+
);
|
|
3148
|
+
}
|
|
3149
|
+
return detached.map((raw, index) => {
|
|
3150
|
+
const path = `${frameLabel(frame)} unresolved effect envelope identities[${index}]`;
|
|
3151
|
+
const record = snapshotRecord(raw, path);
|
|
3152
|
+
if (record.kind === 'boundary') {
|
|
3153
|
+
rejectSnapshotKeys(record, ['kind', 'boundaryId'], path);
|
|
3154
|
+
return {
|
|
3155
|
+
kind: 'boundary' as const,
|
|
3156
|
+
boundaryId: snapshotString(record.boundaryId, `${path}.boundaryId`),
|
|
3157
|
+
};
|
|
3158
|
+
}
|
|
3159
|
+
if (record.kind === 'logical-operation') {
|
|
3160
|
+
rejectSnapshotKeys(record, ['kind', 'operationId'], path);
|
|
3161
|
+
return {
|
|
3162
|
+
kind: 'logical-operation' as const,
|
|
3163
|
+
operationId: snapshotString(record.operationId, `${path}.operationId`),
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
throw new TypeError(`${path}.kind is not supported`);
|
|
3167
|
+
});
|
|
3168
|
+
};
|
|
3169
|
+
|
|
3170
|
+
const unresolvedEffectFromReceipt = (
|
|
3171
|
+
receipt: NonNullable<
|
|
3172
|
+
PlaybookEffectLedger['boundaries'][number]['physicalReceipt']
|
|
3173
|
+
>,
|
|
3174
|
+
baselineHead = receipt.baseline.head,
|
|
3175
|
+
): PlaybookCaptainUnresolvedEffect | undefined => {
|
|
3176
|
+
if (receipt.classification === 'unchanged') return undefined;
|
|
3177
|
+
return {
|
|
3178
|
+
classification: receipt.classification,
|
|
3179
|
+
baselineHead,
|
|
3180
|
+
...(receipt.after === undefined ? {} : { afterHead: receipt.after.head }),
|
|
3181
|
+
...(receipt.commitOid === undefined ? {} : { commitOid: receipt.commitOid }),
|
|
3182
|
+
};
|
|
3183
|
+
};
|
|
3184
|
+
|
|
3185
|
+
const cumulativeOpenLogicalEffect = (
|
|
3186
|
+
operation: PlaybookEffectLedger['logicalOperations'][number],
|
|
3187
|
+
boundaries: readonly PlaybookEffectLedger['boundaries'][number][],
|
|
3188
|
+
): PlaybookCaptainUnresolvedEffect | undefined => {
|
|
3189
|
+
const original = operation.originalBaseline;
|
|
3190
|
+
const latest = boundaries.at(-1)!;
|
|
3191
|
+
const after = latest.after ?? operation.checkpoint;
|
|
3192
|
+
const receipt = latest.physicalReceipt;
|
|
3193
|
+
if (receipt === undefined) {
|
|
3194
|
+
return {
|
|
3195
|
+
classification: 'incomplete',
|
|
3196
|
+
baselineHead: original.head,
|
|
3197
|
+
...(after === undefined ? {} : { afterHead: after.head }),
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
if (after === undefined) {
|
|
3201
|
+
return unresolvedEffectFromReceipt(receipt, original.head);
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
// An open deferred chain has no authoritative cumulative receipt. Reuse
|
|
3205
|
+
// physical ancestry only while every preceding checkpoint is one of the
|
|
3206
|
+
// same-HEAD dispositions that can lawfully keep a deferred operation
|
|
3207
|
+
// open. Any other history is bounded but not cumulatively attributable.
|
|
3208
|
+
const checkpointChainIsSafe = boundaries
|
|
3209
|
+
.slice(0, -1)
|
|
3210
|
+
.every(
|
|
3211
|
+
(boundary) =>
|
|
3212
|
+
boundary.after?.head === original.head &&
|
|
3213
|
+
(boundary.physicalReceipt?.classification === 'unchanged' ||
|
|
3214
|
+
boundary.physicalReceipt?.classification ===
|
|
3215
|
+
'worktree-only-change'),
|
|
3216
|
+
);
|
|
3217
|
+
if (!checkpointChainIsSafe || latest.baseline.head !== original.head) {
|
|
3218
|
+
return {
|
|
3219
|
+
classification: 'observation-ambiguous',
|
|
3220
|
+
baselineHead: original.head,
|
|
3221
|
+
afterHead: after.head,
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
if (
|
|
3226
|
+
receipt.classification !== 'unchanged' &&
|
|
3227
|
+
receipt.classification !== 'worktree-only-change' &&
|
|
3228
|
+
receipt.classification !== 'one-descendant-commit'
|
|
3229
|
+
) {
|
|
3230
|
+
return unresolvedEffectFromReceipt(receipt, original.head);
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
const sameProjection = isDeepStrictEqual(
|
|
3234
|
+
original.projection,
|
|
3235
|
+
after.projection,
|
|
3236
|
+
);
|
|
3237
|
+
if (after.head === original.head) {
|
|
3238
|
+
if (
|
|
3239
|
+
receipt.classification !== 'unchanged' &&
|
|
3240
|
+
receipt.classification !== 'worktree-only-change'
|
|
3241
|
+
) {
|
|
3242
|
+
return {
|
|
3243
|
+
classification: 'observation-ambiguous',
|
|
3244
|
+
baselineHead: original.head,
|
|
3245
|
+
afterHead: after.head,
|
|
3246
|
+
};
|
|
3247
|
+
}
|
|
3248
|
+
if (sameProjection) return undefined;
|
|
3249
|
+
const preservesOriginal = Object.entries(original.projection).every(
|
|
3250
|
+
([path, entry]) =>
|
|
3251
|
+
Object.hasOwn(after.projection, path) &&
|
|
3252
|
+
isDeepStrictEqual(entry, after.projection[path]),
|
|
3253
|
+
);
|
|
3254
|
+
return {
|
|
3255
|
+
classification: preservesOriginal
|
|
3256
|
+
? 'worktree-only-change'
|
|
3257
|
+
: 'observation-ambiguous',
|
|
3258
|
+
baselineHead: original.head,
|
|
3259
|
+
afterHead: after.head,
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
if (
|
|
3264
|
+
receipt.classification === 'one-descendant-commit' &&
|
|
3265
|
+
sameProjection
|
|
3266
|
+
) {
|
|
3267
|
+
return {
|
|
3268
|
+
classification: 'one-descendant-commit',
|
|
3269
|
+
baselineHead: original.head,
|
|
3270
|
+
afterHead: after.head,
|
|
3271
|
+
commitOid: after.head,
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
return {
|
|
3275
|
+
classification: 'observation-ambiguous',
|
|
3276
|
+
baselineHead: original.head,
|
|
3277
|
+
afterHead: after.head,
|
|
3278
|
+
};
|
|
3279
|
+
};
|
|
3280
|
+
|
|
3281
|
+
const projectUnresolvedEffects = (
|
|
3282
|
+
ledger: PlaybookEffectLedger,
|
|
3283
|
+
references: readonly UnresolvedEnvelopeReference[],
|
|
3284
|
+
): readonly PlaybookCaptainUnresolvedEffect[] => {
|
|
3285
|
+
const pendingReferences = [...references];
|
|
3286
|
+
const projected: Array<{
|
|
3287
|
+
readonly order: number;
|
|
3288
|
+
readonly effect: PlaybookCaptainUnresolvedEffect;
|
|
3289
|
+
}> = [];
|
|
3290
|
+
const seenBoundaries = new Set<string>();
|
|
3291
|
+
const seenOperations = new Set<string>();
|
|
3292
|
+
for (let index = 0; index < pendingReferences.length; index += 1) {
|
|
3293
|
+
const reference = pendingReferences[index]!;
|
|
3294
|
+
if (reference.kind === 'boundary') {
|
|
3295
|
+
if (seenBoundaries.has(reference.boundaryId)) continue;
|
|
3296
|
+
seenBoundaries.add(reference.boundaryId);
|
|
3297
|
+
const boundary = ledger.boundaries.find(
|
|
3298
|
+
({ boundaryId }) => boundaryId === reference.boundaryId,
|
|
3299
|
+
);
|
|
3300
|
+
if (boundary === undefined) {
|
|
3301
|
+
throw new Error(
|
|
3302
|
+
`unresolved effect boundary ${JSON.stringify(reference.boundaryId)} is absent from the authoritative ledger`,
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
if (boundary.logicalOperationId !== undefined) {
|
|
3306
|
+
if (!seenOperations.has(boundary.logicalOperationId)) {
|
|
3307
|
+
pendingReferences.push({
|
|
3308
|
+
kind: 'logical-operation',
|
|
3309
|
+
operationId: boundary.logicalOperationId,
|
|
3310
|
+
});
|
|
3311
|
+
}
|
|
3312
|
+
continue;
|
|
3313
|
+
}
|
|
3314
|
+
const effect =
|
|
3315
|
+
boundary.physicalReceipt === undefined
|
|
3316
|
+
? {
|
|
3317
|
+
classification: 'incomplete' as const,
|
|
3318
|
+
baselineHead: boundary.baseline.head,
|
|
3319
|
+
...(boundary.after === undefined
|
|
3320
|
+
? {}
|
|
3321
|
+
: { afterHead: boundary.after.head }),
|
|
3322
|
+
}
|
|
3323
|
+
: unresolvedEffectFromReceipt(boundary.physicalReceipt);
|
|
3324
|
+
if (effect !== undefined) {
|
|
3325
|
+
projected.push({ order: boundary.sequence, effect });
|
|
3326
|
+
}
|
|
3327
|
+
continue;
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
if (seenOperations.has(reference.operationId)) continue;
|
|
3331
|
+
seenOperations.add(reference.operationId);
|
|
3332
|
+
const operation = ledger.logicalOperations.find(
|
|
3333
|
+
({ operationId }) => operationId === reference.operationId,
|
|
3334
|
+
);
|
|
3335
|
+
if (operation === undefined) {
|
|
3336
|
+
throw new Error(
|
|
3337
|
+
`unresolved logical operation ${JSON.stringify(reference.operationId)} is absent from the authoritative ledger`,
|
|
3338
|
+
);
|
|
3339
|
+
}
|
|
3340
|
+
const boundaries = operation.boundaryIds.map((boundaryId) => {
|
|
3341
|
+
const boundary = ledger.boundaries.find(
|
|
3342
|
+
(candidate) => candidate.boundaryId === boundaryId,
|
|
3343
|
+
);
|
|
3344
|
+
if (boundary === undefined) {
|
|
3345
|
+
throw new Error(
|
|
3346
|
+
`unresolved logical operation ${JSON.stringify(reference.operationId)} names an absent boundary`,
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
seenBoundaries.add(boundaryId);
|
|
3350
|
+
return boundary;
|
|
3351
|
+
});
|
|
3352
|
+
let effect: PlaybookCaptainUnresolvedEffect | undefined;
|
|
3353
|
+
if (operation.logicalReceipt !== undefined) {
|
|
3354
|
+
effect = unresolvedEffectFromReceipt(
|
|
3355
|
+
operation.logicalReceipt,
|
|
3356
|
+
operation.originalBaseline.head,
|
|
3357
|
+
);
|
|
3358
|
+
} else {
|
|
3359
|
+
effect = cumulativeOpenLogicalEffect(operation, boundaries);
|
|
3360
|
+
}
|
|
3361
|
+
if (effect !== undefined) {
|
|
3362
|
+
projected.push({ order: boundaries[0]!.sequence, effect });
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
return assertPlaybookCaptainUnresolvedEffects(
|
|
3366
|
+
projected
|
|
3367
|
+
.sort((left, right) => left.order - right.order)
|
|
3368
|
+
.map(({ effect }) => effect),
|
|
3369
|
+
);
|
|
3370
|
+
};
|
|
3371
|
+
|
|
3372
|
+
const currentUnresolvedEffects =
|
|
3373
|
+
(): readonly PlaybookCaptainUnresolvedEffect[] => {
|
|
3374
|
+
const ledger = assertPlaybookEffectLedger(currentEffectLedger());
|
|
3375
|
+
const references: UnresolvedEnvelopeReference[] = [];
|
|
3376
|
+
for (const frame of frames) {
|
|
3377
|
+
references.push(...capturedUnresolvedEnvelopeReferences(frame));
|
|
3378
|
+
}
|
|
3379
|
+
if (retainedEffectReconciliation !== undefined) {
|
|
3380
|
+
for (const boundary of ledger.boundaries.slice(
|
|
3381
|
+
retainedEffectReconciliation.checkpoint.boundaries.length,
|
|
3382
|
+
)) {
|
|
3383
|
+
if (boundary.physicalReceipt?.classification === 'unchanged') {
|
|
3384
|
+
continue;
|
|
3385
|
+
}
|
|
3386
|
+
references.push(
|
|
3387
|
+
boundary.logicalOperationId === undefined
|
|
3388
|
+
? { kind: 'boundary', boundaryId: boundary.boundaryId }
|
|
3389
|
+
: {
|
|
3390
|
+
kind: 'logical-operation',
|
|
3391
|
+
operationId: boundary.logicalOperationId,
|
|
3392
|
+
},
|
|
3393
|
+
);
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
return projectUnresolvedEffects(ledger, references);
|
|
3397
|
+
};
|
|
3398
|
+
|
|
3399
|
+
const freezeTurnUnresolvedEffects =
|
|
3400
|
+
(): readonly PlaybookCaptainUnresolvedEffect[] => {
|
|
3401
|
+
const turn = activeTurn;
|
|
3402
|
+
if (turn?.unresolvedEffects !== undefined) return turn.unresolvedEffects;
|
|
3403
|
+
const frozen = currentUnresolvedEffects();
|
|
3404
|
+
if (turn !== undefined) turn.unresolvedEffects = frozen;
|
|
3405
|
+
settledTurnUnresolvedEffects = frozen;
|
|
3406
|
+
return frozen;
|
|
3407
|
+
};
|
|
3408
|
+
|
|
3409
|
+
const normalizeInstalledRetainedGenerations = (
|
|
3410
|
+
value: Readonly<Record<string, PlaybookCaptainRetainedGeneration>>,
|
|
3411
|
+
): Map<string, PlaybookCaptainRetainedGeneration> => {
|
|
3412
|
+
const path = 'Captain retained generations';
|
|
3413
|
+
const detached = snapshotJsonValue(value, path);
|
|
3414
|
+
const record = snapshotRecord(detached, path);
|
|
3415
|
+
const authoritativeEffectLedger = assertPlaybookEffectLedger(
|
|
3416
|
+
currentEffectLedger(),
|
|
3417
|
+
`${path} current host effect ledger`,
|
|
3418
|
+
);
|
|
3419
|
+
const sourceSessionIds = new Set<string>();
|
|
3420
|
+
const normalized = new Map<string, PlaybookCaptainRetainedGeneration>();
|
|
3421
|
+
for (const [rootPlaybookId, rawGeneration] of Object.entries(record)) {
|
|
3422
|
+
const generationPath = `${path}[${JSON.stringify(rootPlaybookId)}]`;
|
|
3423
|
+
const enablement = enablementById.get(rootPlaybookId);
|
|
3424
|
+
if (enablement === undefined) {
|
|
3425
|
+
throw new TypeError(
|
|
3426
|
+
`${generationPath} names a disabled root playbook`,
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3429
|
+
const generation = snapshotRecord(rawGeneration, generationPath);
|
|
3430
|
+
rejectSnapshotKeys(
|
|
3431
|
+
generation,
|
|
3432
|
+
[
|
|
3433
|
+
'effectLedger',
|
|
3434
|
+
'frames',
|
|
3435
|
+
'retainedEffectReconciliation',
|
|
3436
|
+
'rootStateDescription',
|
|
3437
|
+
],
|
|
3438
|
+
generationPath,
|
|
3439
|
+
);
|
|
3440
|
+
const generationEffectLedger = assertPlaybookEffectLedger(
|
|
3441
|
+
generation.effectLedger,
|
|
3442
|
+
`${generationPath}.effectLedger`,
|
|
3443
|
+
);
|
|
3444
|
+
if (
|
|
3445
|
+
generationEffectLedger.boundaries.some(
|
|
3446
|
+
({ physicalReceipt }) => physicalReceipt === undefined,
|
|
3447
|
+
)
|
|
3448
|
+
) {
|
|
3449
|
+
throw new TypeError(
|
|
3450
|
+
`${generationPath}.effectLedger contains an incomplete physical boundary`,
|
|
3451
|
+
);
|
|
3452
|
+
}
|
|
3453
|
+
if (
|
|
3454
|
+
!isPlaybookEffectLedgerMonotonicExtension(
|
|
3455
|
+
generationEffectLedger,
|
|
3456
|
+
authoritativeEffectLedger,
|
|
3457
|
+
)
|
|
3458
|
+
) {
|
|
3459
|
+
throw new TypeError(
|
|
3460
|
+
`${generationPath}.effectLedger is not a monotonic prefix of the current host mirror`,
|
|
3461
|
+
);
|
|
3462
|
+
}
|
|
3463
|
+
const generationReconciliation =
|
|
3464
|
+
generation.retainedEffectReconciliation === undefined
|
|
3465
|
+
? undefined
|
|
3466
|
+
: snapshotRecord(
|
|
3467
|
+
generation.retainedEffectReconciliation,
|
|
3468
|
+
`${generationPath}.retainedEffectReconciliation`,
|
|
3469
|
+
);
|
|
3470
|
+
if (generationReconciliation !== undefined) {
|
|
3471
|
+
rejectSnapshotKeys(
|
|
3472
|
+
generationReconciliation,
|
|
3473
|
+
['sourceGenerationId'],
|
|
3474
|
+
`${generationPath}.retainedEffectReconciliation`,
|
|
3475
|
+
);
|
|
3476
|
+
}
|
|
3477
|
+
const sourceGenerationId =
|
|
3478
|
+
generationReconciliation === undefined
|
|
3479
|
+
? undefined
|
|
3480
|
+
: snapshotUuid(
|
|
3481
|
+
generationReconciliation.sourceGenerationId,
|
|
3482
|
+
`${generationPath}.retainedEffectReconciliation.sourceGenerationId`,
|
|
3483
|
+
);
|
|
3484
|
+
if (!Array.isArray(generation.frames) || generation.frames.length === 0) {
|
|
3485
|
+
throw new TypeError(`${generationPath}.frames must be non-empty`);
|
|
3486
|
+
}
|
|
3487
|
+
const rootStateDescription =
|
|
3488
|
+
generation.rootStateDescription === undefined
|
|
3489
|
+
? undefined
|
|
3490
|
+
: snapshotString(
|
|
3491
|
+
generation.rootStateDescription,
|
|
3492
|
+
`${generationPath}.rootStateDescription`,
|
|
3493
|
+
);
|
|
3494
|
+
const normalizedFrames: PlaybookCaptainFrameSnapshot[] = [];
|
|
3495
|
+
const playbookIds = new Set<string>();
|
|
3496
|
+
let markedCaptureEffectLedger:
|
|
3497
|
+
| DeepReadonly<PlaybookEffectLedger>
|
|
3498
|
+
| undefined;
|
|
3499
|
+
for (const [index, rawFrame] of generation.frames.entries()) {
|
|
3500
|
+
const framePath = `${generationPath}.frames[${index}]`;
|
|
3501
|
+
const frame = snapshotRecord(rawFrame, framePath);
|
|
3502
|
+
rejectSnapshotKeys(
|
|
3503
|
+
frame,
|
|
3504
|
+
[
|
|
3505
|
+
'playbookId',
|
|
3506
|
+
'sessionId',
|
|
3507
|
+
'rootSessionId',
|
|
3508
|
+
'depth',
|
|
3509
|
+
'parentSessionId',
|
|
3510
|
+
'parentCallId',
|
|
3511
|
+
'options',
|
|
3512
|
+
'roleBindings',
|
|
3513
|
+
'runtime',
|
|
3514
|
+
],
|
|
3515
|
+
framePath,
|
|
3516
|
+
);
|
|
3517
|
+
const playbookId = snapshotString(
|
|
3518
|
+
frame.playbookId,
|
|
3519
|
+
`${framePath}.playbookId`,
|
|
3520
|
+
);
|
|
3521
|
+
const frameEnablement = enablementById.get(playbookId);
|
|
3522
|
+
if (frameEnablement === undefined) {
|
|
3523
|
+
throw new TypeError(`${framePath} names a disabled playbook`);
|
|
3524
|
+
}
|
|
3525
|
+
if (playbookIds.has(playbookId)) {
|
|
3526
|
+
throw new TypeError(
|
|
3527
|
+
`${generationPath}.frames must not contain a playbook cycle`,
|
|
3528
|
+
);
|
|
3529
|
+
}
|
|
3530
|
+
playbookIds.add(playbookId);
|
|
3531
|
+
const sessionId = snapshotUuid(
|
|
3532
|
+
frame.sessionId,
|
|
3533
|
+
`${framePath}.sessionId`,
|
|
3534
|
+
);
|
|
3535
|
+
if (sourceSessionIds.has(sessionId)) {
|
|
3536
|
+
throw new TypeError(
|
|
3537
|
+
`${path} frame session ids must be unique across generations`,
|
|
3538
|
+
);
|
|
3539
|
+
}
|
|
3540
|
+
sourceSessionIds.add(sessionId);
|
|
3541
|
+
const rootSessionId = snapshotUuid(
|
|
3542
|
+
frame.rootSessionId,
|
|
3543
|
+
`${framePath}.rootSessionId`,
|
|
3544
|
+
);
|
|
3545
|
+
const depth = snapshotInteger(frame.depth, `${framePath}.depth`);
|
|
3546
|
+
const parentSessionId =
|
|
3547
|
+
frame.parentSessionId === undefined
|
|
3548
|
+
? undefined
|
|
3549
|
+
: snapshotUuid(
|
|
3550
|
+
frame.parentSessionId,
|
|
3551
|
+
`${framePath}.parentSessionId`,
|
|
3552
|
+
);
|
|
3553
|
+
const parentCallId =
|
|
3554
|
+
frame.parentCallId === undefined
|
|
3555
|
+
? undefined
|
|
3556
|
+
: snapshotString(
|
|
3557
|
+
frame.parentCallId,
|
|
3558
|
+
`${framePath}.parentCallId`,
|
|
3559
|
+
);
|
|
3560
|
+
const options = frame.options as JsonValue;
|
|
3561
|
+
if (
|
|
3562
|
+
index === 0 &&
|
|
3563
|
+
!isDeepStrictEqual(options, frameEnablement.options)
|
|
3564
|
+
) {
|
|
3565
|
+
throw new TypeError(`${framePath}.options changed`);
|
|
3566
|
+
}
|
|
3567
|
+
const roleBindings = snapshotFrameRoleBindings(
|
|
3568
|
+
frame.roleBindings,
|
|
3569
|
+
`${framePath}.roleBindings`,
|
|
3570
|
+
);
|
|
3571
|
+
if (
|
|
3572
|
+
!isDeepStrictEqual(
|
|
3573
|
+
Object.keys(roleBindings).sort(),
|
|
3574
|
+
[...frameEnablement.entry.requiredRoleIds].sort(),
|
|
3575
|
+
)
|
|
3576
|
+
) {
|
|
3577
|
+
throw new TypeError(
|
|
3578
|
+
`${framePath}.roleBindings do not cover the current role set`,
|
|
3579
|
+
);
|
|
3580
|
+
}
|
|
3581
|
+
const runtime = assertPlaybookRuntimeSnapshot(
|
|
3582
|
+
frame.runtime,
|
|
3583
|
+
playbookId,
|
|
3584
|
+
{ allowSuspendedCall: true },
|
|
3585
|
+
);
|
|
3586
|
+
const retainedReconciliation = runtime.retainedEffectReconciliation;
|
|
3587
|
+
if (retainedReconciliation === undefined) {
|
|
3588
|
+
if (!isDeepStrictEqual(runtime.effectLedger, generationEffectLedger)) {
|
|
3589
|
+
throw new TypeError(
|
|
3590
|
+
`${framePath}.runtime effect ledger differs from the retained checkpoint`,
|
|
3591
|
+
);
|
|
3592
|
+
}
|
|
3593
|
+
} else {
|
|
3594
|
+
if (
|
|
3595
|
+
!isDeepStrictEqual(
|
|
3596
|
+
retainedReconciliation.checkpoint,
|
|
3597
|
+
generationEffectLedger,
|
|
3598
|
+
) ||
|
|
3599
|
+
isDeepStrictEqual(runtime.effectLedger, generationEffectLedger) ||
|
|
3600
|
+
!isPlaybookEffectLedgerMonotonicExtension(
|
|
3601
|
+
runtime.effectLedger,
|
|
3602
|
+
authoritativeEffectLedger,
|
|
3603
|
+
)
|
|
3604
|
+
) {
|
|
3605
|
+
throw new TypeError(
|
|
3606
|
+
`${framePath}.runtime retained-effect evidence is inconsistent`,
|
|
3607
|
+
);
|
|
3608
|
+
}
|
|
3609
|
+
if (markedCaptureEffectLedger === undefined) {
|
|
3610
|
+
markedCaptureEffectLedger = runtime.effectLedger;
|
|
3611
|
+
} else if (
|
|
3612
|
+
!isDeepStrictEqual(
|
|
3613
|
+
runtime.effectLedger,
|
|
3614
|
+
markedCaptureEffectLedger,
|
|
3615
|
+
)
|
|
3616
|
+
) {
|
|
3617
|
+
throw new TypeError(
|
|
3618
|
+
`${framePath}.runtime effect ledger differs from the marked generation capture mirror`,
|
|
3619
|
+
);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
if (
|
|
3623
|
+
runtime.state.status !== 'active' ||
|
|
3624
|
+
!runtime.state.quiescent ||
|
|
3625
|
+
typeof runtime.state.stateId !== 'string' ||
|
|
3626
|
+
runtime.state.stateId.trim().length === 0
|
|
3627
|
+
) {
|
|
3628
|
+
throw new TypeError(
|
|
3629
|
+
`${framePath}.runtime must be active, quiescent, and state-identified`,
|
|
3630
|
+
);
|
|
3631
|
+
}
|
|
3632
|
+
for (const question of runtime.pendingBossQuestions) {
|
|
3633
|
+
if (
|
|
3634
|
+
question.asker.kind === 'role' &&
|
|
3635
|
+
roleBindings[question.asker.roleId] === undefined
|
|
3636
|
+
) {
|
|
3637
|
+
throw new TypeError(
|
|
3638
|
+
`${framePath}.runtime pending question names an unbound role`,
|
|
3639
|
+
);
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
for (const roleId of Object.keys(runtime.roleResumeTokens)) {
|
|
3643
|
+
if (roleBindings[roleId] === undefined) {
|
|
3644
|
+
throw new TypeError(
|
|
3645
|
+
`${framePath}.runtime role-resume token names an unbound role`,
|
|
3646
|
+
);
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
normalizedFrames.push({
|
|
3650
|
+
playbookId,
|
|
3651
|
+
sessionId,
|
|
3652
|
+
rootSessionId,
|
|
3653
|
+
depth,
|
|
3654
|
+
...(parentSessionId === undefined ? {} : { parentSessionId }),
|
|
3655
|
+
...(parentCallId === undefined ? {} : { parentCallId }),
|
|
3656
|
+
options,
|
|
3657
|
+
roleBindings,
|
|
3658
|
+
runtime,
|
|
3659
|
+
});
|
|
3660
|
+
}
|
|
3661
|
+
const sourceRootSessionId = normalizedFrames[0]!.sessionId;
|
|
3662
|
+
for (const [index, frame] of normalizedFrames.entries()) {
|
|
3663
|
+
if (frame.depth !== index || frame.rootSessionId !== sourceRootSessionId) {
|
|
3664
|
+
throw new TypeError(
|
|
3665
|
+
`${generationPath}.frames have inconsistent depth or root identity`,
|
|
3666
|
+
);
|
|
3667
|
+
}
|
|
3668
|
+
if (index === 0) {
|
|
3669
|
+
if (
|
|
3670
|
+
frame.playbookId !== rootPlaybookId ||
|
|
3671
|
+
frame.sessionId !== frame.rootSessionId ||
|
|
3672
|
+
frame.parentSessionId !== undefined ||
|
|
3673
|
+
frame.parentCallId !== undefined
|
|
3674
|
+
) {
|
|
3675
|
+
throw new TypeError(
|
|
3676
|
+
`${generationPath}.frames[0] is not the named root`,
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3679
|
+
continue;
|
|
3680
|
+
}
|
|
3681
|
+
const parent = normalizedFrames[index - 1]!;
|
|
3682
|
+
const suspended = parent.runtime.suspendedCall;
|
|
3683
|
+
if (
|
|
3684
|
+
frame.parentSessionId !== parent.sessionId ||
|
|
3685
|
+
frame.parentCallId === undefined ||
|
|
3686
|
+
suspended === undefined ||
|
|
3687
|
+
suspended.callId !== frame.parentCallId ||
|
|
3688
|
+
suspended.playbookId !== frame.playbookId ||
|
|
3689
|
+
suspended.childSessionId !== frame.sessionId
|
|
3690
|
+
) {
|
|
3691
|
+
throw new TypeError(
|
|
3692
|
+
`${generationPath}.frames[${index}] does not match its suspended parent edge`,
|
|
3693
|
+
);
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
const leaf = normalizedFrames.at(-1)!;
|
|
3697
|
+
if (
|
|
3698
|
+
leaf.runtime.suspendedCall !== undefined ||
|
|
3699
|
+
!leaf.runtime.state.tags.includes('playbook.parked')
|
|
3700
|
+
) {
|
|
3701
|
+
throw new TypeError(
|
|
3702
|
+
`${generationPath} leaf must be parked without a suspended child`,
|
|
3703
|
+
);
|
|
3704
|
+
}
|
|
3705
|
+
const markedFrames = normalizedFrames.filter(
|
|
3706
|
+
({ runtime }) => runtime.retainedEffectReconciliation !== undefined,
|
|
3707
|
+
);
|
|
3708
|
+
if (
|
|
3709
|
+
(sourceGenerationId === undefined && markedFrames.length !== 0) ||
|
|
3710
|
+
(sourceGenerationId !== undefined &&
|
|
3711
|
+
markedFrames.length !== normalizedFrames.length) ||
|
|
3712
|
+
(sourceGenerationId !== undefined &&
|
|
3713
|
+
normalizedFrames[0]!.runtime.retainedEffectReconciliation
|
|
3714
|
+
?.sourceSessionId !== sourceGenerationId)
|
|
3715
|
+
) {
|
|
3716
|
+
throw new TypeError(
|
|
3717
|
+
`${generationPath} retained-effect source marker is inconsistent`,
|
|
3718
|
+
);
|
|
3719
|
+
}
|
|
3720
|
+
normalized.set(
|
|
3721
|
+
rootPlaybookId,
|
|
3722
|
+
snapshotJsonValue(
|
|
3723
|
+
{
|
|
3724
|
+
effectLedger: generationEffectLedger,
|
|
3725
|
+
frames: normalizedFrames,
|
|
3726
|
+
...(sourceGenerationId === undefined
|
|
3727
|
+
? {}
|
|
3728
|
+
: {
|
|
3729
|
+
retainedEffectReconciliation: { sourceGenerationId },
|
|
3730
|
+
}),
|
|
3731
|
+
...(rootStateDescription === undefined
|
|
3732
|
+
? {}
|
|
3733
|
+
: { rootStateDescription }),
|
|
3734
|
+
},
|
|
3735
|
+
generationPath,
|
|
3736
|
+
) as unknown as PlaybookCaptainRetainedGeneration,
|
|
3737
|
+
);
|
|
3738
|
+
}
|
|
3739
|
+
return normalized;
|
|
3740
|
+
};
|
|
3741
|
+
|
|
3742
|
+
const runtimeRetainsGenerations = (runtime: PlaybookRuntime): boolean => {
|
|
3743
|
+
const metadata = runtime.retainedGenerationMetadata;
|
|
3744
|
+
return (
|
|
3745
|
+
typeof runtime.exportSnapshot === 'function' &&
|
|
3746
|
+
typeof runtime.restore === 'function' &&
|
|
3747
|
+
typeof runtime.adopt === 'function' &&
|
|
3748
|
+
metadata !== undefined &&
|
|
3749
|
+
Array.isArray(metadata.unfinishedFinalStateIds) &&
|
|
3750
|
+
metadata.unfinishedFinalStateIds.every(
|
|
3751
|
+
(stateId) => typeof stateId === 'string',
|
|
3752
|
+
)
|
|
3753
|
+
);
|
|
3754
|
+
};
|
|
3755
|
+
|
|
3756
|
+
class RetainedRuntimeCleanupError extends AggregateError {
|
|
3757
|
+
readonly failedRuntimes: readonly PlaybookRuntime[];
|
|
3758
|
+
|
|
3759
|
+
constructor(
|
|
3760
|
+
failures: readonly unknown[],
|
|
3761
|
+
message: string,
|
|
3762
|
+
failedRuntimes: readonly PlaybookRuntime[] = [],
|
|
3763
|
+
) {
|
|
3764
|
+
super(failures, message);
|
|
3765
|
+
this.name = 'RetainedRuntimeCleanupError';
|
|
3766
|
+
this.failedRuntimes = failedRuntimes;
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
const disposeRetainedRuntimeSet = async (
|
|
3771
|
+
runtimes: readonly PlaybookRuntime[],
|
|
3772
|
+
message: string,
|
|
3773
|
+
): Promise<void> => {
|
|
3774
|
+
const failures: unknown[] = [];
|
|
3775
|
+
const failedRuntimes: PlaybookRuntime[] = [];
|
|
3776
|
+
for (const runtime of [...runtimes].reverse()) {
|
|
3777
|
+
try {
|
|
3778
|
+
await runtime.dispose();
|
|
3779
|
+
} catch (error) {
|
|
3780
|
+
failures.push(error);
|
|
3781
|
+
failedRuntimes.unshift(runtime);
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
if (failures.length > 0) {
|
|
3785
|
+
throw new RetainedRuntimeCleanupError(
|
|
3786
|
+
failures,
|
|
3787
|
+
message,
|
|
3788
|
+
failedRuntimes,
|
|
3789
|
+
);
|
|
3790
|
+
}
|
|
3791
|
+
};
|
|
3792
|
+
|
|
3793
|
+
const retireRetainedOffer = (rootPlaybookId: string): void => {
|
|
3794
|
+
const offer = retainedGenerationOffers.get(rootPlaybookId);
|
|
3795
|
+
if (offer === undefined) return;
|
|
3796
|
+
retainedGenerationOffers.delete(rootPlaybookId);
|
|
3797
|
+
retiredRetainedRuntimes.push(...offer.runtimes);
|
|
3798
|
+
};
|
|
3799
|
+
|
|
3800
|
+
const applyRetentionUpdateToCatalog = (
|
|
3801
|
+
update: PlaybookCaptainRetentionUpdate,
|
|
3802
|
+
): void => {
|
|
3803
|
+
if (update.kind === 'clear') {
|
|
3804
|
+
retireRetainedOffer(update.rootPlaybookId);
|
|
3805
|
+
retainedGenerations.delete(update.rootPlaybookId);
|
|
3806
|
+
ineligibleRetainedGenerations.delete(update.rootPlaybookId);
|
|
3807
|
+
retainedGenerationRootClears.delete(update.rootPlaybookId);
|
|
3808
|
+
return;
|
|
3809
|
+
}
|
|
3810
|
+
const prior = retainedGenerations.get(update.rootPlaybookId);
|
|
3811
|
+
if (isDeepStrictEqual(prior, update.generation)) return;
|
|
3812
|
+
retireRetainedOffer(update.rootPlaybookId);
|
|
3813
|
+
retainedGenerations.set(update.rootPlaybookId, update.generation);
|
|
3814
|
+
ineligibleRetainedGenerations.delete(update.rootPlaybookId);
|
|
3815
|
+
retainedGenerationRootClears.delete(update.rootPlaybookId);
|
|
3816
|
+
};
|
|
3817
|
+
|
|
3818
|
+
const drainRetiredRetainedRuntimes = async (): Promise<void> => {
|
|
3819
|
+
if (retiredRetainedRuntimes.length === 0) return;
|
|
3820
|
+
const runtimes = retiredRetainedRuntimes.splice(0);
|
|
3821
|
+
try {
|
|
3822
|
+
await disposeRetainedRuntimeSet(
|
|
3823
|
+
runtimes,
|
|
3824
|
+
'retired retained-generation runtime cleanup failed',
|
|
3825
|
+
);
|
|
3826
|
+
} catch (error) {
|
|
3827
|
+
if (error instanceof RetainedRuntimeCleanupError) {
|
|
3828
|
+
retiredRetainedRuntimes.unshift(...error.failedRuntimes);
|
|
3829
|
+
}
|
|
3830
|
+
terminallyDisposed = true;
|
|
3831
|
+
lifecycle = 'closed';
|
|
3832
|
+
throw error;
|
|
3833
|
+
}
|
|
3834
|
+
};
|
|
3835
|
+
|
|
3836
|
+
const takeRetainedOfferRuntimes = (): readonly PlaybookRuntime[] => {
|
|
3837
|
+
const runtimes = [
|
|
3838
|
+
...[...retainedGenerationOffers.values()].flatMap((offer) => [
|
|
3839
|
+
...offer.runtimes,
|
|
3840
|
+
]),
|
|
3841
|
+
...retiredRetainedRuntimes.splice(0),
|
|
3842
|
+
];
|
|
3843
|
+
retainedGenerationOffers.clear();
|
|
3844
|
+
return runtimes;
|
|
3845
|
+
};
|
|
3846
|
+
|
|
3847
|
+
const prepareRetainedGenerationOffers = async (): Promise<void> => {
|
|
3848
|
+
if (rootFrame() !== undefined) return;
|
|
3849
|
+
for (const [rootPlaybookId, generation] of [...retainedGenerations].sort(
|
|
3850
|
+
([left], [right]) => left.localeCompare(right),
|
|
3851
|
+
)) {
|
|
3852
|
+
if (
|
|
3853
|
+
retainedGenerationOffers.has(rootPlaybookId) ||
|
|
3854
|
+
ineligibleRetainedGenerations.has(rootPlaybookId)
|
|
3855
|
+
) {
|
|
3856
|
+
continue;
|
|
3857
|
+
}
|
|
3858
|
+
const runtimes: PlaybookRuntime[] = [];
|
|
3859
|
+
try {
|
|
3860
|
+
for (const sourceFrame of generation.frames) {
|
|
3861
|
+
const enablement = enablementById.get(sourceFrame.playbookId)!;
|
|
3862
|
+
runtimes.push(
|
|
3863
|
+
createRuntimeForEnablement(enablement, hostCapabilitiesById),
|
|
3864
|
+
);
|
|
3865
|
+
}
|
|
3866
|
+
if (runtimes.some((runtime) => !runtimeRetainsGenerations(runtime))) {
|
|
3867
|
+
const rootRetainsGenerations = runtimeRetainsGenerations(runtimes[0]!);
|
|
3868
|
+
await disposeRetainedRuntimeSet(
|
|
3869
|
+
runtimes,
|
|
3870
|
+
`/${enablementById.get(rootPlaybookId)!.command} retained-generation capability cleanup failed`,
|
|
3871
|
+
);
|
|
3872
|
+
runtimes.splice(0);
|
|
3873
|
+
ineligibleRetainedGenerations.add(rootPlaybookId);
|
|
3874
|
+
if (!rootRetainsGenerations) {
|
|
3875
|
+
retainedGenerationRootClears.add(rootPlaybookId);
|
|
3876
|
+
}
|
|
3877
|
+
continue;
|
|
3878
|
+
}
|
|
3879
|
+
retainedGenerationOffers.set(rootPlaybookId, {
|
|
3880
|
+
generation,
|
|
3881
|
+
requiresEffectReconciliation:
|
|
3882
|
+
generation.retainedEffectReconciliation !== undefined ||
|
|
3883
|
+
generation.frames.some(
|
|
3884
|
+
({ runtime }) =>
|
|
3885
|
+
runtime.retainedEffectReconciliation !== undefined,
|
|
3886
|
+
) ||
|
|
3887
|
+
!retainedEffectLedgerCanRebase(
|
|
3888
|
+
generation.effectLedger as PlaybookEffectLedger,
|
|
3889
|
+
assertPlaybookEffectLedger(currentEffectLedger()),
|
|
3890
|
+
),
|
|
3891
|
+
runtimes,
|
|
3892
|
+
});
|
|
3893
|
+
} catch (error) {
|
|
3894
|
+
if (error instanceof RetainedRuntimeCleanupError) {
|
|
3895
|
+
retiredRetainedRuntimes.push(...error.failedRuntimes);
|
|
3896
|
+
terminallyDisposed = true;
|
|
3897
|
+
lifecycle = 'closed';
|
|
3898
|
+
throw error;
|
|
3899
|
+
}
|
|
3900
|
+
let cleanupError: unknown;
|
|
3901
|
+
if (runtimes.length > 0) {
|
|
3902
|
+
try {
|
|
3903
|
+
await disposeRetainedRuntimeSet(
|
|
3904
|
+
runtimes,
|
|
3905
|
+
'retained-generation preparation cleanup failed',
|
|
3906
|
+
);
|
|
3907
|
+
} catch (caught) {
|
|
3908
|
+
cleanupError = caught;
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
if (cleanupError !== undefined) {
|
|
3912
|
+
if (cleanupError instanceof RetainedRuntimeCleanupError) {
|
|
3913
|
+
retiredRetainedRuntimes.push(
|
|
3914
|
+
...cleanupError.failedRuntimes,
|
|
3915
|
+
);
|
|
3916
|
+
}
|
|
3917
|
+
terminallyDisposed = true;
|
|
3918
|
+
lifecycle = 'closed';
|
|
3919
|
+
throw new RetainedRuntimeCleanupError(
|
|
3920
|
+
[error, cleanupError],
|
|
3921
|
+
'retained-generation preparation and cleanup failed',
|
|
3922
|
+
cleanupError instanceof RetainedRuntimeCleanupError
|
|
3923
|
+
? cleanupError.failedRuntimes
|
|
3924
|
+
: [],
|
|
3925
|
+
);
|
|
3926
|
+
}
|
|
3927
|
+
ineligibleRetainedGenerations.add(rootPlaybookId);
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
};
|
|
3931
|
+
|
|
2322
3932
|
const bindingFor = (
|
|
2323
3933
|
frame: EngagementFrame,
|
|
2324
3934
|
localRole: string,
|
|
@@ -2370,7 +3980,10 @@ export function createPlaybookCaptainShell(
|
|
|
2370
3980
|
...(leafFrame()?.state
|
|
2371
3981
|
? { latestSubRuntimeState: leafFrame()!.state }
|
|
2372
3982
|
: {}),
|
|
2373
|
-
...(
|
|
3983
|
+
...(retainedEffectReconciliation === undefined &&
|
|
3984
|
+
pendingBossQuestions !== undefined
|
|
3985
|
+
? { pendingBossQuestions }
|
|
3986
|
+
: {}),
|
|
2374
3987
|
...(lastError ? { lastError } : {}),
|
|
2375
3988
|
...(captainSessionId ? { captainSessionId } : {}),
|
|
2376
3989
|
// Presence only: the pinned token value never reaches telemetry
|
|
@@ -2591,6 +4204,69 @@ export function createPlaybookCaptainShell(
|
|
|
2591
4204
|
}
|
|
2592
4205
|
};
|
|
2593
4206
|
|
|
4207
|
+
const observeSummaryTrace = (
|
|
4208
|
+
frame: EngagementFrame,
|
|
4209
|
+
payload: unknown,
|
|
4210
|
+
): void => {
|
|
4211
|
+
const trace = payloadRecord(payload);
|
|
4212
|
+
const turn = activeTurn;
|
|
4213
|
+
const summary = activeTurnSummary;
|
|
4214
|
+
const expectedParentSessionId = frame.parent?.frame.sessionId;
|
|
4215
|
+
const expectedParentCallId = frame.parent?.callId;
|
|
4216
|
+
if (
|
|
4217
|
+
trace?.schemaVersion !== 4 ||
|
|
4218
|
+
trace.sessionId !== frame.sessionId ||
|
|
4219
|
+
trace.playbookId !== frame.entry.id ||
|
|
4220
|
+
trace.rootSessionId !== frame.rootSessionId ||
|
|
4221
|
+
(expectedParentSessionId === undefined
|
|
4222
|
+
? Object.hasOwn(trace, 'parentSessionId')
|
|
4223
|
+
: !Object.hasOwn(trace, 'parentSessionId') ||
|
|
4224
|
+
trace.parentSessionId !== expectedParentSessionId) ||
|
|
4225
|
+
(expectedParentCallId === undefined
|
|
4226
|
+
? Object.hasOwn(trace, 'parentCallId')
|
|
4227
|
+
: !Object.hasOwn(trace, 'parentCallId') ||
|
|
4228
|
+
trace.parentCallId !== expectedParentCallId) ||
|
|
4229
|
+
trace.depth !== frame.depth ||
|
|
4230
|
+
turn === undefined ||
|
|
4231
|
+
!Number.isSafeInteger(trace.turnId) ||
|
|
4232
|
+
(trace.turnId as number) <= 0 ||
|
|
4233
|
+
!Number.isSafeInteger(trace.sequence) ||
|
|
4234
|
+
(trace.sequence as number) <= 0 ||
|
|
4235
|
+
summary === undefined ||
|
|
4236
|
+
!summaryIncludes(frame)
|
|
4237
|
+
) {
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
if (trace.type !== 'outcome.accepted') return;
|
|
4241
|
+
const receipt = exactOwnDataRecord(trace.payload, [
|
|
4242
|
+
'source',
|
|
4243
|
+
'target',
|
|
4244
|
+
'acceptedOutcome',
|
|
4245
|
+
]);
|
|
4246
|
+
if (
|
|
4247
|
+
receipt === undefined ||
|
|
4248
|
+
typeof receipt.source !== 'string' ||
|
|
4249
|
+
receipt.source.trim().length === 0 ||
|
|
4250
|
+
typeof receipt.target !== 'string' ||
|
|
4251
|
+
receipt.target.trim().length === 0 ||
|
|
4252
|
+
typeof receipt.acceptedOutcome !== 'string' ||
|
|
4253
|
+
receipt.acceptedOutcome.trim().length === 0
|
|
4254
|
+
) {
|
|
4255
|
+
return;
|
|
4256
|
+
}
|
|
4257
|
+
const traceKey = `${frame.sessionId}:${trace.sequence}`;
|
|
4258
|
+
if (summary.acceptedOutcomeTraceKeys.has(traceKey)) return;
|
|
4259
|
+
summary.acceptedOutcomeTraceKeys.add(traceKey);
|
|
4260
|
+
summary.counts.interruptions++;
|
|
4261
|
+
if (
|
|
4262
|
+
frame.entry.summaryPolicy?.copyPasteGuardNames.includes(
|
|
4263
|
+
receipt.acceptedOutcome,
|
|
4264
|
+
)
|
|
4265
|
+
) {
|
|
4266
|
+
summary.counts.copyPastes++;
|
|
4267
|
+
}
|
|
4268
|
+
};
|
|
4269
|
+
|
|
2594
4270
|
let callNestedPlaybook: (
|
|
2595
4271
|
frame: EngagementFrame,
|
|
2596
4272
|
request: PlaybookCallRequest,
|
|
@@ -2787,16 +4463,6 @@ export function createPlaybookCaptainShell(
|
|
|
2787
4463
|
if (result.finalText === undefined) {
|
|
2788
4464
|
throw new Error('callCaptain returned status=ok with no finalText');
|
|
2789
4465
|
}
|
|
2790
|
-
const guard = guardFromJudgeReply(result.finalText);
|
|
2791
|
-
const summary = activeTurnSummary;
|
|
2792
|
-
if (
|
|
2793
|
-
guard &&
|
|
2794
|
-
summary &&
|
|
2795
|
-
summaryIncludes(frame) &&
|
|
2796
|
-
frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)
|
|
2797
|
-
) {
|
|
2798
|
-
summary.counts.copyPastes++;
|
|
2799
|
-
}
|
|
2800
4466
|
return result.finalText;
|
|
2801
4467
|
},
|
|
2802
4468
|
callPlaybook: (request, signal) => {
|
|
@@ -2832,6 +4498,9 @@ export function createPlaybookCaptainShell(
|
|
|
2832
4498
|
await mirrorSubRuntimeTelemetry(frame, event.payload);
|
|
2833
4499
|
}
|
|
2834
4500
|
await requireSession().emitTelemetry(event);
|
|
4501
|
+
if (event.topic === 'playbook.trace') {
|
|
4502
|
+
observeSummaryTrace(frame, event.payload);
|
|
4503
|
+
}
|
|
2835
4504
|
})();
|
|
2836
4505
|
return trackHostCall(frame, emission);
|
|
2837
4506
|
},
|
|
@@ -2855,7 +4524,7 @@ export function createPlaybookCaptainShell(
|
|
|
2855
4524
|
}
|
|
2856
4525
|
};
|
|
2857
4526
|
|
|
2858
|
-
const
|
|
4527
|
+
const generatedSessionId = (): string => {
|
|
2859
4528
|
const sessionId = createSessionId();
|
|
2860
4529
|
if (!UUID_PATTERN.test(sessionId)) {
|
|
2861
4530
|
throw new Error(
|
|
@@ -2864,6 +4533,11 @@ export function createPlaybookCaptainShell(
|
|
|
2864
4533
|
)}`,
|
|
2865
4534
|
);
|
|
2866
4535
|
}
|
|
4536
|
+
return sessionId;
|
|
4537
|
+
};
|
|
4538
|
+
|
|
4539
|
+
const allocateSessionId = (): string => {
|
|
4540
|
+
const sessionId = generatedSessionId();
|
|
2867
4541
|
if (issuedSessionIds.has(sessionId)) {
|
|
2868
4542
|
throw new Error(`playbook session id collision: ${sessionId}`);
|
|
2869
4543
|
}
|
|
@@ -2871,6 +4545,30 @@ export function createPlaybookCaptainShell(
|
|
|
2871
4545
|
return sessionId;
|
|
2872
4546
|
};
|
|
2873
4547
|
|
|
4548
|
+
const allocateAdoptionSessionIds = (
|
|
4549
|
+
count: number,
|
|
4550
|
+
sourceSessionIds: ReadonlySet<string>,
|
|
4551
|
+
): readonly string[] => {
|
|
4552
|
+
const candidates: string[] = [];
|
|
4553
|
+
const rejectedSourceIds = new Set<string>();
|
|
4554
|
+
while (candidates.length < count) {
|
|
4555
|
+
const candidate = generatedSessionId();
|
|
4556
|
+
if (sourceSessionIds.has(candidate)) {
|
|
4557
|
+
if (rejectedSourceIds.has(candidate)) {
|
|
4558
|
+
throw new Error(`playbook source session id collision: ${candidate}`);
|
|
4559
|
+
}
|
|
4560
|
+
rejectedSourceIds.add(candidate);
|
|
4561
|
+
continue;
|
|
4562
|
+
}
|
|
4563
|
+
if (issuedSessionIds.has(candidate) || candidates.includes(candidate)) {
|
|
4564
|
+
throw new Error(`playbook session id collision: ${candidate}`);
|
|
4565
|
+
}
|
|
4566
|
+
candidates.push(candidate);
|
|
4567
|
+
}
|
|
4568
|
+
for (const candidate of candidates) issuedSessionIds.add(candidate);
|
|
4569
|
+
return candidates;
|
|
4570
|
+
};
|
|
4571
|
+
|
|
2874
4572
|
const normalizeErrorFull = (value: unknown): NormalizedError => {
|
|
2875
4573
|
const compact = normalizeErrorCompact(value) ?? {
|
|
2876
4574
|
name: 'Error',
|
|
@@ -2898,7 +4596,10 @@ export function createPlaybookCaptainShell(
|
|
|
2898
4596
|
const entry = enablement.entry;
|
|
2899
4597
|
const sessionId = allocateSessionId();
|
|
2900
4598
|
const playerBindings = makePlayerBindings(enablement);
|
|
2901
|
-
const runtime =
|
|
4599
|
+
const runtime = createRuntimeForEnablement(
|
|
4600
|
+
enablement,
|
|
4601
|
+
hostCapabilitiesById,
|
|
4602
|
+
);
|
|
2902
4603
|
return {
|
|
2903
4604
|
entry,
|
|
2904
4605
|
enablement,
|
|
@@ -2919,7 +4620,10 @@ export function createPlaybookCaptainShell(
|
|
|
2919
4620
|
): EngagementFrame => {
|
|
2920
4621
|
const entry = enablement.entry;
|
|
2921
4622
|
const playerBindings = makePlayerBindings(enablement);
|
|
2922
|
-
const runtime =
|
|
4623
|
+
const runtime = createRuntimeForEnablement(
|
|
4624
|
+
enablement,
|
|
4625
|
+
hostCapabilitiesById,
|
|
4626
|
+
);
|
|
2923
4627
|
return {
|
|
2924
4628
|
entry,
|
|
2925
4629
|
enablement,
|
|
@@ -2976,16 +4680,6 @@ export function createPlaybookCaptainShell(
|
|
|
2976
4680
|
try {
|
|
2977
4681
|
if (resumeToken === undefined) delete ledger.resumeToken;
|
|
2978
4682
|
else ledger.resumeToken = resumeToken;
|
|
2979
|
-
// CAPTAIN-20: a result counts only after the runtime validated it and
|
|
2980
|
-
// atomically published its authorized continuation transition.
|
|
2981
|
-
const summary = activeTurnSummary;
|
|
2982
|
-
if (
|
|
2983
|
-
pending.status === 'ok' &&
|
|
2984
|
-
summary &&
|
|
2985
|
-
summaryIncludes(frame)
|
|
2986
|
-
) {
|
|
2987
|
-
summary.counts.interruptions++;
|
|
2988
|
-
}
|
|
2989
4683
|
} finally {
|
|
2990
4684
|
playerTransactions.delete(binding.playerId);
|
|
2991
4685
|
}
|
|
@@ -3265,6 +4959,7 @@ export function createPlaybookCaptainShell(
|
|
|
3265
4959
|
}
|
|
3266
4960
|
}
|
|
3267
4961
|
clearLeafLedger();
|
|
4962
|
+
if (frames.length === 0) retainedEffectReconciliation = undefined;
|
|
3268
4963
|
if (failures.length === 1) throw failures[0];
|
|
3269
4964
|
if (failures.length > 1) {
|
|
3270
4965
|
throw new AggregateError(
|
|
@@ -3374,10 +5069,62 @@ export function createPlaybookCaptainShell(
|
|
|
3374
5069
|
}
|
|
3375
5070
|
};
|
|
3376
5071
|
|
|
5072
|
+
/**
|
|
5073
|
+
* DR-040 task 11: abandonment is a host settlement, not an authored FSM
|
|
5074
|
+
* result. Freeze the bounded evidence while the complete stack and its
|
|
5075
|
+
* runtime-owned envelope identities still exist, durably fence recovery,
|
|
5076
|
+
* dispose leaf-to-root without resuming a parent, then publish the matching
|
|
5077
|
+
* root clear and evidence as one durable completion before the controller
|
|
5078
|
+
* may return an executed receipt to its result phase.
|
|
5079
|
+
*/
|
|
5080
|
+
const settleUnresolvedEffectAbandonment = async (
|
|
5081
|
+
unresolvedLeaf: EngagementFrame,
|
|
5082
|
+
): Promise<void> => {
|
|
5083
|
+
if (leafFrame() !== unresolvedLeaf) {
|
|
5084
|
+
throw new Error(
|
|
5085
|
+
'unresolved-effect abandonment requires the active leaf',
|
|
5086
|
+
);
|
|
5087
|
+
}
|
|
5088
|
+
const root = rootFrame();
|
|
5089
|
+
if (root === undefined) {
|
|
5090
|
+
throw new Error(
|
|
5091
|
+
'unresolved-effect abandonment requires an active root',
|
|
5092
|
+
);
|
|
5093
|
+
}
|
|
5094
|
+
const unresolvedEffects = freezeTurnUnresolvedEffects();
|
|
5095
|
+
if (unresolvedEffects.length === 0) {
|
|
5096
|
+
throw new Error(
|
|
5097
|
+
'unresolved-effect abandonment requires nonempty effect evidence',
|
|
5098
|
+
);
|
|
5099
|
+
}
|
|
5100
|
+
if (unresolvedEffectSettlement === undefined) {
|
|
5101
|
+
throw new Error(
|
|
5102
|
+
'unresolved-effect abandonment requires durable host settlement',
|
|
5103
|
+
);
|
|
5104
|
+
}
|
|
5105
|
+
const rootPlaybookId = root.entry.id;
|
|
5106
|
+
const settlement = Object.freeze({
|
|
5107
|
+
rootPlaybookId,
|
|
5108
|
+
unresolvedEffects,
|
|
5109
|
+
});
|
|
5110
|
+
await runEffect(() => unresolvedEffectSettlement.begin(settlement));
|
|
5111
|
+
await runEffect(() => disposeStack('unresolved-effect'));
|
|
5112
|
+
pendingRetentionUpdates.set(rootPlaybookId, {
|
|
5113
|
+
kind: 'clear',
|
|
5114
|
+
rootPlaybookId,
|
|
5115
|
+
});
|
|
5116
|
+
await runEffect(() => unresolvedEffectSettlement.complete(settlement));
|
|
5117
|
+
};
|
|
5118
|
+
|
|
3377
5119
|
const callResultFor = (
|
|
3378
5120
|
frame: EngagementFrame,
|
|
3379
5121
|
result: PlaybookRunResult,
|
|
3380
5122
|
): PlaybookCallResult => {
|
|
5123
|
+
if (result.outcome === 'unresolved-effect') {
|
|
5124
|
+
throw new Error(
|
|
5125
|
+
`playbook ${frame.entry.id} unresolved-effect result cannot resume a parent`,
|
|
5126
|
+
);
|
|
5127
|
+
}
|
|
3381
5128
|
if (result.outcome === 'terminal') {
|
|
3382
5129
|
return {
|
|
3383
5130
|
status: 'ok',
|
|
@@ -3425,6 +5172,11 @@ export function createPlaybookCaptainShell(
|
|
|
3425
5172
|
if (leafFrame() !== frame) {
|
|
3426
5173
|
throw new Error('only the active leaf may receive Boss input');
|
|
3427
5174
|
}
|
|
5175
|
+
if (retainedEffectReconciliation !== undefined) {
|
|
5176
|
+
throw new Error(
|
|
5177
|
+
'retained repository-effect reconciliation is required before Boss input',
|
|
5178
|
+
);
|
|
5179
|
+
}
|
|
3428
5180
|
// CAPTAIN-35: the leaf check, the visibility request, and the mode change
|
|
3429
5181
|
// are shell control work performed on the way to the runtime, not the
|
|
3430
5182
|
// effect. Only the call below is the effect, so only it is inside the
|
|
@@ -3451,6 +5203,11 @@ export function createPlaybookCaptainShell(
|
|
|
3451
5203
|
const parentLink = child.parent;
|
|
3452
5204
|
if (!parentLink) throw new Error('root playbook has no caller');
|
|
3453
5205
|
const parent = parentLink.frame;
|
|
5206
|
+
if (retainedEffectReconciliation !== undefined) {
|
|
5207
|
+
throw new Error(
|
|
5208
|
+
'retained repository-effect reconciliation is required before parent resumption',
|
|
5209
|
+
);
|
|
5210
|
+
}
|
|
3454
5211
|
const invocationSignal = child.invocationSignal;
|
|
3455
5212
|
let effectiveResult = callResult;
|
|
3456
5213
|
let ownsReturn = false;
|
|
@@ -3535,6 +5292,16 @@ export function createPlaybookCaptainShell(
|
|
|
3535
5292
|
result: PlaybookRunResult,
|
|
3536
5293
|
context: CaptainContext,
|
|
3537
5294
|
): Promise<void> {
|
|
5295
|
+
if (result.outcome === 'unresolved-effect') {
|
|
5296
|
+
// Task 10 exposes the runtime-owned abandonment signal without
|
|
5297
|
+
// translating it into a nested result or claiming workflow completion.
|
|
5298
|
+
// Task 11 owns the durable host settlement and complete-stack disposal.
|
|
5299
|
+
assertRetainableResult(frame, result);
|
|
5300
|
+
if (leafFrame() === frame) {
|
|
5301
|
+
await setMode('engaged.parked', 'turn:unresolved-effect');
|
|
5302
|
+
}
|
|
5303
|
+
return;
|
|
5304
|
+
}
|
|
3538
5305
|
if (result.outcome === 'terminal') {
|
|
3539
5306
|
if (frame.parent) {
|
|
3540
5307
|
await resumeParent(frame, callResultFor(frame, result), context);
|
|
@@ -3545,6 +5312,7 @@ export function createPlaybookCaptainShell(
|
|
|
3545
5312
|
// output remains runtime-to-runtime data and never becomes Captain
|
|
3546
5313
|
// evidence (CAPPLAY-10).
|
|
3547
5314
|
activeTurn?.settlementFacts.push(rootCompletionFact(frame, result));
|
|
5315
|
+
recordTerminalRetention(frame, result);
|
|
3548
5316
|
await runEffect(() => disposeStack('final'));
|
|
3549
5317
|
}
|
|
3550
5318
|
return;
|
|
@@ -3761,7 +5529,14 @@ export function createPlaybookCaptainShell(
|
|
|
3761
5529
|
const policy = frame.entry.summaryPolicy;
|
|
3762
5530
|
const counts: TurnSummaryCounts = { interruptions: 0, copyPastes: 0 };
|
|
3763
5531
|
const stateCounts = new Map<string, number>();
|
|
3764
|
-
activeTurnSummary = policy
|
|
5532
|
+
activeTurnSummary = policy
|
|
5533
|
+
? {
|
|
5534
|
+
owner: frame,
|
|
5535
|
+
counts,
|
|
5536
|
+
stateCounts,
|
|
5537
|
+
acceptedOutcomeTraceKeys: new Set(),
|
|
5538
|
+
}
|
|
5539
|
+
: undefined;
|
|
3765
5540
|
let result: T | undefined;
|
|
3766
5541
|
let error: unknown;
|
|
3767
5542
|
try {
|
|
@@ -3834,12 +5609,79 @@ export function createPlaybookCaptainShell(
|
|
|
3834
5609
|
];
|
|
3835
5610
|
};
|
|
3836
5611
|
|
|
5612
|
+
const retainedResumptionDigest = (): string => {
|
|
5613
|
+
if (rootFrame() !== undefined) {
|
|
5614
|
+
return 'Retained resumptions: unavailable while a playbook is engaged.';
|
|
5615
|
+
}
|
|
5616
|
+
const offers = [...retainedGenerationOffers].sort(([left], [right]) =>
|
|
5617
|
+
left.localeCompare(right),
|
|
5618
|
+
);
|
|
5619
|
+
if (offers.length === 0) return 'Retained resumptions: none.';
|
|
5620
|
+
const lines = ['Retained resumptions:'];
|
|
5621
|
+
for (const [rootPlaybookId, offer] of offers) {
|
|
5622
|
+
const enablement = enablementById.get(rootPlaybookId)!;
|
|
5623
|
+
lines.push(
|
|
5624
|
+
digestLine`- ${rootPlaybookId} (/${enablement.command}): ${
|
|
5625
|
+
offer.generation.rootStateDescription ??
|
|
5626
|
+
'(no published root-state description was retained)'
|
|
5627
|
+
}`,
|
|
5628
|
+
);
|
|
5629
|
+
}
|
|
5630
|
+
return lines.join('\n');
|
|
5631
|
+
};
|
|
5632
|
+
|
|
3837
5633
|
const controlViewDigest = (): string => {
|
|
3838
5634
|
const leaf = leafFrame();
|
|
3839
5635
|
const lines: string[] = [digestLine`Active path: ${activePathDigest()}`];
|
|
3840
5636
|
if (!leaf) {
|
|
3841
5637
|
lines.push('The shell is idle: no leaf state, no pending question.');
|
|
3842
5638
|
lines.push('Advertised actions: none.');
|
|
5639
|
+
lines.push(retainedResumptionDigest());
|
|
5640
|
+
return lines.join('\n');
|
|
5641
|
+
}
|
|
5642
|
+
refreshRetainedEffectFence();
|
|
5643
|
+
if (retainedEffectReconciliation !== undefined) {
|
|
5644
|
+
let reconciliationActions: readonly {
|
|
5645
|
+
readonly id: string;
|
|
5646
|
+
readonly label: string;
|
|
5647
|
+
}[] = [];
|
|
5648
|
+
if (
|
|
5649
|
+
typeof leaf.runtime.describe === 'function' &&
|
|
5650
|
+
typeof leaf.runtime.apply === 'function'
|
|
5651
|
+
) {
|
|
5652
|
+
try {
|
|
5653
|
+
reconciliationActions = leaf.runtime
|
|
5654
|
+
.describe()
|
|
5655
|
+
.actions.filter(
|
|
5656
|
+
({ id }) =>
|
|
5657
|
+
id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
|
|
5658
|
+
id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
|
|
5659
|
+
);
|
|
5660
|
+
} catch {
|
|
5661
|
+
// An unreadable control surface cannot open a fail-closed fence.
|
|
5662
|
+
}
|
|
5663
|
+
}
|
|
5664
|
+
for (const action of reconciliationActions) {
|
|
5665
|
+
recordSuppliedIdentifier(action.id);
|
|
5666
|
+
}
|
|
5667
|
+
lines.push(
|
|
5668
|
+
`Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`,
|
|
5669
|
+
);
|
|
5670
|
+
lines.push('Pending Boss questions: withheld until reconciliation.');
|
|
5671
|
+
lines.push(
|
|
5672
|
+
reconciliationActions.length === 0
|
|
5673
|
+
? 'Advertised actions: none.'
|
|
5674
|
+
: [
|
|
5675
|
+
'Advertised actions:',
|
|
5676
|
+
...reconciliationActions.map(
|
|
5677
|
+
(action) => digestLine`- ${action.id}: ${action.label}`,
|
|
5678
|
+
),
|
|
5679
|
+
].join('\n'),
|
|
5680
|
+
);
|
|
5681
|
+
lines.push(
|
|
5682
|
+
'Ordinary delivery, switching, dismissal, and runtime actions are unavailable while retained effect evidence is unresolved. Only the advertised unresolved-effect controls may run. Conversation is unaffected: `respond` stays valid for any turn.',
|
|
5683
|
+
);
|
|
5684
|
+
lines.push(retainedResumptionDigest());
|
|
3843
5685
|
return lines.join('\n');
|
|
3844
5686
|
}
|
|
3845
5687
|
let view: PlaybookControlView | undefined;
|
|
@@ -3897,6 +5739,7 @@ export function createPlaybookCaptainShell(
|
|
|
3897
5739
|
? 'This leaf advertises no runtime action, so plain text delivery is the only machine verb against it and a `runtime` selection is invalid. Conversation is unaffected: `respond` stays valid for any turn.'
|
|
3898
5740
|
: 'No runtime action can be validated while the control view is unreadable, so plain text delivery is the only machine verb against it this turn and a `runtime` selection is invalid. Conversation is unaffected: `respond` stays valid for any turn.',
|
|
3899
5741
|
);
|
|
5742
|
+
lines.push(retainedResumptionDigest());
|
|
3900
5743
|
return lines.join('\n');
|
|
3901
5744
|
}
|
|
3902
5745
|
// CAPTAIN-9: the guarded set is what the digest supplies *for selection* —
|
|
@@ -3949,6 +5792,7 @@ export function createPlaybookCaptainShell(
|
|
|
3949
5792
|
),
|
|
3950
5793
|
].join('\n'),
|
|
3951
5794
|
);
|
|
5795
|
+
lines.push(retainedResumptionDigest());
|
|
3952
5796
|
return lines.join('\n');
|
|
3953
5797
|
};
|
|
3954
5798
|
|
|
@@ -4043,9 +5887,14 @@ export function createPlaybookCaptainShell(
|
|
|
4043
5887
|
// all of this prose. Preserve the exact attempt before crossing the
|
|
4044
5888
|
// boundary; the uncertainty record below keeps recovery from pretending
|
|
4045
5889
|
// delivery was confirmed while still understanding a Boss follow-up.
|
|
4046
|
-
|
|
5890
|
+
const suffix = turn?.mandatoryPresentationSuffix;
|
|
5891
|
+
const visibleText =
|
|
5892
|
+
suffix === undefined || settlement.text.includes(suffix)
|
|
5893
|
+
? settlement.text
|
|
5894
|
+
: `${settlement.text.trimEnd()}\n\n${suffix}`;
|
|
5895
|
+
appendJournal('reply', visibleText);
|
|
4047
5896
|
try {
|
|
4048
|
-
await trackTurnCall(settlement.context.emitReply(
|
|
5897
|
+
await trackTurnCall(settlement.context.emitReply(visibleText));
|
|
4049
5898
|
} catch (error) {
|
|
4050
5899
|
conversation = { kind: 'needsSeeding' };
|
|
4051
5900
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
@@ -4211,8 +6060,9 @@ export function createPlaybookCaptainShell(
|
|
|
4211
6060
|
};
|
|
4212
6061
|
|
|
4213
6062
|
/**
|
|
4214
|
-
* CAPTAIN-35: the one wrapper an effect runs through — a runtime driven
|
|
4215
|
-
* engagement constructed, a stack disposed, an advertised
|
|
6063
|
+
* CAPTAIN-35: the one wrapper an effect runs through — a runtime driven or
|
|
6064
|
+
* adopted, an engagement constructed, a stack disposed, an advertised
|
|
6065
|
+
* action applied.
|
|
4216
6066
|
* Attribution is recorded here, at the operation that threw, and nowhere
|
|
4217
6067
|
* else: an error acquires the mark by escaping this call, so no later
|
|
4218
6068
|
* failure can inherit it.
|
|
@@ -4225,7 +6075,7 @@ export function createPlaybookCaptainShell(
|
|
|
4225
6075
|
* instead of settling, so a misfiling costs the Boss their only settlement.
|
|
4226
6076
|
*
|
|
4227
6077
|
* Neither can a boundary drawn around a *region* of the turn. `operation` is
|
|
4228
|
-
* therefore always one call expression naming one of those
|
|
6078
|
+
* therefore always one call expression naming one of those five operations,
|
|
4229
6079
|
* never a closure that also performs the shell work leading to it: the leaf
|
|
4230
6080
|
* check, the visibility request, the mode change, and the processing of what
|
|
4231
6081
|
* the runtime returned are all shell control work, and a boundary wide
|
|
@@ -4493,7 +6343,10 @@ export function createPlaybookCaptainShell(
|
|
|
4493
6343
|
...(kind === 'closingReply' && turn?.report
|
|
4494
6344
|
? [
|
|
4495
6345
|
labeledBlock('ControlView digest', controlViewDigest()),
|
|
4496
|
-
outcomeReportBlock(
|
|
6346
|
+
outcomeReportBlock(
|
|
6347
|
+
turn.report,
|
|
6348
|
+
turn.unresolvedEffects ?? [],
|
|
6349
|
+
),
|
|
4497
6350
|
]
|
|
4498
6351
|
: []),
|
|
4499
6352
|
...(options.proseRejection === undefined
|
|
@@ -4670,6 +6523,9 @@ export function createPlaybookCaptainShell(
|
|
|
4670
6523
|
const leafStateSummary = (): string | undefined => {
|
|
4671
6524
|
const leaf = leafFrame();
|
|
4672
6525
|
if (!leaf) return 'idle: no playbook is engaged';
|
|
6526
|
+
if (retainedEffectReconciliation !== undefined) {
|
|
6527
|
+
return `${frameLabel(leaf)} parked for repository-effect reconciliation`;
|
|
6528
|
+
}
|
|
4673
6529
|
if (!leaf.state) return `${frameLabel(leaf)} engaged`;
|
|
4674
6530
|
return `${frameLabel(leaf)} at ${stateDigestLine(
|
|
4675
6531
|
leaf.state,
|
|
@@ -4681,9 +6537,9 @@ export function createPlaybookCaptainShell(
|
|
|
4681
6537
|
selection: CaptainControllerSelection | undefined,
|
|
4682
6538
|
reason: string,
|
|
4683
6539
|
options: { silent?: boolean } = {},
|
|
4684
|
-
): Promise<
|
|
6540
|
+
): Promise<ControllerSettlementDraft> => {
|
|
4685
6541
|
const summary = leafStateSummary();
|
|
4686
|
-
const settlement:
|
|
6542
|
+
const settlement: ControllerSettlementDraft = {
|
|
4687
6543
|
status: 'rejected',
|
|
4688
6544
|
facts: [`Rejected: ${reason}.`],
|
|
4689
6545
|
reason,
|
|
@@ -4729,6 +6585,9 @@ export function createPlaybookCaptainShell(
|
|
|
4729
6585
|
const root = rootFrame();
|
|
4730
6586
|
if (!root) return false;
|
|
4731
6587
|
const label = frameLabel(root);
|
|
6588
|
+
// Dismissal leaves the procedure unfinished. Persist the latest safe
|
|
6589
|
+
// generation captured for this turn before disposal erases the frames.
|
|
6590
|
+
retainOrClearDisposedRoot(root);
|
|
4732
6591
|
try {
|
|
4733
6592
|
await runEffect(() => disposeStack('dismiss'));
|
|
4734
6593
|
facts.push(`Dismissed the ${label} engagement.`);
|
|
@@ -4785,6 +6644,163 @@ export function createPlaybookCaptainShell(
|
|
|
4785
6644
|
return { frame, report: outcome.report, failed: false };
|
|
4786
6645
|
};
|
|
4787
6646
|
|
|
6647
|
+
const adoptRetainedGeneration = async (
|
|
6648
|
+
rootPlaybookId: string,
|
|
6649
|
+
offer: RetainedGenerationOffer,
|
|
6650
|
+
): Promise<readonly string[]> => {
|
|
6651
|
+
const generation = offer.generation;
|
|
6652
|
+
const currentLedger = assertPlaybookEffectLedger(currentEffectLedger());
|
|
6653
|
+
const requiresEffectReconciliation =
|
|
6654
|
+
offer.requiresEffectReconciliation ||
|
|
6655
|
+
!retainedEffectLedgerCanRebase(
|
|
6656
|
+
generation.effectLedger as PlaybookEffectLedger,
|
|
6657
|
+
currentLedger,
|
|
6658
|
+
);
|
|
6659
|
+
const sourceSessionIds = new Set(
|
|
6660
|
+
generation.frames.map((frame) => frame.sessionId),
|
|
6661
|
+
);
|
|
6662
|
+
const targetSessionIds = allocateAdoptionSessionIds(
|
|
6663
|
+
generation.frames.length,
|
|
6664
|
+
sourceSessionIds,
|
|
6665
|
+
);
|
|
6666
|
+
const targetRootSessionId = targetSessionIds[0]!;
|
|
6667
|
+
const adoptedFrames: EngagementFrame[] = [];
|
|
6668
|
+
for (const [index, sourceFrame] of generation.frames.entries()) {
|
|
6669
|
+
const enablement = enablementById.get(sourceFrame.playbookId)!;
|
|
6670
|
+
const parent = adoptedFrames.at(-1);
|
|
6671
|
+
adoptedFrames.push({
|
|
6672
|
+
entry: enablement.entry,
|
|
6673
|
+
enablement,
|
|
6674
|
+
runtime: offer.runtimes[index]!,
|
|
6675
|
+
sessionId: targetSessionIds[index]!,
|
|
6676
|
+
rootSessionId: targetRootSessionId,
|
|
6677
|
+
depth: index,
|
|
6678
|
+
playerBindings: makePlayerBindings(enablement),
|
|
6679
|
+
...(parent
|
|
6680
|
+
? { parent: { frame: parent, callId: 'playbook-1' } }
|
|
6681
|
+
: {}),
|
|
6682
|
+
state: sourceFrame.runtime.state,
|
|
6683
|
+
inFlightHostCalls: new Set(),
|
|
6684
|
+
});
|
|
6685
|
+
}
|
|
6686
|
+
|
|
6687
|
+
retainedGenerationOffers.delete(rootPlaybookId);
|
|
6688
|
+
let installed = false;
|
|
6689
|
+
try {
|
|
6690
|
+
for (const [index, frame] of adoptedFrames.entries()) {
|
|
6691
|
+
const sourceFrame = generation.frames[index]!;
|
|
6692
|
+
const targetChild = adoptedFrames[index + 1];
|
|
6693
|
+
await runEffect(() =>
|
|
6694
|
+
frame.runtime.adopt!(
|
|
6695
|
+
frameSession(frame),
|
|
6696
|
+
sourceFrame.runtime,
|
|
6697
|
+
{
|
|
6698
|
+
sourceSessionId: sourceFrame.sessionId,
|
|
6699
|
+
sourceGenerationId: generation.frames[0]!.rootSessionId,
|
|
6700
|
+
...(targetChild === undefined
|
|
6701
|
+
? {}
|
|
6702
|
+
: { targetChildSessionId: targetChild.sessionId }),
|
|
6703
|
+
},
|
|
6704
|
+
),
|
|
6705
|
+
);
|
|
6706
|
+
}
|
|
6707
|
+
|
|
6708
|
+
frames.push(...adoptedFrames);
|
|
6709
|
+
installed = true;
|
|
6710
|
+
retainedEffectReconciliation = requiresEffectReconciliation
|
|
6711
|
+
? {
|
|
6712
|
+
sourceGenerationId:
|
|
6713
|
+
generation.retainedEffectReconciliation?.sourceGenerationId ??
|
|
6714
|
+
generation.frames[0]!.runtime.retainedEffectSourceSessionId ??
|
|
6715
|
+
generation.frames[0]!.rootSessionId,
|
|
6716
|
+
checkpoint: generation.effectLedger,
|
|
6717
|
+
}
|
|
6718
|
+
: undefined;
|
|
6719
|
+
for (const parent of adoptedFrames.slice(0, -1)) {
|
|
6720
|
+
pendingChildParents.add(parent);
|
|
6721
|
+
}
|
|
6722
|
+
const retainedQuestions = generation.frames.at(-1)!.runtime
|
|
6723
|
+
.pendingBossQuestions;
|
|
6724
|
+
pendingBossQuestions =
|
|
6725
|
+
requiresEffectReconciliation || retainedQuestions.length === 0
|
|
6726
|
+
? undefined
|
|
6727
|
+
: mirroredBossQuestions(retainedQuestions);
|
|
6728
|
+
lastError = undefined;
|
|
6729
|
+
retainedGenerations.delete(rootPlaybookId);
|
|
6730
|
+
ineligibleRetainedGenerations.delete(rootPlaybookId);
|
|
6731
|
+
await setMode(
|
|
6732
|
+
'engaged.parked',
|
|
6733
|
+
'resume',
|
|
6734
|
+
rootPlaybookId,
|
|
6735
|
+
targetRootSessionId,
|
|
6736
|
+
);
|
|
6737
|
+
if (requiresEffectReconciliation) {
|
|
6738
|
+
await requireSession().setVisiblePlayers([]);
|
|
6739
|
+
} else {
|
|
6740
|
+
await requestVisibility(adoptedFrames.at(-1)!);
|
|
6741
|
+
}
|
|
6742
|
+
await requireSession().emitStatus(
|
|
6743
|
+
`◇ /${enablementById.get(rootPlaybookId)!.command} resumed`,
|
|
6744
|
+
);
|
|
6745
|
+
if (activeTurn) {
|
|
6746
|
+
appendMandatoryPresentationSuffix(
|
|
6747
|
+
activeTurn,
|
|
6748
|
+
requiresEffectReconciliation
|
|
6749
|
+
? 'The retained work remains parked until its repository-effect evidence is reconciled.'
|
|
6750
|
+
: RESUMPTION_DUPLICATE_EFFECT_WARNING,
|
|
6751
|
+
);
|
|
6752
|
+
}
|
|
6753
|
+
return [
|
|
6754
|
+
generation.rootStateDescription === undefined
|
|
6755
|
+
? `Resumed /${enablementById.get(rootPlaybookId)!.command} from its retained state; no published root-state description was retained.`
|
|
6756
|
+
: `Resumed /${enablementById.get(rootPlaybookId)!.command} from the retained state described as ${quoteEvidence(compactEvidence(generation.rootStateDescription))}.`,
|
|
6757
|
+
requiresEffectReconciliation
|
|
6758
|
+
? 'The retained work remains parked until its repository-effect evidence is reconciled; no ordinary action was resumed.'
|
|
6759
|
+
: RESUMPTION_DUPLICATE_EFFECT_WARNING,
|
|
6760
|
+
];
|
|
6761
|
+
} catch (error) {
|
|
6762
|
+
if (installed) {
|
|
6763
|
+
frames.splice(0);
|
|
6764
|
+
pendingChildParents.clear();
|
|
6765
|
+
retainedEffectReconciliation = undefined;
|
|
6766
|
+
clearLeafLedger();
|
|
6767
|
+
}
|
|
6768
|
+
const cleanupFailures: unknown[] = [];
|
|
6769
|
+
const failedCleanupRuntimes: PlaybookRuntime[] = [];
|
|
6770
|
+
for (const frame of [...adoptedFrames].reverse()) {
|
|
6771
|
+
try {
|
|
6772
|
+
await disposeFrame(frame);
|
|
6773
|
+
} catch (cleanupError) {
|
|
6774
|
+
cleanupFailures.push(cleanupError);
|
|
6775
|
+
failedCleanupRuntimes.push(frame.runtime);
|
|
6776
|
+
}
|
|
6777
|
+
}
|
|
6778
|
+
const rollbackFailures: unknown[] = [];
|
|
6779
|
+
if (installed) {
|
|
6780
|
+
try {
|
|
6781
|
+
await setMode('chat', 'resume.failed');
|
|
6782
|
+
} catch (rollbackError) {
|
|
6783
|
+
rollbackFailures.push(rollbackError);
|
|
6784
|
+
}
|
|
6785
|
+
} else {
|
|
6786
|
+
mode = 'chat';
|
|
6787
|
+
}
|
|
6788
|
+
if (cleanupFailures.length > 0 || rollbackFailures.length > 0) {
|
|
6789
|
+
retiredRetainedRuntimes.push(...failedCleanupRuntimes);
|
|
6790
|
+
ineligibleRetainedGenerations.add(rootPlaybookId);
|
|
6791
|
+
terminallyDisposed = true;
|
|
6792
|
+
lifecycle = 'closed';
|
|
6793
|
+
throw new AggregateError(
|
|
6794
|
+
[error, ...cleanupFailures, ...rollbackFailures],
|
|
6795
|
+
'retained-generation adoption and rollback failed',
|
|
6796
|
+
);
|
|
6797
|
+
}
|
|
6798
|
+
retainedGenerations.set(rootPlaybookId, generation);
|
|
6799
|
+
ineligibleRetainedGenerations.delete(rootPlaybookId);
|
|
6800
|
+
throw error;
|
|
6801
|
+
}
|
|
6802
|
+
};
|
|
6803
|
+
|
|
4788
6804
|
const driveAndProcess = async (
|
|
4789
6805
|
frame: EngagementFrame,
|
|
4790
6806
|
text: string,
|
|
@@ -4815,14 +6831,46 @@ export function createPlaybookCaptainShell(
|
|
|
4815
6831
|
}
|
|
4816
6832
|
};
|
|
4817
6833
|
|
|
6834
|
+
const containsEffectThrow = (turn: ActiveTurn, error: unknown): boolean => {
|
|
6835
|
+
if (turn.effectThrows.has(error)) return true;
|
|
6836
|
+
return (
|
|
6837
|
+
error instanceof AggregateError &&
|
|
6838
|
+
error.errors.some((nested) => containsEffectThrow(turn, nested))
|
|
6839
|
+
);
|
|
6840
|
+
};
|
|
6841
|
+
|
|
4818
6842
|
const settleSelection = async (
|
|
4819
6843
|
selection: CaptainControllerSelection,
|
|
4820
6844
|
signal: AbortSignal,
|
|
4821
6845
|
): Promise<SettlementEvidence> => {
|
|
4822
6846
|
const turn = activeTurn;
|
|
4823
6847
|
runFailureFacts = [];
|
|
6848
|
+
let frozenUnresolvedEffects:
|
|
6849
|
+
| readonly PlaybookCaptainUnresolvedEffect[]
|
|
6850
|
+
| undefined;
|
|
6851
|
+
const freezeControllerEvidence =
|
|
6852
|
+
(): readonly PlaybookCaptainUnresolvedEffect[] => {
|
|
6853
|
+
frozenUnresolvedEffects ??= freezeTurnUnresolvedEffects();
|
|
6854
|
+
const report = unresolvedEffectBossReport(frozenUnresolvedEffects);
|
|
6855
|
+
if (turn !== undefined && report !== undefined) {
|
|
6856
|
+
appendMandatoryPresentationSuffix(turn, report);
|
|
6857
|
+
}
|
|
6858
|
+
return frozenUnresolvedEffects;
|
|
6859
|
+
};
|
|
6860
|
+
const finalizeSettlement = (
|
|
6861
|
+
settlement: ControllerSettlementDraft,
|
|
6862
|
+
): SettlementEvidence =>
|
|
6863
|
+
Object.freeze({
|
|
6864
|
+
...settlement,
|
|
6865
|
+
unresolvedEffects: freezeControllerEvidence(),
|
|
6866
|
+
});
|
|
4824
6867
|
try {
|
|
4825
|
-
|
|
6868
|
+
// `respond` has no result phase: freeze its no-effect projection before
|
|
6869
|
+
// its decision-call prose crosses the presentation boundary. Acting
|
|
6870
|
+
// selections freeze after their work and before reporting begins.
|
|
6871
|
+
if (selection.action === 'respond') freezeControllerEvidence();
|
|
6872
|
+
const settlement = await executeSelection(selection, signal);
|
|
6873
|
+
return finalizeSettlement(settlement);
|
|
4826
6874
|
} catch (error) {
|
|
4827
6875
|
if (turn?.presentationError === error) throw error;
|
|
4828
6876
|
const aborted = signal.aborted || activeContext?.signal.aborted === true;
|
|
@@ -4853,7 +6901,7 @@ export function createPlaybookCaptainShell(
|
|
|
4853
6901
|
}
|
|
4854
6902
|
const mayHaveApplied =
|
|
4855
6903
|
selection.action !== 'respond' &&
|
|
4856
|
-
turn
|
|
6904
|
+
containsEffectThrow(turn, error);
|
|
4857
6905
|
turn.settlementFacts.push(
|
|
4858
6906
|
mayHaveApplied
|
|
4859
6907
|
? `The ${selection.action} action failed before its complete outcome could be confirmed and may have changed the session: ${normalized.name}: ${compactEvidence(normalized.message)}. It was not repeated automatically.`
|
|
@@ -4896,7 +6944,7 @@ export function createPlaybookCaptainShell(
|
|
|
4896
6944
|
};
|
|
4897
6945
|
turn.settled = true;
|
|
4898
6946
|
lastSettlementStatus = 'failed';
|
|
4899
|
-
|
|
6947
|
+
const settlement: ControllerSettlementDraft = {
|
|
4900
6948
|
status: 'failed',
|
|
4901
6949
|
facts: [...turn.settlementFacts],
|
|
4902
6950
|
...(turn.report.receipt === undefined
|
|
@@ -4904,6 +6952,7 @@ export function createPlaybookCaptainShell(
|
|
|
4904
6952
|
: { receipt: turn.report.receipt }),
|
|
4905
6953
|
...(summary === undefined ? {} : { leafStateSummary: summary }),
|
|
4906
6954
|
};
|
|
6955
|
+
return finalizeSettlement(settlement);
|
|
4907
6956
|
} finally {
|
|
4908
6957
|
runFailureFacts = undefined;
|
|
4909
6958
|
}
|
|
@@ -4920,7 +6969,7 @@ export function createPlaybookCaptainShell(
|
|
|
4920
6969
|
const executeSelection = async (
|
|
4921
6970
|
selection: CaptainControllerSelection,
|
|
4922
6971
|
signal: AbortSignal,
|
|
4923
|
-
): Promise<
|
|
6972
|
+
): Promise<ControllerSettlementDraft> => {
|
|
4924
6973
|
const context = activeContext;
|
|
4925
6974
|
const turn = activeTurn;
|
|
4926
6975
|
if (!context || !turn) {
|
|
@@ -4973,6 +7022,63 @@ export function createPlaybookCaptainShell(
|
|
|
4973
7022
|
};
|
|
4974
7023
|
}
|
|
4975
7024
|
|
|
7025
|
+
refreshRetainedEffectFence();
|
|
7026
|
+
const fencedLeaf = leafFrame();
|
|
7027
|
+
const routesRetainedReconciliation =
|
|
7028
|
+
selection.action === 'runtime' &&
|
|
7029
|
+
(selection.actionId === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
|
|
7030
|
+
selection.actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID) &&
|
|
7031
|
+
fencedLeaf !== undefined;
|
|
7032
|
+
if (
|
|
7033
|
+
retainedEffectReconciliation !== undefined &&
|
|
7034
|
+
!routesRetainedReconciliation
|
|
7035
|
+
) {
|
|
7036
|
+
return rejectSelection(
|
|
7037
|
+
selection,
|
|
7038
|
+
'retained work must reconcile its repository-effect evidence before an ordinary action can run',
|
|
7039
|
+
);
|
|
7040
|
+
}
|
|
7041
|
+
|
|
7042
|
+
if (selection.action === 'resume') {
|
|
7043
|
+
const entry = byId.get(selection.playbookId);
|
|
7044
|
+
if (entry === undefined) {
|
|
7045
|
+
return rejectSelection(
|
|
7046
|
+
selection,
|
|
7047
|
+
`"${selection.playbookId}" is not an enabled playbook`,
|
|
7048
|
+
);
|
|
7049
|
+
}
|
|
7050
|
+
if (rootFrame() !== undefined) {
|
|
7051
|
+
return rejectSelection(
|
|
7052
|
+
selection,
|
|
7053
|
+
'a playbook is already engaged; its live actions take precedence',
|
|
7054
|
+
);
|
|
7055
|
+
}
|
|
7056
|
+
const offer = retainedGenerationOffers.get(entry.id);
|
|
7057
|
+
if (offer === undefined) {
|
|
7058
|
+
return rejectSelection(
|
|
7059
|
+
selection,
|
|
7060
|
+
`/${enablementById.get(entry.id)!.command} has no resumable retained generation`,
|
|
7061
|
+
);
|
|
7062
|
+
}
|
|
7063
|
+
turn.settled = true;
|
|
7064
|
+
journalAction({ action: 'resume', playbookId: entry.id });
|
|
7065
|
+
facts.push(...(await adoptRetainedGeneration(entry.id, offer)));
|
|
7066
|
+
const summary = leafStateSummary();
|
|
7067
|
+
turn.report = {
|
|
7068
|
+
...emptyReport(),
|
|
7069
|
+
facts,
|
|
7070
|
+
status: 'ok',
|
|
7071
|
+
...(summary === undefined ? {} : { leafStateSummary: summary }),
|
|
7072
|
+
};
|
|
7073
|
+
journalOutcome([...facts]);
|
|
7074
|
+
lastSettlementStatus = 'ok';
|
|
7075
|
+
return {
|
|
7076
|
+
status: 'ok',
|
|
7077
|
+
facts: [...facts],
|
|
7078
|
+
...(summary === undefined ? {} : { leafStateSummary: summary }),
|
|
7079
|
+
};
|
|
7080
|
+
}
|
|
7081
|
+
|
|
4976
7082
|
if (selection.action === 'start' || selection.action === 'switch') {
|
|
4977
7083
|
const entry = byId.get(selection.playbookId);
|
|
4978
7084
|
if (!entry) {
|
|
@@ -5239,6 +7345,7 @@ export function createPlaybookCaptainShell(
|
|
|
5239
7345
|
);
|
|
5240
7346
|
if (outcome.error !== undefined) throw outcome.error;
|
|
5241
7347
|
const receipt = outcome.result!;
|
|
7348
|
+
refreshRetainedEffectFence();
|
|
5242
7349
|
let status: SettlementEvidence['status'] =
|
|
5243
7350
|
receipt.disposition === 'executed'
|
|
5244
7351
|
? 'ok'
|
|
@@ -5259,9 +7366,53 @@ export function createPlaybookCaptainShell(
|
|
|
5259
7366
|
}
|
|
5260
7367
|
: {}),
|
|
5261
7368
|
};
|
|
7369
|
+
const unresolvedAbandonment =
|
|
7370
|
+
receipt.disposition === 'executed' &&
|
|
7371
|
+
actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID;
|
|
5262
7372
|
if (receipt.disposition === 'executed') {
|
|
5263
|
-
|
|
7373
|
+
if (
|
|
7374
|
+
unresolvedAbandonment &&
|
|
7375
|
+
receipt.run?.outcome !== 'unresolved-effect'
|
|
7376
|
+
) {
|
|
7377
|
+
throw new Error(
|
|
7378
|
+
'unresolved-effect abandonment returned no unresolved-effect result',
|
|
7379
|
+
);
|
|
7380
|
+
}
|
|
5264
7381
|
const establishedSummary = leafStateSummary();
|
|
7382
|
+
if (unresolvedAbandonment) {
|
|
7383
|
+
try {
|
|
7384
|
+
await settleUnresolvedEffectAbandonment(leaf);
|
|
7385
|
+
} catch (error) {
|
|
7386
|
+
abandonmentSettlementUnsafe = true;
|
|
7387
|
+
const normalized = normalizeErrorCompact(error) ?? {
|
|
7388
|
+
name: 'Error',
|
|
7389
|
+
message: String(error),
|
|
7390
|
+
};
|
|
7391
|
+
// The runtime action was accepted, but its distinct host-level
|
|
7392
|
+
// settlement did not complete. Do not expose an `executed` control
|
|
7393
|
+
// receipt until both disposal and durable publication have
|
|
7394
|
+
// succeeded.
|
|
7395
|
+
turn.report = {
|
|
7396
|
+
...outcome.report,
|
|
7397
|
+
facts: [...facts],
|
|
7398
|
+
bossFacts: facts.map((fact) =>
|
|
7399
|
+
fact
|
|
7400
|
+
.split(`"${actionId}"`)
|
|
7401
|
+
.join(`"${compactEvidence(actionLabel)}"`),
|
|
7402
|
+
),
|
|
7403
|
+
status: 'failed',
|
|
7404
|
+
receipt: {
|
|
7405
|
+
disposition: 'failed',
|
|
7406
|
+
error: normalized,
|
|
7407
|
+
},
|
|
7408
|
+
...(establishedSummary === undefined
|
|
7409
|
+
? {}
|
|
7410
|
+
: { leafStateSummary: establishedSummary }),
|
|
7411
|
+
};
|
|
7412
|
+
throw error;
|
|
7413
|
+
}
|
|
7414
|
+
}
|
|
7415
|
+
facts.push(`Applied "${actionId}" on ${frameLabel(leaf)}.`);
|
|
5265
7416
|
// Execution is now proven. Preserve that receipt and the counts already
|
|
5266
7417
|
// collected before processing the returned run, because disposal,
|
|
5267
7418
|
// telemetry, or parent resumption can still fail afterward.
|
|
@@ -5279,7 +7430,11 @@ export function createPlaybookCaptainShell(
|
|
|
5279
7430
|
? {}
|
|
5280
7431
|
: { leafStateSummary: establishedSummary }),
|
|
5281
7432
|
};
|
|
5282
|
-
if (
|
|
7433
|
+
if (
|
|
7434
|
+
!unresolvedAbandonment &&
|
|
7435
|
+
receipt.run !== undefined &&
|
|
7436
|
+
retainedEffectReconciliation === undefined
|
|
7437
|
+
) {
|
|
5283
7438
|
// The same rule as the drive path: processing the run the receipt
|
|
5284
7439
|
// carried is not itself an effect, and the resume or disposal it may
|
|
5285
7440
|
// perform is marked where it happens (CAPTAIN-35).
|
|
@@ -5520,6 +7675,42 @@ export function createPlaybookCaptainShell(
|
|
|
5520
7675
|
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
|
|
5521
7676
|
);
|
|
5522
7677
|
}
|
|
7678
|
+
if (
|
|
7679
|
+
!isDeepStrictEqual(frame.runtime.effectLedger, snapshot.effectLedger)
|
|
7680
|
+
) {
|
|
7681
|
+
throw new TypeError(
|
|
7682
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger does not match its artifact schema`,
|
|
7683
|
+
);
|
|
7684
|
+
}
|
|
7685
|
+
const runtimeReconciliation =
|
|
7686
|
+
frame.runtime.retainedEffectReconciliation;
|
|
7687
|
+
if (snapshot.retainedEffectReconciliation === undefined) {
|
|
7688
|
+
if (runtimeReconciliation !== undefined) {
|
|
7689
|
+
throw new TypeError(
|
|
7690
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} carries an unmirrored retained-effect fence`,
|
|
7691
|
+
);
|
|
7692
|
+
}
|
|
7693
|
+
} else if (
|
|
7694
|
+
runtimeReconciliation === undefined ||
|
|
7695
|
+
!isDeepStrictEqual(
|
|
7696
|
+
runtimeReconciliation.checkpoint,
|
|
7697
|
+
snapshot.retainedEffectReconciliation.checkpoint,
|
|
7698
|
+
)
|
|
7699
|
+
) {
|
|
7700
|
+
throw new TypeError(
|
|
7701
|
+
`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} does not mirror the root retained-effect fence`,
|
|
7702
|
+
);
|
|
7703
|
+
}
|
|
7704
|
+
if (
|
|
7705
|
+
frame.depth === 0 &&
|
|
7706
|
+
snapshot.retainedEffectReconciliation !== undefined &&
|
|
7707
|
+
runtimeReconciliation?.sourceSessionId !==
|
|
7708
|
+
snapshot.retainedEffectReconciliation.sourceGenerationId
|
|
7709
|
+
) {
|
|
7710
|
+
throw new TypeError(
|
|
7711
|
+
'Captain shell snapshot retained-effect root source identity differs from its generation',
|
|
7712
|
+
);
|
|
7713
|
+
}
|
|
5523
7714
|
}
|
|
5524
7715
|
};
|
|
5525
7716
|
|
|
@@ -5585,40 +7776,44 @@ export function createPlaybookCaptainShell(
|
|
|
5585
7776
|
});
|
|
5586
7777
|
};
|
|
5587
7778
|
|
|
5588
|
-
const
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
!captainSessionId ||
|
|
5593
|
-
!captainAgent
|
|
5594
|
-
) {
|
|
5595
|
-
return undefined;
|
|
5596
|
-
}
|
|
7779
|
+
const captureFrameSnapshots = (
|
|
7780
|
+
requireRecordedState: boolean,
|
|
7781
|
+
): readonly PlaybookCaptainFrameSnapshot[] | undefined => {
|
|
7782
|
+
const captured: PlaybookCaptainFrameSnapshot[] = [];
|
|
5597
7783
|
try {
|
|
5598
|
-
|
|
5599
|
-
typeof captainRuntime.exportSnapshot !== 'function' ||
|
|
5600
|
-
typeof captainRuntime.restore !== 'function'
|
|
5601
|
-
) {
|
|
5602
|
-
return undefined;
|
|
5603
|
-
}
|
|
5604
|
-
const captainSnapshot = captainRuntime.exportSnapshot();
|
|
5605
|
-
if (captainSnapshot === undefined) return undefined;
|
|
5606
|
-
const frameSnapshots: PlaybookCaptainFrameSnapshot[] = [];
|
|
5607
|
-
for (const frame of frames) {
|
|
7784
|
+
for (const [index, frame] of frames.entries()) {
|
|
5608
7785
|
if (
|
|
5609
7786
|
typeof frame.runtime.exportSnapshot !== 'function' ||
|
|
5610
7787
|
typeof frame.runtime.restore !== 'function'
|
|
5611
7788
|
) {
|
|
5612
7789
|
return undefined;
|
|
5613
7790
|
}
|
|
5614
|
-
const
|
|
7791
|
+
const exported = frame.runtime.exportSnapshot();
|
|
7792
|
+
if (exported === undefined) return undefined;
|
|
7793
|
+
const runtime = assertPlaybookRuntimeSnapshot(
|
|
7794
|
+
exported,
|
|
7795
|
+
frame.entry.id,
|
|
7796
|
+
{ allowSuspendedCall: true },
|
|
7797
|
+
);
|
|
7798
|
+
if (
|
|
7799
|
+
runtime.state.status !== 'active' ||
|
|
7800
|
+
!runtime.state.quiescent ||
|
|
7801
|
+
(requireRecordedState && frame.state === undefined) ||
|
|
7802
|
+
(frame.state !== undefined &&
|
|
7803
|
+
!isDeepStrictEqual(frame.state, runtime.state))
|
|
7804
|
+
) {
|
|
7805
|
+
return undefined;
|
|
7806
|
+
}
|
|
7807
|
+
const isLeaf = index === frames.length - 1;
|
|
5615
7808
|
if (
|
|
5616
|
-
|
|
5617
|
-
|
|
7809
|
+
(isLeaf &&
|
|
7810
|
+
(runtime.suspendedCall !== undefined ||
|
|
7811
|
+
!runtime.state.tags.includes('playbook.parked'))) ||
|
|
7812
|
+
(!isLeaf && runtime.suspendedCall === undefined)
|
|
5618
7813
|
) {
|
|
5619
7814
|
return undefined;
|
|
5620
7815
|
}
|
|
5621
|
-
|
|
7816
|
+
captured.push({
|
|
5622
7817
|
playbookId: frame.entry.id,
|
|
5623
7818
|
sessionId: frame.sessionId,
|
|
5624
7819
|
rootSessionId: frame.rootSessionId,
|
|
@@ -5639,8 +7834,253 @@ export function createPlaybookCaptainShell(
|
|
|
5639
7834
|
runtime,
|
|
5640
7835
|
});
|
|
5641
7836
|
}
|
|
7837
|
+
for (let index = 1; index < captured.length; index += 1) {
|
|
7838
|
+
const parent = captured[index - 1]!;
|
|
7839
|
+
const child = captured[index]!;
|
|
7840
|
+
if (
|
|
7841
|
+
child.parentSessionId !== parent.sessionId ||
|
|
7842
|
+
child.parentCallId === undefined ||
|
|
7843
|
+
parent.runtime.suspendedCall?.callId !== child.parentCallId ||
|
|
7844
|
+
parent.runtime.suspendedCall.playbookId !== child.playbookId ||
|
|
7845
|
+
parent.runtime.suspendedCall.childSessionId !== child.sessionId
|
|
7846
|
+
) {
|
|
7847
|
+
return undefined;
|
|
7848
|
+
}
|
|
7849
|
+
}
|
|
7850
|
+
return captured;
|
|
7851
|
+
} catch {
|
|
7852
|
+
return undefined;
|
|
7853
|
+
}
|
|
7854
|
+
};
|
|
7855
|
+
|
|
7856
|
+
const refreshRetainedEffectFence = (
|
|
7857
|
+
capturedFrames?: readonly PlaybookCaptainFrameSnapshot[],
|
|
7858
|
+
capturedLedger?: PlaybookEffectLedger,
|
|
7859
|
+
): void => {
|
|
7860
|
+
const fence = retainedEffectReconciliation;
|
|
7861
|
+
if (fence === undefined) return;
|
|
7862
|
+
try {
|
|
7863
|
+
const ledger =
|
|
7864
|
+
capturedLedger ?? assertPlaybookEffectLedger(currentEffectLedger());
|
|
7865
|
+
if (
|
|
7866
|
+
!retainedEffectLedgerCanRebase(
|
|
7867
|
+
fence.checkpoint as PlaybookEffectLedger,
|
|
7868
|
+
ledger,
|
|
7869
|
+
)
|
|
7870
|
+
) {
|
|
7871
|
+
return;
|
|
7872
|
+
}
|
|
7873
|
+
const snapshots = capturedFrames ?? captureFrameSnapshots(true);
|
|
7874
|
+
if (snapshots === undefined || snapshots.length !== frames.length) return;
|
|
7875
|
+
for (const frameSnapshot of snapshots) {
|
|
7876
|
+
const runtime = frameSnapshot.runtime;
|
|
7877
|
+
if (
|
|
7878
|
+
!isDeepStrictEqual(runtime.effectLedger, ledger) ||
|
|
7879
|
+
runtime.retainedEffectSourceSessionId === undefined ||
|
|
7880
|
+
runtime.retainedEffectReconciliation !== undefined
|
|
7881
|
+
) {
|
|
7882
|
+
return;
|
|
7883
|
+
}
|
|
7884
|
+
}
|
|
7885
|
+
const retainedQuestions = snapshots.at(-1)!.runtime.pendingBossQuestions;
|
|
7886
|
+
const restoredQuestions =
|
|
7887
|
+
retainedQuestions.length === 0
|
|
7888
|
+
? undefined
|
|
7889
|
+
: mirroredBossQuestions(retainedQuestions);
|
|
7890
|
+
pendingBossQuestions = restoredQuestions;
|
|
7891
|
+
retainedEffectReconciliation = undefined;
|
|
7892
|
+
} catch {
|
|
7893
|
+
// Any host-ledger or runtime-snapshot defect leaves the root fence shut.
|
|
7894
|
+
}
|
|
7895
|
+
};
|
|
7896
|
+
|
|
7897
|
+
const rootStateDescriptionForRetention = (
|
|
7898
|
+
root: EngagementFrame,
|
|
7899
|
+
retainedState: PlaybookState,
|
|
7900
|
+
): string | undefined => {
|
|
7901
|
+
if (typeof root.runtime.describe !== 'function') return undefined;
|
|
7902
|
+
try {
|
|
7903
|
+
const view = root.runtime.describe();
|
|
7904
|
+
if (!isDeepStrictEqual(view.state, retainedState)) return undefined;
|
|
7905
|
+
return typeof view.stateDescription === 'string' &&
|
|
7906
|
+
view.stateDescription.trim().length > 0
|
|
7907
|
+
? view.stateDescription
|
|
7908
|
+
: undefined;
|
|
7909
|
+
} catch {
|
|
7910
|
+
return undefined;
|
|
7911
|
+
}
|
|
7912
|
+
};
|
|
7913
|
+
|
|
7914
|
+
const retainedGenerationFromFrames = (
|
|
7915
|
+
root: EngagementFrame,
|
|
7916
|
+
frameSnapshots: readonly PlaybookCaptainFrameSnapshot[],
|
|
7917
|
+
): PlaybookCaptainRetainedGeneration => {
|
|
7918
|
+
const rootStateDescription = rootStateDescriptionForRetention(
|
|
7919
|
+
root,
|
|
7920
|
+
frameSnapshots[0]!.runtime.state,
|
|
7921
|
+
);
|
|
7922
|
+
const checkpoint =
|
|
7923
|
+
retainedEffectReconciliation?.checkpoint ??
|
|
7924
|
+
assertPlaybookEffectLedger(currentEffectLedger());
|
|
7925
|
+
if (
|
|
7926
|
+
checkpoint.boundaries.some(
|
|
7927
|
+
({ physicalReceipt }) => physicalReceipt === undefined,
|
|
7928
|
+
)
|
|
7929
|
+
) {
|
|
7930
|
+
throw new TypeError(
|
|
7931
|
+
'Captain retained-generation checkpoint contains an incomplete physical boundary',
|
|
7932
|
+
);
|
|
7933
|
+
}
|
|
7934
|
+
return snapshotJsonValue(
|
|
7935
|
+
{
|
|
7936
|
+
effectLedger: checkpoint,
|
|
7937
|
+
frames: frameSnapshots,
|
|
7938
|
+
...(retainedEffectReconciliation === undefined
|
|
7939
|
+
? {}
|
|
7940
|
+
: {
|
|
7941
|
+
retainedEffectReconciliation: {
|
|
7942
|
+
sourceGenerationId:
|
|
7943
|
+
retainedEffectReconciliation.sourceGenerationId,
|
|
7944
|
+
},
|
|
7945
|
+
}),
|
|
7946
|
+
...(rootStateDescription === undefined
|
|
7947
|
+
? {}
|
|
7948
|
+
: { rootStateDescription }),
|
|
7949
|
+
},
|
|
7950
|
+
'Captain retained generation',
|
|
7951
|
+
) as unknown as PlaybookCaptainRetainedGeneration;
|
|
7952
|
+
};
|
|
7953
|
+
|
|
7954
|
+
const captureRetainedGeneration =
|
|
7955
|
+
(): PlaybookCaptainRetainedGeneration | undefined => {
|
|
7956
|
+
const root = rootFrame();
|
|
7957
|
+
if (
|
|
7958
|
+
!root ||
|
|
7959
|
+
frames.some((frame) => !runtimeRetainsGenerations(frame.runtime))
|
|
7960
|
+
) {
|
|
7961
|
+
return undefined;
|
|
7962
|
+
}
|
|
7963
|
+
const frameSnapshots = captureFrameSnapshots(true);
|
|
7964
|
+
if (frameSnapshots === undefined || frameSnapshots.length === 0) {
|
|
7965
|
+
return undefined;
|
|
7966
|
+
}
|
|
7967
|
+
return retainedGenerationFromFrames(root, frameSnapshots);
|
|
7968
|
+
};
|
|
7969
|
+
|
|
7970
|
+
const rememberRetainedGeneration = (): void => {
|
|
7971
|
+
const root = rootFrame();
|
|
7972
|
+
if (!root) return;
|
|
7973
|
+
if (frames.some((frame) => !runtimeRetainsGenerations(frame.runtime))) {
|
|
7974
|
+
retainedGenerationCandidates.set(root.entry.id, {
|
|
7975
|
+
status: 'incapable',
|
|
7976
|
+
});
|
|
7977
|
+
return;
|
|
7978
|
+
}
|
|
7979
|
+
const generation = captureRetainedGeneration();
|
|
7980
|
+
retainedGenerationCandidates.set(
|
|
7981
|
+
root.entry.id,
|
|
7982
|
+
generation === undefined
|
|
7983
|
+
? { status: 'unsafe' }
|
|
7984
|
+
: { status: 'captured', generation },
|
|
7985
|
+
);
|
|
7986
|
+
};
|
|
7987
|
+
|
|
7988
|
+
const retentionUpdateForPriorGeneration = (
|
|
7989
|
+
root: EngagementFrame,
|
|
7990
|
+
): PlaybookCaptainRetentionUpdate | undefined => {
|
|
7991
|
+
const rootPlaybookId = root.entry.id;
|
|
7992
|
+
if (!runtimeRetainsGenerations(root.runtime)) {
|
|
7993
|
+
return {
|
|
7994
|
+
kind: 'clear',
|
|
7995
|
+
rootPlaybookId,
|
|
7996
|
+
};
|
|
7997
|
+
}
|
|
7998
|
+
const candidate = retainedGenerationCandidates.get(rootPlaybookId);
|
|
7999
|
+
if (candidate?.status === 'incapable') {
|
|
8000
|
+
return undefined;
|
|
8001
|
+
}
|
|
8002
|
+
if (candidate?.status !== 'captured') {
|
|
8003
|
+
throw new Error(
|
|
8004
|
+
`${frameLabel(root)} could not capture its pre-terminal retained generation`,
|
|
8005
|
+
);
|
|
8006
|
+
}
|
|
8007
|
+
return {
|
|
8008
|
+
kind: 'retain',
|
|
8009
|
+
rootPlaybookId,
|
|
8010
|
+
generation: candidate.generation,
|
|
8011
|
+
};
|
|
8012
|
+
};
|
|
8013
|
+
|
|
8014
|
+
const retainOrClearDisposedRoot = (root: EngagementFrame): void => {
|
|
8015
|
+
const update = retentionUpdateForPriorGeneration(root);
|
|
8016
|
+
if (update !== undefined) {
|
|
8017
|
+
pendingRetentionUpdates.set(update.rootPlaybookId, update);
|
|
8018
|
+
}
|
|
8019
|
+
};
|
|
8020
|
+
|
|
8021
|
+
const recordTerminalRetention = (
|
|
8022
|
+
root: EngagementFrame,
|
|
8023
|
+
result: Extract<PlaybookRunResult, { outcome: 'terminal' }>,
|
|
8024
|
+
): void => {
|
|
8025
|
+
const rootPlaybookId = root.entry.id;
|
|
8026
|
+
if (!runtimeRetainsGenerations(root.runtime)) {
|
|
8027
|
+
pendingRetentionUpdates.set(rootPlaybookId, {
|
|
8028
|
+
kind: 'clear',
|
|
8029
|
+
rootPlaybookId,
|
|
8030
|
+
});
|
|
8031
|
+
return;
|
|
8032
|
+
}
|
|
8033
|
+
const terminalStateId = result.state.stateId;
|
|
8034
|
+
if (
|
|
8035
|
+
typeof terminalStateId !== 'string' ||
|
|
8036
|
+
terminalStateId.trim().length === 0
|
|
8037
|
+
) {
|
|
8038
|
+
throw new Error(
|
|
8039
|
+
`${frameLabel(root)} terminal result has no stable state id for retention`,
|
|
8040
|
+
);
|
|
8041
|
+
}
|
|
8042
|
+
const unfinished =
|
|
8043
|
+
root.runtime.retainedGenerationMetadata!.unfinishedFinalStateIds.includes(
|
|
8044
|
+
terminalStateId,
|
|
8045
|
+
);
|
|
8046
|
+
if (unfinished) {
|
|
8047
|
+
// A root opened and terminated within this turn has no pre-turn,
|
|
8048
|
+
// work-bearing generation. Leave any earlier store entry untouched.
|
|
8049
|
+
if (!retainedGenerationCandidates.has(rootPlaybookId)) return;
|
|
8050
|
+
retainOrClearDisposedRoot(root);
|
|
8051
|
+
} else {
|
|
8052
|
+
pendingRetentionUpdates.set(rootPlaybookId, {
|
|
8053
|
+
kind: 'clear',
|
|
8054
|
+
rootPlaybookId,
|
|
8055
|
+
});
|
|
8056
|
+
}
|
|
8057
|
+
};
|
|
8058
|
+
|
|
8059
|
+
const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
|
|
8060
|
+
if (
|
|
8061
|
+
!safeCapturePoint() ||
|
|
8062
|
+
!captainRuntime ||
|
|
8063
|
+
!captainSessionId ||
|
|
8064
|
+
!captainAgent
|
|
8065
|
+
) {
|
|
8066
|
+
return undefined;
|
|
8067
|
+
}
|
|
8068
|
+
try {
|
|
8069
|
+
if (
|
|
8070
|
+
typeof captainRuntime.exportSnapshot !== 'function' ||
|
|
8071
|
+
typeof captainRuntime.restore !== 'function'
|
|
8072
|
+
) {
|
|
8073
|
+
return undefined;
|
|
8074
|
+
}
|
|
8075
|
+
const captainSnapshot = captainRuntime.exportSnapshot();
|
|
8076
|
+
if (captainSnapshot === undefined) return undefined;
|
|
8077
|
+
const frameSnapshots = captureFrameSnapshots(true);
|
|
8078
|
+
if (frameSnapshots === undefined) return undefined;
|
|
8079
|
+
const effectLedger = assertPlaybookEffectLedger(currentEffectLedger());
|
|
8080
|
+
refreshRetainedEffectFence(frameSnapshots, effectLedger);
|
|
5642
8081
|
const common = {
|
|
5643
|
-
schemaVersion:
|
|
8082
|
+
schemaVersion: 4 as const,
|
|
8083
|
+
effectLedger,
|
|
5644
8084
|
captain: {
|
|
5645
8085
|
sessionId: captainSessionId,
|
|
5646
8086
|
runtime: captainSnapshot,
|
|
@@ -5663,6 +8103,9 @@ export function createPlaybookCaptainShell(
|
|
|
5663
8103
|
...common,
|
|
5664
8104
|
mode: 'engaged.parked',
|
|
5665
8105
|
frames: frameSnapshots,
|
|
8106
|
+
...(retainedEffectReconciliation === undefined
|
|
8107
|
+
? {}
|
|
8108
|
+
: { retainedEffectReconciliation }),
|
|
5666
8109
|
...(pendingBossQuestions === undefined
|
|
5667
8110
|
? {}
|
|
5668
8111
|
: { pendingBossQuestions: pendingBossQuestions as JsonValue }),
|
|
@@ -5676,6 +8119,66 @@ export function createPlaybookCaptainShell(
|
|
|
5676
8119
|
}
|
|
5677
8120
|
};
|
|
5678
8121
|
|
|
8122
|
+
const exportSettlement = (): PlaybookCaptainSettlement | undefined => {
|
|
8123
|
+
if (!retentionSettlementReady || abandonmentSettlementUnsafe) {
|
|
8124
|
+
return undefined;
|
|
8125
|
+
}
|
|
8126
|
+
const snapshot = exportShellSnapshot();
|
|
8127
|
+
if (snapshot === undefined) return undefined;
|
|
8128
|
+
let unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
|
|
8129
|
+
try {
|
|
8130
|
+
unresolvedEffects =
|
|
8131
|
+
settledTurnUnresolvedEffects ?? currentUnresolvedEffects();
|
|
8132
|
+
} catch {
|
|
8133
|
+
return undefined;
|
|
8134
|
+
}
|
|
8135
|
+
const updates = new Map(pendingRetentionUpdates);
|
|
8136
|
+
for (const rootPlaybookId of retainedGenerationRootClears) {
|
|
8137
|
+
updates.set(rootPlaybookId, { kind: 'clear', rootPlaybookId });
|
|
8138
|
+
}
|
|
8139
|
+
const root = rootFrame();
|
|
8140
|
+
if (root !== undefined) {
|
|
8141
|
+
const rootPlaybookId = root.entry.id;
|
|
8142
|
+
if (frames.every((frame) => runtimeRetainsGenerations(frame.runtime))) {
|
|
8143
|
+
const generation =
|
|
8144
|
+
snapshot.mode === 'engaged.parked'
|
|
8145
|
+
? retainedGenerationFromFrames(root, snapshot.frames)
|
|
8146
|
+
: undefined;
|
|
8147
|
+
if (generation !== undefined) {
|
|
8148
|
+
updates.set(rootPlaybookId, {
|
|
8149
|
+
kind: 'retain',
|
|
8150
|
+
rootPlaybookId,
|
|
8151
|
+
generation,
|
|
8152
|
+
});
|
|
8153
|
+
}
|
|
8154
|
+
} else if (!runtimeRetainsGenerations(root.runtime)) {
|
|
8155
|
+
updates.set(rootPlaybookId, { kind: 'clear', rootPlaybookId });
|
|
8156
|
+
} else if (retainedGenerationCandidates.has(rootPlaybookId)) {
|
|
8157
|
+
try {
|
|
8158
|
+
const update = retentionUpdateForPriorGeneration(root);
|
|
8159
|
+
if (update !== undefined) {
|
|
8160
|
+
updates.set(rootPlaybookId, update);
|
|
8161
|
+
}
|
|
8162
|
+
} catch {
|
|
8163
|
+
return undefined;
|
|
8164
|
+
}
|
|
8165
|
+
}
|
|
8166
|
+
}
|
|
8167
|
+
for (const update of updates.values()) {
|
|
8168
|
+
applyRetentionUpdateToCatalog(update);
|
|
8169
|
+
}
|
|
8170
|
+
return snapshotJsonValue(
|
|
8171
|
+
{
|
|
8172
|
+
snapshot,
|
|
8173
|
+
retentionUpdates: [...updates.values()].sort((left, right) =>
|
|
8174
|
+
left.rootPlaybookId.localeCompare(right.rootPlaybookId),
|
|
8175
|
+
),
|
|
8176
|
+
unresolvedEffects,
|
|
8177
|
+
},
|
|
8178
|
+
'Captain settlement',
|
|
8179
|
+
) as unknown as PlaybookCaptainSettlement;
|
|
8180
|
+
};
|
|
8181
|
+
|
|
5679
8182
|
const verifyRestoredRuntime = (
|
|
5680
8183
|
runtime: PlaybookRuntime,
|
|
5681
8184
|
expected: PlaybookRuntimeSnapshot,
|
|
@@ -5699,6 +8202,9 @@ export function createPlaybookCaptainShell(
|
|
|
5699
8202
|
'sequences',
|
|
5700
8203
|
'pendingBossQuestions',
|
|
5701
8204
|
'suspendedCall',
|
|
8205
|
+
'effectLedger',
|
|
8206
|
+
'retainedEffectSourceSessionId',
|
|
8207
|
+
'retainedEffectReconciliation',
|
|
5702
8208
|
] as const) {
|
|
5703
8209
|
if (!isDeepStrictEqual(normalized[key], expected[key])) {
|
|
5704
8210
|
throw new Error(
|
|
@@ -5718,6 +8224,17 @@ export function createPlaybookCaptainShell(
|
|
|
5718
8224
|
cleanupFailures.push(error);
|
|
5719
8225
|
}
|
|
5720
8226
|
}
|
|
8227
|
+
const retainedRuntimes = takeRetainedOfferRuntimes();
|
|
8228
|
+
if (retainedRuntimes.length > 0) {
|
|
8229
|
+
try {
|
|
8230
|
+
await disposeRetainedRuntimeSet(
|
|
8231
|
+
retainedRuntimes,
|
|
8232
|
+
'retained-generation restore cleanup failed',
|
|
8233
|
+
);
|
|
8234
|
+
} catch (error) {
|
|
8235
|
+
cleanupFailures.push(error);
|
|
8236
|
+
}
|
|
8237
|
+
}
|
|
5721
8238
|
if (captainRuntime) {
|
|
5722
8239
|
shuttingDown = true;
|
|
5723
8240
|
try {
|
|
@@ -5734,11 +8251,20 @@ export function createPlaybookCaptainShell(
|
|
|
5734
8251
|
byCommand = new Map();
|
|
5735
8252
|
byId = new Map();
|
|
5736
8253
|
enablementById = new Map();
|
|
8254
|
+
hostCapabilitiesById = new Map();
|
|
8255
|
+
pendingHostCapabilities = undefined;
|
|
8256
|
+
currentEffectLedger = () => emptyPlaybookEffectLedger();
|
|
5737
8257
|
captainAgent = undefined;
|
|
5738
8258
|
captainAdapter = undefined;
|
|
5739
8259
|
playerAgents = new Map();
|
|
5740
8260
|
playerLedger.clear();
|
|
5741
8261
|
playerTransactions.clear();
|
|
8262
|
+
retainedGenerations.clear();
|
|
8263
|
+
ineligibleRetainedGenerations.clear();
|
|
8264
|
+
retainedGenerationRootClears.clear();
|
|
8265
|
+
retainedGenerationsInstalled = false;
|
|
8266
|
+
retainedGenerationInstallationInProgress = false;
|
|
8267
|
+
retainedGenerationInstallationClosed = false;
|
|
5742
8268
|
session = undefined;
|
|
5743
8269
|
sessionEmissionsOpen = false;
|
|
5744
8270
|
closedGateAttempted = false;
|
|
@@ -5746,6 +8272,7 @@ export function createPlaybookCaptainShell(
|
|
|
5746
8272
|
captainSessionId = undefined;
|
|
5747
8273
|
conversation = { kind: 'unopened' };
|
|
5748
8274
|
mode = 'chat';
|
|
8275
|
+
retainedEffectReconciliation = undefined;
|
|
5749
8276
|
pendingBossQuestions = undefined;
|
|
5750
8277
|
lastError = undefined;
|
|
5751
8278
|
journalSeq = 0;
|
|
@@ -5775,7 +8302,16 @@ export function createPlaybookCaptainShell(
|
|
|
5775
8302
|
lifecycle = 'restoring';
|
|
5776
8303
|
try {
|
|
5777
8304
|
const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
|
|
5778
|
-
const built = await
|
|
8305
|
+
const built = await buildCurrentEnablements();
|
|
8306
|
+
const builtHostCapabilities = new Map(built.hostCapabilitiesById);
|
|
8307
|
+
const readEffectLedger = () =>
|
|
8308
|
+
effectLedgerMirrorFromCapabilities(builtHostCapabilities);
|
|
8309
|
+
const hostLedger = readEffectLedger();
|
|
8310
|
+
if (!isDeepStrictEqual(snapshot.effectLedger, hostLedger)) {
|
|
8311
|
+
throw new Error(
|
|
8312
|
+
'Captain shell restore effect ledger does not match current-host authority',
|
|
8313
|
+
);
|
|
8314
|
+
}
|
|
5779
8315
|
captainAgent = built.captainAgent;
|
|
5780
8316
|
captainAdapter = captainAgent.adapter;
|
|
5781
8317
|
playerAgents = built.playerAgents;
|
|
@@ -5786,6 +8322,8 @@ export function createPlaybookCaptainShell(
|
|
|
5786
8322
|
byCommand = built.byCommand;
|
|
5787
8323
|
byId = built.byId;
|
|
5788
8324
|
enablementById = built.enablementById;
|
|
8325
|
+
hostCapabilitiesById = builtHostCapabilities;
|
|
8326
|
+
currentEffectLedger = readEffectLedger;
|
|
5789
8327
|
for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
|
|
5790
8328
|
playerLedger.set(playerId, {
|
|
5791
8329
|
adapter: saved.adapter,
|
|
@@ -5891,6 +8429,8 @@ export function createPlaybookCaptainShell(
|
|
|
5891
8429
|
lastSettlementStatus = snapshot.lastSettlementStatus;
|
|
5892
8430
|
mode = snapshot.mode;
|
|
5893
8431
|
if (snapshot.mode === 'engaged.parked') {
|
|
8432
|
+
retainedEffectReconciliation =
|
|
8433
|
+
snapshot.retainedEffectReconciliation;
|
|
5894
8434
|
pendingBossQuestions = snapshot.pendingBossQuestions;
|
|
5895
8435
|
lastError = snapshot.lastError;
|
|
5896
8436
|
}
|
|
@@ -5909,6 +8449,74 @@ export function createPlaybookCaptainShell(
|
|
|
5909
8449
|
}
|
|
5910
8450
|
};
|
|
5911
8451
|
|
|
8452
|
+
const installRetainedGenerations = async (
|
|
8453
|
+
generations: Readonly<
|
|
8454
|
+
Record<string, PlaybookCaptainRetainedGeneration>
|
|
8455
|
+
>,
|
|
8456
|
+
): Promise<void> => {
|
|
8457
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
8458
|
+
throw new Error(
|
|
8459
|
+
'retained generations require an initialized or restored Captain shell',
|
|
8460
|
+
);
|
|
8461
|
+
}
|
|
8462
|
+
if (
|
|
8463
|
+
retainedGenerationsInstalled ||
|
|
8464
|
+
retainedGenerationInstallationInProgress ||
|
|
8465
|
+
retainedGenerationInstallationClosed ||
|
|
8466
|
+
activeTurnHostCalls !== undefined
|
|
8467
|
+
) {
|
|
8468
|
+
throw new Error(
|
|
8469
|
+
'retained generations may be installed exactly once before the first nonempty Boss turn',
|
|
8470
|
+
);
|
|
8471
|
+
}
|
|
8472
|
+
const normalized = normalizeInstalledRetainedGenerations(generations);
|
|
8473
|
+
retainedGenerationInstallationInProgress = true;
|
|
8474
|
+
try {
|
|
8475
|
+
retainedGenerations.clear();
|
|
8476
|
+
retainedGenerationOffers.clear();
|
|
8477
|
+
ineligibleRetainedGenerations.clear();
|
|
8478
|
+
retainedGenerationRootClears.clear();
|
|
8479
|
+
for (const [rootPlaybookId, generation] of normalized) {
|
|
8480
|
+
retainedGenerations.set(rootPlaybookId, generation);
|
|
8481
|
+
}
|
|
8482
|
+
await prepareRetainedGenerationOffers();
|
|
8483
|
+
retainedGenerationsInstalled = true;
|
|
8484
|
+
} catch (error) {
|
|
8485
|
+
const preparationCleanupFailed =
|
|
8486
|
+
error instanceof RetainedRuntimeCleanupError;
|
|
8487
|
+
const runtimes = [...retainedGenerationOffers.values()].flatMap(
|
|
8488
|
+
(offer) => [...offer.runtimes],
|
|
8489
|
+
);
|
|
8490
|
+
retainedGenerationOffers.clear();
|
|
8491
|
+
retainedGenerations.clear();
|
|
8492
|
+
ineligibleRetainedGenerations.clear();
|
|
8493
|
+
retainedGenerationRootClears.clear();
|
|
8494
|
+
try {
|
|
8495
|
+
await disposeRetainedRuntimeSet(
|
|
8496
|
+
runtimes,
|
|
8497
|
+
'retained-generation installation cleanup failed',
|
|
8498
|
+
);
|
|
8499
|
+
} catch (cleanupError) {
|
|
8500
|
+
if (cleanupError instanceof RetainedRuntimeCleanupError) {
|
|
8501
|
+
retiredRetainedRuntimes.push(...cleanupError.failedRuntimes);
|
|
8502
|
+
}
|
|
8503
|
+
terminallyDisposed = true;
|
|
8504
|
+
lifecycle = 'closed';
|
|
8505
|
+
throw new AggregateError(
|
|
8506
|
+
[error, cleanupError],
|
|
8507
|
+
'retained-generation installation and cleanup failed',
|
|
8508
|
+
);
|
|
8509
|
+
}
|
|
8510
|
+
if (preparationCleanupFailed) {
|
|
8511
|
+
terminallyDisposed = true;
|
|
8512
|
+
lifecycle = 'closed';
|
|
8513
|
+
}
|
|
8514
|
+
throw error;
|
|
8515
|
+
} finally {
|
|
8516
|
+
retainedGenerationInstallationInProgress = false;
|
|
8517
|
+
}
|
|
8518
|
+
};
|
|
8519
|
+
|
|
5912
8520
|
return {
|
|
5913
8521
|
async init(initSession: CaptainSession): Promise<void> {
|
|
5914
8522
|
if (lifecycle !== 'fresh' || terminallyDisposed) {
|
|
@@ -5920,11 +8528,17 @@ export function createPlaybookCaptainShell(
|
|
|
5920
8528
|
lifecycle = 'initializing';
|
|
5921
8529
|
try {
|
|
5922
8530
|
installSession(initSession, true);
|
|
5923
|
-
const built = await
|
|
8531
|
+
const built = await buildCurrentEnablements();
|
|
8532
|
+
const builtHostCapabilities = new Map(built.hostCapabilitiesById);
|
|
8533
|
+
const readEffectLedger = () =>
|
|
8534
|
+
effectLedgerMirrorFromCapabilities(builtHostCapabilities);
|
|
8535
|
+
readEffectLedger();
|
|
5924
8536
|
entries = built.entries;
|
|
5925
8537
|
byCommand = built.byCommand;
|
|
5926
8538
|
byId = built.byId;
|
|
5927
8539
|
enablementById = built.enablementById;
|
|
8540
|
+
hostCapabilitiesById = builtHostCapabilities;
|
|
8541
|
+
currentEffectLedger = readEffectLedger;
|
|
5928
8542
|
captainAgent = built.captainAgent;
|
|
5929
8543
|
captainAdapter = captainAgent.adapter;
|
|
5930
8544
|
playerAgents = built.playerAgents;
|
|
@@ -5950,8 +8564,12 @@ export function createPlaybookCaptainShell(
|
|
|
5950
8564
|
|
|
5951
8565
|
exportSnapshot: exportShellSnapshot,
|
|
5952
8566
|
|
|
8567
|
+
exportSettlement,
|
|
8568
|
+
|
|
5953
8569
|
restore: restoreShellSnapshot,
|
|
5954
8570
|
|
|
8571
|
+
installRetainedGenerations,
|
|
8572
|
+
|
|
5955
8573
|
async handleBossTurn(
|
|
5956
8574
|
turn: BossTurn,
|
|
5957
8575
|
context: CaptainContext,
|
|
@@ -5968,9 +8586,26 @@ export function createPlaybookCaptainShell(
|
|
|
5968
8586
|
if (activeTurnHostCalls !== undefined) {
|
|
5969
8587
|
throw new Error('cannot handle concurrent Boss turns');
|
|
5970
8588
|
}
|
|
8589
|
+
if (retainedGenerationInstallationInProgress) {
|
|
8590
|
+
throw new Error(
|
|
8591
|
+
'cannot handle a Boss turn while retained generations are installing',
|
|
8592
|
+
);
|
|
8593
|
+
}
|
|
5971
8594
|
// Empty or whitespace-only input allocates no call, session, or
|
|
5972
8595
|
// telemetry (CAPTAIN-7).
|
|
8596
|
+
retentionSettlementReady = false;
|
|
8597
|
+
abandonmentSettlementUnsafe = false;
|
|
5973
8598
|
if (turn.prompt.trim().length === 0) return;
|
|
8599
|
+
settledTurnUnresolvedEffects = undefined;
|
|
8600
|
+
retainedGenerationInstallationClosed = true;
|
|
8601
|
+
for (const update of pendingRetentionUpdates.values()) {
|
|
8602
|
+
applyRetentionUpdateToCatalog(update);
|
|
8603
|
+
}
|
|
8604
|
+
retainedGenerationCandidates.clear();
|
|
8605
|
+
pendingRetentionUpdates.clear();
|
|
8606
|
+
// A terminal or dismissal can remove the whole stack during this turn;
|
|
8607
|
+
// take the latest already-settled generation before controller work.
|
|
8608
|
+
rememberRetainedGeneration();
|
|
5974
8609
|
const turnHostCalls = new Set<Promise<unknown>>();
|
|
5975
8610
|
activeTurnHostCalls = turnHostCalls;
|
|
5976
8611
|
activeContext = context;
|
|
@@ -5993,6 +8628,8 @@ export function createPlaybookCaptainShell(
|
|
|
5993
8628
|
decisionCall = undefined;
|
|
5994
8629
|
appendJournal('boss', turn.prompt);
|
|
5995
8630
|
try {
|
|
8631
|
+
await drainRetiredRetainedRuntimes();
|
|
8632
|
+
await prepareRetainedGenerationOffers();
|
|
5996
8633
|
const result = await captainRuntime.handleBossInput({
|
|
5997
8634
|
text: turn.prompt,
|
|
5998
8635
|
signal: context.signal,
|
|
@@ -6064,11 +8701,16 @@ export function createPlaybookCaptainShell(
|
|
|
6064
8701
|
activeTurnHostCalls = undefined;
|
|
6065
8702
|
}
|
|
6066
8703
|
activeContext = undefined;
|
|
8704
|
+
retentionSettlementReady = true;
|
|
6067
8705
|
}
|
|
6068
8706
|
},
|
|
6069
8707
|
|
|
6070
8708
|
async prepareDispose(): Promise<void> {
|
|
6071
|
-
if (
|
|
8709
|
+
if (
|
|
8710
|
+
lifecycle === 'initializing' ||
|
|
8711
|
+
lifecycle === 'restoring' ||
|
|
8712
|
+
retainedGenerationInstallationInProgress
|
|
8713
|
+
) {
|
|
6072
8714
|
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
6073
8715
|
}
|
|
6074
8716
|
activeContext = undefined;
|
|
@@ -6076,7 +8718,11 @@ export function createPlaybookCaptainShell(
|
|
|
6076
8718
|
},
|
|
6077
8719
|
|
|
6078
8720
|
async dispose(): Promise<void> {
|
|
6079
|
-
if (
|
|
8721
|
+
if (
|
|
8722
|
+
lifecycle === 'initializing' ||
|
|
8723
|
+
lifecycle === 'restoring' ||
|
|
8724
|
+
retainedGenerationInstallationInProgress
|
|
8725
|
+
) {
|
|
6080
8726
|
throw new Error('cannot dispose while Captain shell setup is in progress');
|
|
6081
8727
|
}
|
|
6082
8728
|
activeContext = undefined;
|
|
@@ -6095,6 +8741,20 @@ export function createPlaybookCaptainShell(
|
|
|
6095
8741
|
} catch (error) {
|
|
6096
8742
|
failure = error;
|
|
6097
8743
|
}
|
|
8744
|
+
const retainedRuntimes = takeRetainedOfferRuntimes();
|
|
8745
|
+
if (retainedRuntimes.length > 0) {
|
|
8746
|
+
try {
|
|
8747
|
+
await disposeRetainedRuntimeSet(
|
|
8748
|
+
retainedRuntimes,
|
|
8749
|
+
'retained-generation shell cleanup failed',
|
|
8750
|
+
);
|
|
8751
|
+
} catch (error) {
|
|
8752
|
+
failure ??= error;
|
|
8753
|
+
}
|
|
8754
|
+
}
|
|
8755
|
+
retainedGenerations.clear();
|
|
8756
|
+
ineligibleRetainedGenerations.clear();
|
|
8757
|
+
retainedGenerationRootClears.clear();
|
|
6098
8758
|
const runtime = captainRuntime;
|
|
6099
8759
|
captainRuntime = undefined;
|
|
6100
8760
|
if (runtime) {
|
|
@@ -6108,6 +8768,9 @@ export function createPlaybookCaptainShell(
|
|
|
6108
8768
|
// Quarantine is session-wide by design. Only terminal teardown may drop
|
|
6109
8769
|
// its ownership after every frame host call and the Captain are drained.
|
|
6110
8770
|
playerTransactions.clear();
|
|
8771
|
+
hostCapabilitiesById = new Map();
|
|
8772
|
+
pendingHostCapabilities = undefined;
|
|
8773
|
+
currentEffectLedger = () => emptyPlaybookEffectLedger();
|
|
6111
8774
|
lifecycle = 'closed';
|
|
6112
8775
|
if (failure !== undefined) throw failure;
|
|
6113
8776
|
}
|