@sublang/playbook 12.2.2 → 13.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 +56 -52
- package/docs/configuration.md +2 -2
- package/docs/embedding.md +68 -32
- package/package.json +11 -3
- package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +13 -7
- package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
- package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
- package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
- package/reference/sdlc/code.playbook/bin/run.js +62 -18
- package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
- package/reference/sdlc/code.playbook/bin/session-store.js +638 -70
- package/reference/sdlc/code.playbook/code.fsm.js +43 -5
- package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +75 -19
- package/reference/sdlc/code.playbook/playbook-captain.ts +93 -26
- package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
- package/reference/sdlc/code.playbook/session-host.js +18 -0
- package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
- package/reference/sdlc/code.playbook/session-store.js +22 -6
- package/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
- package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
- package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
- package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
- package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
- package/reference/sdlc/review.playbook/review.fsm.js +11 -1
- package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
- package/slc/gears2fsm.md +21 -3
- package/slc/link.md +21 -2
- package/src/runtime.d.ts +7 -0
- package/src/runtime.ts +12 -0
- package/src/xstate-playbook-runtime.d.ts +13 -0
- package/src/xstate-playbook-runtime.js +75 -13
- package/src/xstate-playbook-runtime.ts +87 -21
- package/src/xstate-runtime.js +35 -4
- package/src/xstate-runtime.ts +49 -4
|
@@ -77,6 +77,18 @@ function playbookMeta(stateId, role) {
|
|
|
77
77
|
},
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
// DR-048: a final state additionally declares whether its outcome means the
|
|
81
|
+
// workflow succeeded or failed, so a caller learns that from the machine
|
|
82
|
+
// rather than from CODE's output fields.
|
|
83
|
+
function terminalMeta(stateId, terminal) {
|
|
84
|
+
return {
|
|
85
|
+
playbook: {
|
|
86
|
+
stateId,
|
|
87
|
+
description: STATE_DESCRIPTIONS[stateId],
|
|
88
|
+
terminal,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
80
92
|
function isRecord(value) {
|
|
81
93
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
82
94
|
return false;
|
|
@@ -206,6 +218,16 @@ function nestedResultFromError(error) {
|
|
|
206
218
|
const result = error.result;
|
|
207
219
|
return isRecord(result) ? result : undefined;
|
|
208
220
|
}
|
|
221
|
+
function isFailureTerminalRecord(value) {
|
|
222
|
+
if (!isRecord(value))
|
|
223
|
+
return false;
|
|
224
|
+
const allowed = new Set(['stateId', 'kind', 'description']);
|
|
225
|
+
return (Reflect.ownKeys(value).every((key) => typeof key === 'string' && allowed.has(key)) &&
|
|
226
|
+
isNonEmptyString(value.stateId) &&
|
|
227
|
+
value.kind === 'failure' &&
|
|
228
|
+
(!Object.prototype.hasOwnProperty.call(value, 'description') ||
|
|
229
|
+
typeof value.description === 'string'));
|
|
230
|
+
}
|
|
209
231
|
function normalizedReviewFailure(error) {
|
|
210
232
|
const result = nestedResultFromError(error);
|
|
211
233
|
if (result === undefined)
|
|
@@ -215,10 +237,12 @@ function normalizedReviewFailure(error) {
|
|
|
215
237
|
'playbookId',
|
|
216
238
|
'childSessionId',
|
|
217
239
|
'state',
|
|
218
|
-
'error',
|
|
240
|
+
...(result.status === 'ok' ? ['output', 'terminal'] : ['error']),
|
|
219
241
|
]);
|
|
220
242
|
if (Reflect.ownKeys(result).some((key) => typeof key !== 'string' || !allowed.has(key)) ||
|
|
221
|
-
(result.status !== 'aborted' &&
|
|
243
|
+
(result.status !== 'aborted' &&
|
|
244
|
+
result.status !== 'error' &&
|
|
245
|
+
result.status !== 'ok') ||
|
|
222
246
|
result.playbookId !== 'review') {
|
|
223
247
|
return undefined;
|
|
224
248
|
}
|
|
@@ -230,6 +254,19 @@ function normalizedReviewFailure(error) {
|
|
|
230
254
|
!isPlaybookState(result.state)) {
|
|
231
255
|
return undefined;
|
|
232
256
|
}
|
|
257
|
+
if (result.status === 'ok') {
|
|
258
|
+
if (!Object.prototype.hasOwnProperty.call(result, 'terminal') ||
|
|
259
|
+
!isFailureTerminalRecord(result.terminal) ||
|
|
260
|
+
!isNonEmptyString(result.childSessionId)) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
status: 'ok',
|
|
265
|
+
...(result.output === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: { output: result.output }),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
233
270
|
let normalizedError;
|
|
234
271
|
if (Object.prototype.hasOwnProperty.call(result, 'error')) {
|
|
235
272
|
if (!isRecord(result.error))
|
|
@@ -274,8 +311,9 @@ function compactError(value) {
|
|
|
274
311
|
function authoredReviewError(event) {
|
|
275
312
|
const outer = isRecord(event) ? event.error : undefined;
|
|
276
313
|
const failure = normalizedReviewFailure(outer);
|
|
277
|
-
if (failure
|
|
314
|
+
if (failure !== undefined && failure.status !== 'ok' && failure.error) {
|
|
278
315
|
return failure.error;
|
|
316
|
+
}
|
|
279
317
|
if (failure?.status === 'aborted') {
|
|
280
318
|
return { name: 'AbortError', message: 'REVIEW was aborted.' };
|
|
281
319
|
}
|
|
@@ -787,13 +825,13 @@ export const codingMachine = machineSetup.createMachine({
|
|
|
787
825
|
reportedReviewFailure: {
|
|
788
826
|
id: 'reportedReviewFailure',
|
|
789
827
|
description: STATE_DESCRIPTIONS.reportedReviewFailure,
|
|
790
|
-
meta:
|
|
828
|
+
meta: terminalMeta('reportedReviewFailure', 'failure'),
|
|
791
829
|
type: 'final',
|
|
792
830
|
},
|
|
793
831
|
done: {
|
|
794
832
|
id: 'done',
|
|
795
833
|
description: STATE_DESCRIPTIONS.done,
|
|
796
|
-
meta:
|
|
834
|
+
meta: terminalMeta('done', 'success'),
|
|
797
835
|
type: 'final',
|
|
798
836
|
},
|
|
799
837
|
},
|
|
@@ -241,6 +241,22 @@ function playbookMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// DR-048: a final state additionally declares whether its outcome means the
|
|
245
|
+
// workflow succeeded or failed, so a caller learns that from the machine
|
|
246
|
+
// rather than from CODE's output fields.
|
|
247
|
+
function terminalMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
|
|
248
|
+
stateId: StateId,
|
|
249
|
+
terminal: 'success' | 'failure',
|
|
250
|
+
) {
|
|
251
|
+
return {
|
|
252
|
+
playbook: {
|
|
253
|
+
stateId,
|
|
254
|
+
description: STATE_DESCRIPTIONS[stateId],
|
|
255
|
+
terminal,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
244
260
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
245
261
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
246
262
|
return false;
|
|
@@ -419,10 +435,32 @@ function nestedResultFromError(error: unknown): Record<string, unknown> | undefi
|
|
|
419
435
|
return isRecord(result) ? result : undefined;
|
|
420
436
|
}
|
|
421
437
|
|
|
422
|
-
type AuthoredReviewFailure =
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
438
|
+
type AuthoredReviewFailure =
|
|
439
|
+
| {
|
|
440
|
+
readonly status: 'aborted' | 'error';
|
|
441
|
+
readonly error?: CompactError;
|
|
442
|
+
}
|
|
443
|
+
// DR-048: a child that completed at an authored failure terminal. The
|
|
444
|
+
// bridge rejects it through the same error path, so CODE recognizes the
|
|
445
|
+
// failure from REVIEW's own machine and never from its output fields.
|
|
446
|
+
| {
|
|
447
|
+
readonly status: 'ok';
|
|
448
|
+
readonly output?: JsonValue;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
function isFailureTerminalRecord(value: unknown): boolean {
|
|
452
|
+
if (!isRecord(value)) return false;
|
|
453
|
+
const allowed = new Set(['stateId', 'kind', 'description']);
|
|
454
|
+
return (
|
|
455
|
+
Reflect.ownKeys(value).every(
|
|
456
|
+
(key) => typeof key === 'string' && allowed.has(key),
|
|
457
|
+
) &&
|
|
458
|
+
isNonEmptyString(value.stateId) &&
|
|
459
|
+
value.kind === 'failure' &&
|
|
460
|
+
(!Object.prototype.hasOwnProperty.call(value, 'description') ||
|
|
461
|
+
typeof value.description === 'string')
|
|
462
|
+
);
|
|
463
|
+
}
|
|
426
464
|
|
|
427
465
|
function normalizedReviewFailure(
|
|
428
466
|
error: unknown,
|
|
@@ -434,13 +472,15 @@ function normalizedReviewFailure(
|
|
|
434
472
|
'playbookId',
|
|
435
473
|
'childSessionId',
|
|
436
474
|
'state',
|
|
437
|
-
'error',
|
|
475
|
+
...(result.status === 'ok' ? ['output', 'terminal'] : ['error']),
|
|
438
476
|
]);
|
|
439
477
|
if (
|
|
440
478
|
Reflect.ownKeys(result).some(
|
|
441
479
|
(key) => typeof key !== 'string' || !allowed.has(key),
|
|
442
480
|
) ||
|
|
443
|
-
(result.status !== 'aborted' &&
|
|
481
|
+
(result.status !== 'aborted' &&
|
|
482
|
+
result.status !== 'error' &&
|
|
483
|
+
result.status !== 'ok') ||
|
|
444
484
|
result.playbookId !== 'review'
|
|
445
485
|
) {
|
|
446
486
|
return undefined;
|
|
@@ -457,6 +497,21 @@ function normalizedReviewFailure(
|
|
|
457
497
|
) {
|
|
458
498
|
return undefined;
|
|
459
499
|
}
|
|
500
|
+
if (result.status === 'ok') {
|
|
501
|
+
if (
|
|
502
|
+
!Object.prototype.hasOwnProperty.call(result, 'terminal') ||
|
|
503
|
+
!isFailureTerminalRecord(result.terminal) ||
|
|
504
|
+
!isNonEmptyString(result.childSessionId)
|
|
505
|
+
) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
status: 'ok',
|
|
510
|
+
...(result.output === undefined
|
|
511
|
+
? {}
|
|
512
|
+
: { output: result.output as JsonValue }),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
460
515
|
let normalizedError: CompactError | undefined;
|
|
461
516
|
if (Object.prototype.hasOwnProperty.call(result, 'error')) {
|
|
462
517
|
if (!isRecord(result.error)) return undefined;
|
|
@@ -507,7 +562,9 @@ function compactError(value: unknown): CompactError {
|
|
|
507
562
|
function authoredReviewError(event: unknown): CompactError {
|
|
508
563
|
const outer = isRecord(event) ? event.error : undefined;
|
|
509
564
|
const failure = normalizedReviewFailure(outer);
|
|
510
|
-
if (failure
|
|
565
|
+
if (failure !== undefined && failure.status !== 'ok' && failure.error) {
|
|
566
|
+
return failure.error;
|
|
567
|
+
}
|
|
511
568
|
if (failure?.status === 'aborted') {
|
|
512
569
|
return { name: 'AbortError', message: 'REVIEW was aborted.' };
|
|
513
570
|
}
|
|
@@ -1052,13 +1109,13 @@ export const codingMachine = machineSetup.createMachine({
|
|
|
1052
1109
|
reportedReviewFailure: {
|
|
1053
1110
|
id: 'reportedReviewFailure',
|
|
1054
1111
|
description: STATE_DESCRIPTIONS.reportedReviewFailure,
|
|
1055
|
-
meta:
|
|
1112
|
+
meta: terminalMeta('reportedReviewFailure', 'failure'),
|
|
1056
1113
|
type: 'final',
|
|
1057
1114
|
},
|
|
1058
1115
|
done: {
|
|
1059
1116
|
id: 'done',
|
|
1060
1117
|
description: STATE_DESCRIPTIONS.done,
|
|
1061
|
-
meta:
|
|
1118
|
+
meta: terminalMeta('done', 'success'),
|
|
1062
1119
|
type: 'final',
|
|
1063
1120
|
},
|
|
1064
1121
|
},
|
|
@@ -35,6 +35,11 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
|
|
|
35
35
|
type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, 'model' | 'effort' | 'fastMode'>>;
|
|
36
36
|
type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
|
|
37
37
|
export interface PlaybookCaptainDeps {
|
|
38
|
+
continuity?: {
|
|
39
|
+
beforeCall(participantId: string): Promise<void>;
|
|
40
|
+
acknowledged(participantId: string, token: string): void;
|
|
41
|
+
reset(participantId: string, reason: 'missing_hint' | 'rejected_hint'): Promise<void>;
|
|
42
|
+
};
|
|
38
43
|
loadModule?: (specifier: string) => Promise<unknown>;
|
|
39
44
|
createSessionId?: () => string;
|
|
40
45
|
hostCapabilities?: Readonly<Record<string, PlaybookHostConstructionCapabilities>>;
|
|
@@ -60,8 +60,21 @@ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
|
60
60
|
const INTERNAL_CAPTAIN_ID = 'captain';
|
|
61
61
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
62
62
|
const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
|
63
|
-
const
|
|
63
|
+
const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
|
|
64
64
|
const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
|
|
65
|
+
// PBCLI-4: a local role id is its source role name lowercased by Unicode case
|
|
66
|
+
// mapping — nonempty, free of whitespace and control characters, and equal to
|
|
67
|
+
// its own lowercase form. The rule restricts neither script nor alphabet, so
|
|
68
|
+
// `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
|
|
69
|
+
// enforce the reserved `captain` name separately. The CLI host owns the same
|
|
70
|
+
// predicate in `bin/session-store.js`; that private module already imports
|
|
71
|
+
// this one, so the text is repeated here rather than cycled back.
|
|
72
|
+
function isCanonicalLocalRoleId(value) {
|
|
73
|
+
return (typeof value === 'string' &&
|
|
74
|
+
value.length > 0 &&
|
|
75
|
+
value === value.toLowerCase() &&
|
|
76
|
+
!ROLE_ID_WHITESPACE_OR_CONTROL.test(value));
|
|
77
|
+
}
|
|
65
78
|
const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID = 'reconcile:unresolved-effect';
|
|
66
79
|
const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
|
|
67
80
|
// The fixed machine-syntax guard of a player's Boss-question suspension
|
|
@@ -685,9 +698,7 @@ function isValidRegistryEntry(value, artifactSchema) {
|
|
|
685
698
|
return false;
|
|
686
699
|
const e = value;
|
|
687
700
|
if (!Array.isArray(e.requiredRoleIds) ||
|
|
688
|
-
e.requiredRoleIds.some((role) =>
|
|
689
|
-
!ROLE_ID_PATTERN.test(role) ||
|
|
690
|
-
role === INTERNAL_CAPTAIN_ID) ||
|
|
701
|
+
e.requiredRoleIds.some((role) => !isCanonicalLocalRoleId(role) || role === INTERNAL_CAPTAIN_ID) ||
|
|
691
702
|
new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
|
|
692
703
|
!Array.isArray(e.concurrentRoleSets)) {
|
|
693
704
|
return false;
|
|
@@ -911,7 +922,7 @@ function snapshotPlayerSessions(value, path) {
|
|
|
911
922
|
function snapshotFrameRoleBindings(value, path) {
|
|
912
923
|
const bindings = snapshotRecord(value, path);
|
|
913
924
|
return Object.fromEntries(Object.entries(bindings).map(([roleId, raw]) => {
|
|
914
|
-
if (!
|
|
925
|
+
if (!isCanonicalLocalRoleId(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
|
|
915
926
|
throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
|
|
916
927
|
}
|
|
917
928
|
const playerId = snapshotString(raw, `${path}.${roleId}`);
|
|
@@ -938,6 +949,7 @@ function normalizeHostPlayerResult(value, expectedPlayerId) {
|
|
|
938
949
|
'resumeToken',
|
|
939
950
|
'finalText',
|
|
940
951
|
'error',
|
|
952
|
+
'errorCode',
|
|
941
953
|
]);
|
|
942
954
|
const normalized = {};
|
|
943
955
|
for (const key of Reflect.ownKeys(descriptors)) {
|
|
@@ -959,11 +971,16 @@ function normalizeHostPlayerResult(value, expectedPlayerId) {
|
|
|
959
971
|
normalized[key] = descriptor.value;
|
|
960
972
|
}
|
|
961
973
|
const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
|
|
962
|
-
rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'], path);
|
|
974
|
+
rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error', 'errorCode'], path);
|
|
963
975
|
if (record.playerId !== expectedPlayerId) {
|
|
964
976
|
throw new TypeError(`${path}.playerId does not match the requested player`);
|
|
965
977
|
}
|
|
966
978
|
snapshotInteger(record.turnId, `${path}.turnId`, 1);
|
|
979
|
+
if (record.errorCode !== undefined &&
|
|
980
|
+
(record.errorCode !== 'SESSION_RESUME_REJECTED' ||
|
|
981
|
+
record.status !== 'error' || record.resumeToken !== undefined)) {
|
|
982
|
+
throw new TypeError(`${path}.errorCode is not a definite resume rejection`);
|
|
983
|
+
}
|
|
967
984
|
return validatePlayerResult({
|
|
968
985
|
status: record.status,
|
|
969
986
|
...(record.resumeToken === undefined
|
|
@@ -1618,6 +1635,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1618
1635
|
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
1619
1636
|
const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
1620
1637
|
const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
|
|
1638
|
+
const continuity = deps.continuity;
|
|
1621
1639
|
let pendingHostCapabilities = deps.hostCapabilities;
|
|
1622
1640
|
let currentEffectLedger = () => emptyPlaybookEffectLedger();
|
|
1623
1641
|
// The returned shell must not retain the caller's aggregate dependency
|
|
@@ -2604,11 +2622,32 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2604
2622
|
try {
|
|
2605
2623
|
let rawResult;
|
|
2606
2624
|
try {
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2625
|
+
const call = async (resume) => {
|
|
2626
|
+
await continuity?.beforeCall(binding.playerId);
|
|
2627
|
+
signal.throwIfAborted();
|
|
2628
|
+
const raw = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, prompt, { resume, settings })));
|
|
2629
|
+
hostResolved = true;
|
|
2630
|
+
return raw;
|
|
2631
|
+
};
|
|
2632
|
+
if (options.resume === false) {
|
|
2633
|
+
await continuity?.reset(binding.playerId, 'missing_hint');
|
|
2634
|
+
}
|
|
2635
|
+
rawResult = await call(options.resume);
|
|
2636
|
+
normalizeHostPlayerResult(rawResult, binding.playerId);
|
|
2637
|
+
if (typeof options.resume === 'string' && options.resume.length > 0 &&
|
|
2638
|
+
Object.getOwnPropertyDescriptor(rawResult, 'errorCode')?.value ===
|
|
2639
|
+
'SESSION_RESUME_REJECTED') {
|
|
2640
|
+
if (playerTransactions.get(binding.playerId) !== calling ||
|
|
2641
|
+
calling.abandoned || signal.aborted || activeTurn !== admittedTurn ||
|
|
2642
|
+
frame.playerCallScope !== scope || !frames.includes(frame)) {
|
|
2643
|
+
signal.throwIfAborted();
|
|
2644
|
+
throw new Error(`${frameLabel(frame)} player rejection arrived after its runtime operation ended`);
|
|
2645
|
+
}
|
|
2646
|
+
delete ledger.resumeToken;
|
|
2647
|
+
await continuity?.reset(binding.playerId, 'rejected_hint');
|
|
2648
|
+
hostResolved = false;
|
|
2649
|
+
rawResult = await call(false);
|
|
2650
|
+
}
|
|
2612
2651
|
}
|
|
2613
2652
|
catch (error) {
|
|
2614
2653
|
if (error instanceof AgentSettingsPreflightError) {
|
|
@@ -2903,6 +2942,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2903
2942
|
delete ledger.resumeToken;
|
|
2904
2943
|
else
|
|
2905
2944
|
ledger.resumeToken = resumeToken;
|
|
2945
|
+
if (pending.status === 'ok' && resumeToken !== undefined) {
|
|
2946
|
+
continuity?.acknowledged(binding.playerId, resumeToken);
|
|
2947
|
+
}
|
|
2906
2948
|
}
|
|
2907
2949
|
finally {
|
|
2908
2950
|
playerTransactions.delete(binding.playerId);
|
|
@@ -3305,6 +3347,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3305
3347
|
childSessionId: frame.sessionId,
|
|
3306
3348
|
state: result.state,
|
|
3307
3349
|
...(result.output !== undefined ? { output: result.output } : {}),
|
|
3350
|
+
// DR-048: the child runtime read this record from its own artifact;
|
|
3351
|
+
// the host relays it unchanged so the caller's bridge can route a
|
|
3352
|
+
// failure terminal without knowing the callee's output fields.
|
|
3353
|
+
...(result.terminal !== undefined
|
|
3354
|
+
? { terminal: result.terminal }
|
|
3355
|
+
: {}),
|
|
3308
3356
|
};
|
|
3309
3357
|
}
|
|
3310
3358
|
if (result.outcome === 'aborted') {
|
|
@@ -4092,7 +4140,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4092
4140
|
};
|
|
4093
4141
|
class CaptainContinuityError extends Error {
|
|
4094
4142
|
constructor(cause) {
|
|
4095
|
-
super('the session Captain conversation
|
|
4143
|
+
super('the session Captain conversation lost continuity', { cause });
|
|
4096
4144
|
this.name = 'CaptainContinuityError';
|
|
4097
4145
|
}
|
|
4098
4146
|
}
|
|
@@ -4104,6 +4152,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4104
4152
|
}
|
|
4105
4153
|
const rawDurableCall = async (context, prompt, resume, attempt) => {
|
|
4106
4154
|
const queued = captainQueue.add(async () => {
|
|
4155
|
+
context.signal.throwIfAborted();
|
|
4156
|
+
await continuity?.beforeCall('captain');
|
|
4107
4157
|
context.signal.throwIfAborted();
|
|
4108
4158
|
attempt.providerBoundaryEntered = true;
|
|
4109
4159
|
const result = await classifySettingsCall(() => context.callCaptain(prompt, {
|
|
@@ -4117,11 +4167,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4117
4167
|
});
|
|
4118
4168
|
return trackTurnCall(queued);
|
|
4119
4169
|
};
|
|
4120
|
-
//
|
|
4121
|
-
//
|
|
4122
|
-
// seeded with the reseed digest plus the current ControlView digest. A
|
|
4123
|
-
// conversation that is owed a reseed carries the digest on its very next
|
|
4124
|
-
// call, so the turn after a failed reseed starts seeded rather than blank.
|
|
4170
|
+
// Only proven pre-execution rejection permits an immediate fresh call.
|
|
4171
|
+
// Other continuity loss leaves the journal reseed for the next Boss turn.
|
|
4125
4172
|
const durableCall = async (context, compose) => {
|
|
4126
4173
|
const startingConversation = conversation;
|
|
4127
4174
|
const resume = startingConversation.kind === 'pinned'
|
|
@@ -4136,6 +4183,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4136
4183
|
let result;
|
|
4137
4184
|
let failure;
|
|
4138
4185
|
try {
|
|
4186
|
+
if (resume === false)
|
|
4187
|
+
await continuity?.reset('captain', 'missing_hint');
|
|
4139
4188
|
result = await rawDurableCall(context, compose(seedFirstCall
|
|
4140
4189
|
? { reseedDigest: reseedDigest() }
|
|
4141
4190
|
: startingConversation.kind === 'needsCatchUp'
|
|
@@ -4170,9 +4219,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4170
4219
|
const unsynchronized = failure !== undefined ||
|
|
4171
4220
|
result === undefined ||
|
|
4172
4221
|
result.status !== 'ok' ||
|
|
4173
|
-
result.resumeToken ===
|
|
4222
|
+
typeof result.resumeToken !== 'string' || result.resumeToken.trim().length === 0;
|
|
4174
4223
|
if (!unsynchronized) {
|
|
4175
4224
|
conversation = { kind: 'pinned', token: result.resumeToken };
|
|
4225
|
+
continuity?.acknowledged('captain', result.resumeToken);
|
|
4176
4226
|
if (activeTurn) {
|
|
4177
4227
|
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
4178
4228
|
}
|
|
@@ -4188,6 +4238,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4188
4238
|
// stays `needsSeeding` until a call comes back with a token, so a reseed
|
|
4189
4239
|
// that itself fails leaves the obligation standing for the next turn.
|
|
4190
4240
|
conversation = { kind: 'needsSeeding' };
|
|
4241
|
+
if (typeof resume !== 'string' || resume.length === 0 ||
|
|
4242
|
+
result?.status !== 'error' || result.errorCode !== 'SESSION_RESUME_REJECTED') {
|
|
4243
|
+
throw markControlFailure(new CaptainContinuityError(failure ?? result?.error ?? 'callCaptain did not establish continuation'));
|
|
4244
|
+
}
|
|
4245
|
+
await continuity?.reset('captain', 'rejected_hint');
|
|
4191
4246
|
const recap = reseedDigest();
|
|
4192
4247
|
let reissued;
|
|
4193
4248
|
const reissueAttempt = { providerBoundaryEntered: false };
|
|
@@ -4204,11 +4259,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4204
4259
|
}
|
|
4205
4260
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
4206
4261
|
}
|
|
4207
|
-
if (reissued.status !== 'ok' || reissued.resumeToken ===
|
|
4262
|
+
if (reissued.status !== 'ok' || typeof reissued.resumeToken !== 'string' || reissued.resumeToken.trim().length === 0) {
|
|
4208
4263
|
throw markControlFailure(new CaptainContinuityError(reissued.error ??
|
|
4209
4264
|
`callCaptain status "${reissued.status}" without a resume token`));
|
|
4210
4265
|
}
|
|
4211
4266
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
4267
|
+
continuity?.acknowledged('captain', reissued.resumeToken);
|
|
4212
4268
|
if (activeTurn)
|
|
4213
4269
|
activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
4214
4270
|
return {
|