@sublang/playbook 12.3.0 → 13.1.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.md +7 -0
- package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
- 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 +622 -65
- package/reference/sdlc/code.playbook/code.fsm.js +5 -5
- package/reference/sdlc/code.playbook/code.fsm.ts +5 -5
- package/reference/sdlc/code.playbook/code.gears.md +5 -5
- package/reference/sdlc/code.playbook/code.playbook.d.ts +1 -1
- package/reference/sdlc/code.playbook/code.playbook.js +6 -16
- package/reference/sdlc/code.playbook/code.playbook.ts +7 -17
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +56 -14
- package/reference/sdlc/code.playbook/playbook-captain.ts +71 -21
- 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.md +2 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +1 -1
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1 -1
- package/reference/sdlc/decide.playbook/decide.gears.md +2 -2
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +1 -2
- package/reference/sdlc/decide.playbook/decide.playbook.js +8 -22
- package/reference/sdlc/decide.playbook/decide.playbook.ts +8 -25
- package/reference/sdlc/dev.md +21 -1
- package/reference/sdlc/dev.playbook/dev.fsm.js +10 -10
- package/reference/sdlc/dev.playbook/dev.fsm.ts +13 -13
- package/reference/sdlc/dev.playbook/dev.gears.md +15 -15
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +1 -1
- package/reference/sdlc/dev.playbook/dev.playbook.js +7 -17
- package/reference/sdlc/dev.playbook/dev.playbook.ts +8 -20
- package/reference/sdlc/review.md +18 -0
- package/reference/sdlc/review.playbook/review.fsm.js +8 -8
- package/reference/sdlc/review.playbook/review.fsm.ts +8 -8
- package/reference/sdlc/review.playbook/review.gears.md +8 -8
- package/reference/sdlc/review.playbook/review.playbook.d.ts +1 -1
- package/reference/sdlc/review.playbook/review.playbook.js +5 -15
- package/reference/sdlc/review.playbook/review.playbook.ts +6 -16
- package/slc/link.md +12 -24
- package/src/runtime.d.ts +2 -0
- package/src/runtime.ts +2 -0
- package/src/xstate-playbook-runtime.d.ts +4 -2
- package/src/xstate-playbook-runtime.js +30 -26
- package/src/xstate-playbook-runtime.ts +40 -35
|
@@ -12,8 +12,8 @@ const APPENDED_PHASE_PROMPT = [
|
|
|
12
12
|
'Coder is <coder-llm>.',
|
|
13
13
|
];
|
|
14
14
|
const FIRST_PHASE_PROMPT = [
|
|
15
|
-
'> <caller-input>',
|
|
16
|
-
'> <run-results>',
|
|
15
|
+
'> Original request: <caller-input>',
|
|
16
|
+
'> Run results: <run-results>',
|
|
17
17
|
'',
|
|
18
18
|
'First determine whether the coding request starts a new coding intent or continues an existing IR with unfinished work.',
|
|
19
19
|
'If the request may continue an existing IR but does not identify it unambiguously, ask Boss before changing files.',
|
|
@@ -33,9 +33,9 @@ const FIRST_PHASE_PROMPT = [
|
|
|
33
33
|
...APPENDED_PHASE_PROMPT,
|
|
34
34
|
].join('\n');
|
|
35
35
|
const IR_TASK_PROMPT = [
|
|
36
|
-
'> <caller-input>',
|
|
37
|
-
'> <ir-number>',
|
|
38
|
-
'> <run-results>',
|
|
36
|
+
'> Original request: <caller-input>',
|
|
37
|
+
'> IR number: <ir-number>',
|
|
38
|
+
'> Run results: <run-results>',
|
|
39
39
|
'',
|
|
40
40
|
'Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.',
|
|
41
41
|
'Do not implement a later task in this phase.',
|
|
@@ -156,8 +156,8 @@ const APPENDED_PHASE_PROMPT = [
|
|
|
156
156
|
];
|
|
157
157
|
|
|
158
158
|
const FIRST_PHASE_PROMPT = [
|
|
159
|
-
'> <caller-input>',
|
|
160
|
-
'> <run-results>',
|
|
159
|
+
'> Original request: <caller-input>',
|
|
160
|
+
'> Run results: <run-results>',
|
|
161
161
|
'',
|
|
162
162
|
'First determine whether the coding request starts a new coding intent or continues an existing IR with unfinished work.',
|
|
163
163
|
'If the request may continue an existing IR but does not identify it unambiguously, ask Boss before changing files.',
|
|
@@ -178,9 +178,9 @@ const FIRST_PHASE_PROMPT = [
|
|
|
178
178
|
].join('\n');
|
|
179
179
|
|
|
180
180
|
const IR_TASK_PROMPT = [
|
|
181
|
-
'> <caller-input>',
|
|
182
|
-
'> <ir-number>',
|
|
183
|
-
'> <run-results>',
|
|
181
|
+
'> Original request: <caller-input>',
|
|
182
|
+
'> IR number: <ir-number>',
|
|
183
|
+
'> Run results: <run-results>',
|
|
184
184
|
'',
|
|
185
185
|
'Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.',
|
|
186
186
|
'Do not implement a later task in this phase.',
|
|
@@ -13,8 +13,8 @@ Roles:
|
|
|
13
13
|
|
|
14
14
|
When the first coding phase begins, Captain shall prompt Coder:
|
|
15
15
|
|
|
16
|
-
> > <caller-input>
|
|
17
|
-
> > <run-results>
|
|
16
|
+
> > Original request: <caller-input>
|
|
17
|
+
> > Run results: <run-results>
|
|
18
18
|
>
|
|
19
19
|
> First determine whether the coding request starts a new coding intent or continues an existing IR with unfinished work.
|
|
20
20
|
> If the request may continue an existing IR but does not identify it unambiguously, ask Boss before changing files.
|
|
@@ -63,9 +63,9 @@ Workflow outcomes:
|
|
|
63
63
|
|
|
64
64
|
When a later IR-task phase begins, Captain shall prompt Coder:
|
|
65
65
|
|
|
66
|
-
> > <caller-input>
|
|
67
|
-
> > <ir-number>
|
|
68
|
-
> > <run-results>
|
|
66
|
+
> > Original request: <caller-input>
|
|
67
|
+
> > IR number: <ir-number>
|
|
68
|
+
> > Run results: <run-results>
|
|
69
69
|
>
|
|
70
70
|
> Read the identified IR and implement exactly its next unfinished task, including corresponding tests or specs if any.
|
|
71
71
|
> Do not implement a later task in this phase.
|
|
@@ -10,7 +10,7 @@ export type CodePlaybookHostCapabilities = PlaybookHostConstructionCapabilities
|
|
|
10
10
|
* runtime text. The generic composer preserves the marker itself; CODE's
|
|
11
11
|
* override additionally keeps a multiline value inside that quote.
|
|
12
12
|
*/
|
|
13
|
-
declare function composePlayerPrompt(input: PlayerInput, promptIdentity: XStatePromptIdentity): string;
|
|
13
|
+
declare function composePlayerPrompt(input: PlayerInput, promptIdentity: XStatePromptIdentity, resuming?: boolean): string;
|
|
14
14
|
export declare const _internal: {
|
|
15
15
|
composePlayerPrompt: typeof composePlayerPrompt;
|
|
16
16
|
VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string>;
|
|
@@ -10,11 +10,10 @@
|
|
|
10
10
|
// the first phase decides among four semantic outcomes
|
|
11
11
|
// Nested call: literal review target through the shared bridge
|
|
12
12
|
// Compat: artifact schema 3 / runtime ABI 1
|
|
13
|
-
import { createXStatePlaybookRuntime, snapshotJsonValue, } from '@sublang/playbook/xstate-runtime';
|
|
13
|
+
import { createXStatePlaybookRuntime, composePlayerContinuation, snapshotJsonValue, } from '@sublang/playbook/xstate-runtime';
|
|
14
14
|
import { codingMachine, } from './code.fsm.js';
|
|
15
15
|
const OPTION_KEYS = new Set(['runResults']);
|
|
16
16
|
const PLACEHOLDER = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
|
|
17
|
-
const CONTINUATION_PREAMBLE = 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
|
|
18
17
|
const VERBATIM_PAYLOAD_FIELDS = new Set([
|
|
19
18
|
'coderOutput',
|
|
20
19
|
]);
|
|
@@ -52,11 +51,11 @@ function quoteContinuation(value) {
|
|
|
52
51
|
* runtime text. The generic composer preserves the marker itself; CODE's
|
|
53
52
|
* override additionally keeps a multiline value inside that quote.
|
|
54
53
|
*/
|
|
55
|
-
function composePlayerPrompt(input, promptIdentity) {
|
|
54
|
+
function composePlayerPrompt(input, promptIdentity, resuming = false) {
|
|
56
55
|
const fields = input;
|
|
57
56
|
const template = input.prompt
|
|
58
57
|
.split('\n')
|
|
59
|
-
.filter((line) => !(line === '> <run-results>' && input.runResults.length === 0))
|
|
58
|
+
.filter((line) => !(line === '> Run results: <run-results>' && input.runResults.length === 0))
|
|
60
59
|
.join('\n');
|
|
61
60
|
const body = template.replace(PLACEHOLDER, (match, token, offset, source) => {
|
|
62
61
|
const value = token === 'coder-llm'
|
|
@@ -66,18 +65,9 @@ function composePlayerPrompt(input, promptIdentity) {
|
|
|
66
65
|
return match;
|
|
67
66
|
const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
|
|
68
67
|
const literal = source.slice(lineStart, offset);
|
|
69
|
-
return literal
|
|
68
|
+
return literal.startsWith('> ') ? quoteContinuation(value) : value;
|
|
70
69
|
});
|
|
71
|
-
|
|
72
|
-
input.bossReply === undefined) {
|
|
73
|
-
return body;
|
|
74
|
-
}
|
|
75
|
-
return [
|
|
76
|
-
CONTINUATION_PREAMBLE,
|
|
77
|
-
`Boss question:\n${input.pendingBossQuestion.question}`,
|
|
78
|
-
`Boss reply:\n${input.bossReply}`,
|
|
79
|
-
body,
|
|
80
|
-
].join('\n\n');
|
|
70
|
+
return composePlayerContinuation(input, body, resuming);
|
|
81
71
|
}
|
|
82
72
|
export const _internal = {
|
|
83
73
|
composePlayerPrompt,
|
|
@@ -177,7 +167,7 @@ const runtimeSpec = {
|
|
|
177
167
|
},
|
|
178
168
|
},
|
|
179
169
|
},
|
|
180
|
-
composePlayerPrompt: (input, promptIdentity) => composePlayerPrompt(input, promptIdentity),
|
|
170
|
+
composePlayerPrompt: (input, promptIdentity, resuming) => composePlayerPrompt(input, promptIdentity, resuming),
|
|
181
171
|
verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
|
|
182
172
|
controlContextFields: ['phase'],
|
|
183
173
|
unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
createXStatePlaybookRuntime,
|
|
16
|
+
composePlayerContinuation,
|
|
16
17
|
snapshotJsonValue,
|
|
17
18
|
type PlaybookPlayerInput,
|
|
18
19
|
type XStatePlaybookRuntimeFactory,
|
|
@@ -85,8 +86,6 @@ export type CodePlaybookHostCapabilities =
|
|
|
85
86
|
|
|
86
87
|
const OPTION_KEYS = new Set(['runResults']);
|
|
87
88
|
const PLACEHOLDER = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
|
|
88
|
-
const CONTINUATION_PREAMBLE =
|
|
89
|
-
'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
|
|
90
89
|
|
|
91
90
|
const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
|
|
92
91
|
'coderOutput',
|
|
@@ -135,12 +134,13 @@ function quoteContinuation(value: string): string {
|
|
|
135
134
|
function composePlayerPrompt(
|
|
136
135
|
input: PlayerInput,
|
|
137
136
|
promptIdentity: XStatePromptIdentity,
|
|
137
|
+
resuming = false,
|
|
138
138
|
): string {
|
|
139
139
|
const fields = input as unknown as Record<string, unknown>;
|
|
140
140
|
const template = input.prompt
|
|
141
141
|
.split('\n')
|
|
142
142
|
.filter(
|
|
143
|
-
(line) => !(line === '> <run-results>' && input.runResults.length === 0),
|
|
143
|
+
(line) => !(line === '> Run results: <run-results>' && input.runResults.length === 0),
|
|
144
144
|
)
|
|
145
145
|
.join('\n');
|
|
146
146
|
const body = template.replace(
|
|
@@ -153,21 +153,10 @@ function composePlayerPrompt(
|
|
|
153
153
|
if (typeof value !== 'string') return match;
|
|
154
154
|
const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
|
|
155
155
|
const literal = source.slice(lineStart, offset);
|
|
156
|
-
return literal
|
|
156
|
+
return literal.startsWith('> ') ? quoteContinuation(value) : value;
|
|
157
157
|
},
|
|
158
158
|
);
|
|
159
|
-
|
|
160
|
-
input.pendingBossQuestion === undefined ||
|
|
161
|
-
input.bossReply === undefined
|
|
162
|
-
) {
|
|
163
|
-
return body;
|
|
164
|
-
}
|
|
165
|
-
return [
|
|
166
|
-
CONTINUATION_PREAMBLE,
|
|
167
|
-
`Boss question:\n${input.pendingBossQuestion.question}`,
|
|
168
|
-
`Boss reply:\n${input.bossReply}`,
|
|
169
|
-
body,
|
|
170
|
-
].join('\n\n');
|
|
159
|
+
return composePlayerContinuation(input, body, resuming);
|
|
171
160
|
}
|
|
172
161
|
|
|
173
162
|
export const _internal = {
|
|
@@ -273,7 +262,8 @@ const runtimeSpec = {
|
|
|
273
262
|
composePlayerPrompt: (
|
|
274
263
|
input: PlaybookPlayerInput,
|
|
275
264
|
promptIdentity: XStatePromptIdentity,
|
|
276
|
-
|
|
265
|
+
resuming?: boolean,
|
|
266
|
+
) => composePlayerPrompt(input as PlayerInput, promptIdentity, resuming),
|
|
277
267
|
verbatimPayloadFields: VERBATIM_PAYLOAD_FIELDS,
|
|
278
268
|
controlContextFields: ['phase'],
|
|
279
269
|
unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
|
|
@@ -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>>;
|
|
@@ -949,6 +949,7 @@ function normalizeHostPlayerResult(value, expectedPlayerId) {
|
|
|
949
949
|
'resumeToken',
|
|
950
950
|
'finalText',
|
|
951
951
|
'error',
|
|
952
|
+
'errorCode',
|
|
952
953
|
]);
|
|
953
954
|
const normalized = {};
|
|
954
955
|
for (const key of Reflect.ownKeys(descriptors)) {
|
|
@@ -970,11 +971,16 @@ function normalizeHostPlayerResult(value, expectedPlayerId) {
|
|
|
970
971
|
normalized[key] = descriptor.value;
|
|
971
972
|
}
|
|
972
973
|
const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
|
|
973
|
-
rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'], path);
|
|
974
|
+
rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error', 'errorCode'], path);
|
|
974
975
|
if (record.playerId !== expectedPlayerId) {
|
|
975
976
|
throw new TypeError(`${path}.playerId does not match the requested player`);
|
|
976
977
|
}
|
|
977
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
|
+
}
|
|
978
984
|
return validatePlayerResult({
|
|
979
985
|
status: record.status,
|
|
980
986
|
...(record.resumeToken === undefined
|
|
@@ -1629,6 +1635,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1629
1635
|
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
1630
1636
|
const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
1631
1637
|
const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
|
|
1638
|
+
const continuity = deps.continuity;
|
|
1632
1639
|
let pendingHostCapabilities = deps.hostCapabilities;
|
|
1633
1640
|
let currentEffectLedger = () => emptyPlaybookEffectLedger();
|
|
1634
1641
|
// The returned shell must not retain the caller's aggregate dependency
|
|
@@ -2592,6 +2599,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2592
2599
|
if (!ledger) {
|
|
2593
2600
|
throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
|
|
2594
2601
|
}
|
|
2602
|
+
if (options.freshPrompt !== undefined && typeof options.freshPrompt !== 'string') {
|
|
2603
|
+
throw new TypeError('player freshPrompt must be a string');
|
|
2604
|
+
}
|
|
2595
2605
|
const expectedResume = ledger.resumeToken ?? false;
|
|
2596
2606
|
if (options.resume !== expectedResume) {
|
|
2597
2607
|
throw new Error(`${frameLabel(frame)} player continuation changed before dispatch`);
|
|
@@ -2615,11 +2625,32 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2615
2625
|
try {
|
|
2616
2626
|
let rawResult;
|
|
2617
2627
|
try {
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2628
|
+
const call = async (resume) => {
|
|
2629
|
+
await continuity?.beforeCall(binding.playerId);
|
|
2630
|
+
signal.throwIfAborted();
|
|
2631
|
+
const raw = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings })));
|
|
2632
|
+
hostResolved = true;
|
|
2633
|
+
return raw;
|
|
2634
|
+
};
|
|
2635
|
+
if (options.resume === false) {
|
|
2636
|
+
await continuity?.reset(binding.playerId, 'missing_hint');
|
|
2637
|
+
}
|
|
2638
|
+
rawResult = await call(options.resume);
|
|
2639
|
+
normalizeHostPlayerResult(rawResult, binding.playerId);
|
|
2640
|
+
if (typeof options.resume === 'string' && options.resume.length > 0 &&
|
|
2641
|
+
Object.getOwnPropertyDescriptor(rawResult, 'errorCode')?.value ===
|
|
2642
|
+
'SESSION_RESUME_REJECTED') {
|
|
2643
|
+
if (playerTransactions.get(binding.playerId) !== calling ||
|
|
2644
|
+
calling.abandoned || signal.aborted || activeTurn !== admittedTurn ||
|
|
2645
|
+
frame.playerCallScope !== scope || !frames.includes(frame)) {
|
|
2646
|
+
signal.throwIfAborted();
|
|
2647
|
+
throw new Error(`${frameLabel(frame)} player rejection arrived after its runtime operation ended`);
|
|
2648
|
+
}
|
|
2649
|
+
delete ledger.resumeToken;
|
|
2650
|
+
await continuity?.reset(binding.playerId, 'rejected_hint');
|
|
2651
|
+
hostResolved = false;
|
|
2652
|
+
rawResult = await call(false);
|
|
2653
|
+
}
|
|
2623
2654
|
}
|
|
2624
2655
|
catch (error) {
|
|
2625
2656
|
if (error instanceof AgentSettingsPreflightError) {
|
|
@@ -2914,6 +2945,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2914
2945
|
delete ledger.resumeToken;
|
|
2915
2946
|
else
|
|
2916
2947
|
ledger.resumeToken = resumeToken;
|
|
2948
|
+
if (pending.status === 'ok' && resumeToken !== undefined) {
|
|
2949
|
+
continuity?.acknowledged(binding.playerId, resumeToken);
|
|
2950
|
+
}
|
|
2917
2951
|
}
|
|
2918
2952
|
finally {
|
|
2919
2953
|
playerTransactions.delete(binding.playerId);
|
|
@@ -4109,7 +4143,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4109
4143
|
};
|
|
4110
4144
|
class CaptainContinuityError extends Error {
|
|
4111
4145
|
constructor(cause) {
|
|
4112
|
-
super('the session Captain conversation
|
|
4146
|
+
super('the session Captain conversation lost continuity', { cause });
|
|
4113
4147
|
this.name = 'CaptainContinuityError';
|
|
4114
4148
|
}
|
|
4115
4149
|
}
|
|
@@ -4121,6 +4155,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4121
4155
|
}
|
|
4122
4156
|
const rawDurableCall = async (context, prompt, resume, attempt) => {
|
|
4123
4157
|
const queued = captainQueue.add(async () => {
|
|
4158
|
+
context.signal.throwIfAborted();
|
|
4159
|
+
await continuity?.beforeCall('captain');
|
|
4124
4160
|
context.signal.throwIfAborted();
|
|
4125
4161
|
attempt.providerBoundaryEntered = true;
|
|
4126
4162
|
const result = await classifySettingsCall(() => context.callCaptain(prompt, {
|
|
@@ -4134,11 +4170,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4134
4170
|
});
|
|
4135
4171
|
return trackTurnCall(queued);
|
|
4136
4172
|
};
|
|
4137
|
-
//
|
|
4138
|
-
//
|
|
4139
|
-
// seeded with the reseed digest plus the current ControlView digest. A
|
|
4140
|
-
// conversation that is owed a reseed carries the digest on its very next
|
|
4141
|
-
// call, so the turn after a failed reseed starts seeded rather than blank.
|
|
4173
|
+
// Only proven pre-execution rejection permits an immediate fresh call.
|
|
4174
|
+
// Other continuity loss leaves the journal reseed for the next Boss turn.
|
|
4142
4175
|
const durableCall = async (context, compose) => {
|
|
4143
4176
|
const startingConversation = conversation;
|
|
4144
4177
|
const resume = startingConversation.kind === 'pinned'
|
|
@@ -4153,6 +4186,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4153
4186
|
let result;
|
|
4154
4187
|
let failure;
|
|
4155
4188
|
try {
|
|
4189
|
+
if (resume === false)
|
|
4190
|
+
await continuity?.reset('captain', 'missing_hint');
|
|
4156
4191
|
result = await rawDurableCall(context, compose(seedFirstCall
|
|
4157
4192
|
? { reseedDigest: reseedDigest() }
|
|
4158
4193
|
: startingConversation.kind === 'needsCatchUp'
|
|
@@ -4187,9 +4222,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4187
4222
|
const unsynchronized = failure !== undefined ||
|
|
4188
4223
|
result === undefined ||
|
|
4189
4224
|
result.status !== 'ok' ||
|
|
4190
|
-
result.resumeToken ===
|
|
4225
|
+
typeof result.resumeToken !== 'string' || result.resumeToken.trim().length === 0;
|
|
4191
4226
|
if (!unsynchronized) {
|
|
4192
4227
|
conversation = { kind: 'pinned', token: result.resumeToken };
|
|
4228
|
+
continuity?.acknowledged('captain', result.resumeToken);
|
|
4193
4229
|
if (activeTurn) {
|
|
4194
4230
|
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
4195
4231
|
}
|
|
@@ -4205,6 +4241,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4205
4241
|
// stays `needsSeeding` until a call comes back with a token, so a reseed
|
|
4206
4242
|
// that itself fails leaves the obligation standing for the next turn.
|
|
4207
4243
|
conversation = { kind: 'needsSeeding' };
|
|
4244
|
+
if (typeof resume !== 'string' || resume.length === 0 ||
|
|
4245
|
+
result?.status !== 'error' || result.errorCode !== 'SESSION_RESUME_REJECTED') {
|
|
4246
|
+
throw markControlFailure(new CaptainContinuityError(failure ?? result?.error ?? 'callCaptain did not establish continuation'));
|
|
4247
|
+
}
|
|
4248
|
+
await continuity?.reset('captain', 'rejected_hint');
|
|
4208
4249
|
const recap = reseedDigest();
|
|
4209
4250
|
let reissued;
|
|
4210
4251
|
const reissueAttempt = { providerBoundaryEntered: false };
|
|
@@ -4221,11 +4262,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4221
4262
|
}
|
|
4222
4263
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
4223
4264
|
}
|
|
4224
|
-
if (reissued.status !== 'ok' || reissued.resumeToken ===
|
|
4265
|
+
if (reissued.status !== 'ok' || typeof reissued.resumeToken !== 'string' || reissued.resumeToken.trim().length === 0) {
|
|
4225
4266
|
throw markControlFailure(new CaptainContinuityError(reissued.error ??
|
|
4226
4267
|
`callCaptain status "${reissued.status}" without a resume token`));
|
|
4227
4268
|
}
|
|
4228
4269
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
4270
|
+
continuity?.acknowledged('captain', reissued.resumeToken);
|
|
4229
4271
|
if (activeTurn)
|
|
4230
4272
|
activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
4231
4273
|
return {
|
|
@@ -103,6 +103,11 @@ type SnapshotAgentEnvelope = DeepReadonly<
|
|
|
103
103
|
type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
|
|
104
104
|
|
|
105
105
|
export interface PlaybookCaptainDeps {
|
|
106
|
+
continuity?: {
|
|
107
|
+
beforeCall(participantId: string): Promise<void>;
|
|
108
|
+
acknowledged(participantId: string, token: string): void;
|
|
109
|
+
reset(participantId: string, reason: 'missing_hint' | 'rejected_hint'): Promise<void>;
|
|
110
|
+
};
|
|
106
111
|
loadModule?: (specifier: string) => Promise<unknown>;
|
|
107
112
|
createSessionId?: () => string;
|
|
108
113
|
hostCapabilities?: Readonly<
|
|
@@ -1819,6 +1824,7 @@ function normalizeHostPlayerResult(
|
|
|
1819
1824
|
'resumeToken',
|
|
1820
1825
|
'finalText',
|
|
1821
1826
|
'error',
|
|
1827
|
+
'errorCode',
|
|
1822
1828
|
]);
|
|
1823
1829
|
const normalized: Record<string, unknown> = {};
|
|
1824
1830
|
for (const key of Reflect.ownKeys(descriptors)) {
|
|
@@ -1841,13 +1847,18 @@ function normalizeHostPlayerResult(
|
|
|
1841
1847
|
const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
|
|
1842
1848
|
rejectSnapshotKeys(
|
|
1843
1849
|
record,
|
|
1844
|
-
['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'],
|
|
1850
|
+
['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error', 'errorCode'],
|
|
1845
1851
|
path,
|
|
1846
1852
|
);
|
|
1847
1853
|
if (record.playerId !== expectedPlayerId) {
|
|
1848
1854
|
throw new TypeError(`${path}.playerId does not match the requested player`);
|
|
1849
1855
|
}
|
|
1850
1856
|
snapshotInteger(record.turnId, `${path}.turnId`, 1);
|
|
1857
|
+
if (record.errorCode !== undefined &&
|
|
1858
|
+
(record.errorCode !== 'SESSION_RESUME_REJECTED' ||
|
|
1859
|
+
record.status !== 'error' || record.resumeToken !== undefined)) {
|
|
1860
|
+
throw new TypeError(`${path}.errorCode is not a definite resume rejection`);
|
|
1861
|
+
}
|
|
1851
1862
|
return validatePlayerResult(
|
|
1852
1863
|
{
|
|
1853
1864
|
status: record.status,
|
|
@@ -2988,6 +2999,7 @@ export function createPlaybookCaptainShell(
|
|
|
2988
2999
|
PlaybookCaptainDeps['createCaptainRuntime']
|
|
2989
3000
|
> = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
2990
3001
|
const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
|
|
3002
|
+
const continuity = deps.continuity;
|
|
2991
3003
|
let pendingHostCapabilities = deps.hostCapabilities;
|
|
2992
3004
|
let currentEffectLedger = () => emptyPlaybookEffectLedger();
|
|
2993
3005
|
// The returned shell must not retain the caller's aggregate dependency
|
|
@@ -4349,6 +4361,9 @@ export function createPlaybookCaptainShell(
|
|
|
4349
4361
|
`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
|
|
4350
4362
|
);
|
|
4351
4363
|
}
|
|
4364
|
+
if (options.freshPrompt !== undefined && typeof options.freshPrompt !== 'string') {
|
|
4365
|
+
throw new TypeError('player freshPrompt must be a string');
|
|
4366
|
+
}
|
|
4352
4367
|
const expectedResume = ledger.resumeToken ?? false;
|
|
4353
4368
|
if (options.resume !== expectedResume) {
|
|
4354
4369
|
throw new Error(
|
|
@@ -4376,16 +4391,37 @@ export function createPlaybookCaptainShell(
|
|
|
4376
4391
|
try {
|
|
4377
4392
|
let rawResult: unknown;
|
|
4378
4393
|
try {
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4394
|
+
const call = async (resume: string | false): Promise<unknown> => {
|
|
4395
|
+
await continuity?.beforeCall(binding.playerId);
|
|
4396
|
+
signal.throwIfAborted();
|
|
4397
|
+
const raw = await trackHostCall(
|
|
4398
|
+
frame,
|
|
4399
|
+
classifySettingsCall(() =>
|
|
4400
|
+
context.callPlayer(binding.playerId, resume === false ? options.freshPrompt ?? prompt : prompt, { resume, settings }),
|
|
4401
|
+
),
|
|
4402
|
+
);
|
|
4403
|
+
hostResolved = true;
|
|
4404
|
+
return raw;
|
|
4405
|
+
};
|
|
4406
|
+
if (options.resume === false) {
|
|
4407
|
+
await continuity?.reset(binding.playerId, 'missing_hint');
|
|
4408
|
+
}
|
|
4409
|
+
rawResult = await call(options.resume);
|
|
4410
|
+
normalizeHostPlayerResult(rawResult, binding.playerId);
|
|
4411
|
+
if (typeof options.resume === 'string' && options.resume.length > 0 &&
|
|
4412
|
+
Object.getOwnPropertyDescriptor(rawResult as object, 'errorCode')?.value ===
|
|
4413
|
+
'SESSION_RESUME_REJECTED') {
|
|
4414
|
+
if (playerTransactions.get(binding.playerId) !== calling ||
|
|
4415
|
+
calling.abandoned || signal.aborted || activeTurn !== admittedTurn ||
|
|
4416
|
+
frame.playerCallScope !== scope || !frames.includes(frame)) {
|
|
4417
|
+
signal.throwIfAborted();
|
|
4418
|
+
throw new Error(`${frameLabel(frame)} player rejection arrived after its runtime operation ended`);
|
|
4419
|
+
}
|
|
4420
|
+
delete ledger.resumeToken;
|
|
4421
|
+
await continuity?.reset(binding.playerId, 'rejected_hint');
|
|
4422
|
+
hostResolved = false;
|
|
4423
|
+
rawResult = await call(false);
|
|
4424
|
+
}
|
|
4389
4425
|
} catch (error) {
|
|
4390
4426
|
if (error instanceof AgentSettingsPreflightError) {
|
|
4391
4427
|
if (
|
|
@@ -4739,6 +4775,9 @@ export function createPlaybookCaptainShell(
|
|
|
4739
4775
|
try {
|
|
4740
4776
|
if (resumeToken === undefined) delete ledger.resumeToken;
|
|
4741
4777
|
else ledger.resumeToken = resumeToken;
|
|
4778
|
+
if (pending.status === 'ok' && resumeToken !== undefined) {
|
|
4779
|
+
continuity?.acknowledged(binding.playerId, resumeToken);
|
|
4780
|
+
}
|
|
4742
4781
|
} finally {
|
|
4743
4782
|
playerTransactions.delete(binding.playerId);
|
|
4744
4783
|
}
|
|
@@ -6161,7 +6200,7 @@ export function createPlaybookCaptainShell(
|
|
|
6161
6200
|
class CaptainContinuityError extends Error {
|
|
6162
6201
|
constructor(cause: unknown) {
|
|
6163
6202
|
super(
|
|
6164
|
-
'the session Captain conversation
|
|
6203
|
+
'the session Captain conversation lost continuity',
|
|
6165
6204
|
{ cause },
|
|
6166
6205
|
);
|
|
6167
6206
|
this.name = 'CaptainContinuityError';
|
|
@@ -6185,8 +6224,11 @@ export function createPlaybookCaptainShell(
|
|
|
6185
6224
|
finalText?: string;
|
|
6186
6225
|
resumeToken?: string;
|
|
6187
6226
|
error?: string;
|
|
6227
|
+
errorCode?: 'SESSION_RESUME_REJECTED';
|
|
6188
6228
|
}> => {
|
|
6189
6229
|
const queued = captainQueue.add(async () => {
|
|
6230
|
+
context.signal.throwIfAborted();
|
|
6231
|
+
await continuity?.beforeCall('captain');
|
|
6190
6232
|
context.signal.throwIfAborted();
|
|
6191
6233
|
attempt.providerBoundaryEntered = true;
|
|
6192
6234
|
const result = await classifySettingsCall(() =>
|
|
@@ -6205,14 +6247,12 @@ export function createPlaybookCaptainShell(
|
|
|
6205
6247
|
finalText?: string;
|
|
6206
6248
|
resumeToken?: string;
|
|
6207
6249
|
error?: string;
|
|
6250
|
+
errorCode?: 'SESSION_RESUME_REJECTED';
|
|
6208
6251
|
}>;
|
|
6209
6252
|
};
|
|
6210
6253
|
|
|
6211
|
-
//
|
|
6212
|
-
//
|
|
6213
|
-
// seeded with the reseed digest plus the current ControlView digest. A
|
|
6214
|
-
// conversation that is owed a reseed carries the digest on its very next
|
|
6215
|
-
// call, so the turn after a failed reseed starts seeded rather than blank.
|
|
6254
|
+
// Only proven pre-execution rejection permits an immediate fresh call.
|
|
6255
|
+
// Other continuity loss leaves the journal reseed for the next Boss turn.
|
|
6216
6256
|
const durableCall = async (
|
|
6217
6257
|
context: CaptainContext,
|
|
6218
6258
|
compose: (options: { reseedDigest?: string }) => string,
|
|
@@ -6229,10 +6269,11 @@ export function createPlaybookCaptainShell(
|
|
|
6229
6269
|
const representedJournalSeq = journalSeq;
|
|
6230
6270
|
const firstAttempt = { providerBoundaryEntered: false };
|
|
6231
6271
|
let result:
|
|
6232
|
-
| { status: string; finalText?: string; resumeToken?: string; error?: string }
|
|
6272
|
+
| { status: string; finalText?: string; resumeToken?: string; error?: string; errorCode?: 'SESSION_RESUME_REJECTED' }
|
|
6233
6273
|
| undefined;
|
|
6234
6274
|
let failure: unknown;
|
|
6235
6275
|
try {
|
|
6276
|
+
if (resume === false) await continuity?.reset('captain', 'missing_hint');
|
|
6236
6277
|
result = await rawDurableCall(
|
|
6237
6278
|
context,
|
|
6238
6279
|
compose(
|
|
@@ -6279,9 +6320,10 @@ export function createPlaybookCaptainShell(
|
|
|
6279
6320
|
failure !== undefined ||
|
|
6280
6321
|
result === undefined ||
|
|
6281
6322
|
result.status !== 'ok' ||
|
|
6282
|
-
result.resumeToken ===
|
|
6323
|
+
typeof result.resumeToken !== 'string' || result.resumeToken.trim().length === 0;
|
|
6283
6324
|
if (!unsynchronized) {
|
|
6284
6325
|
conversation = { kind: 'pinned', token: result!.resumeToken! };
|
|
6326
|
+
continuity?.acknowledged('captain', result!.resumeToken!);
|
|
6285
6327
|
if (activeTurn) {
|
|
6286
6328
|
activeTurn.captainSyncedJournalSeq = representedJournalSeq;
|
|
6287
6329
|
}
|
|
@@ -6297,9 +6339,16 @@ export function createPlaybookCaptainShell(
|
|
|
6297
6339
|
// stays `needsSeeding` until a call comes back with a token, so a reseed
|
|
6298
6340
|
// that itself fails leaves the obligation standing for the next turn.
|
|
6299
6341
|
conversation = { kind: 'needsSeeding' };
|
|
6342
|
+
if (typeof resume !== 'string' || resume.length === 0 ||
|
|
6343
|
+
result?.status !== 'error' || result.errorCode !== 'SESSION_RESUME_REJECTED') {
|
|
6344
|
+
throw markControlFailure(new CaptainContinuityError(
|
|
6345
|
+
failure ?? result?.error ?? 'callCaptain did not establish continuation',
|
|
6346
|
+
));
|
|
6347
|
+
}
|
|
6348
|
+
await continuity?.reset('captain', 'rejected_hint');
|
|
6300
6349
|
const recap = reseedDigest();
|
|
6301
6350
|
let reissued:
|
|
6302
|
-
| { status: string; finalText?: string; resumeToken?: string; error?: string }
|
|
6351
|
+
| { status: string; finalText?: string; resumeToken?: string; error?: string; errorCode?: 'SESSION_RESUME_REJECTED' }
|
|
6303
6352
|
| undefined;
|
|
6304
6353
|
const reissueAttempt = { providerBoundaryEntered: false };
|
|
6305
6354
|
try {
|
|
@@ -6319,7 +6368,7 @@ export function createPlaybookCaptainShell(
|
|
|
6319
6368
|
}
|
|
6320
6369
|
throw markControlFailure(new CaptainContinuityError(error));
|
|
6321
6370
|
}
|
|
6322
|
-
if (reissued.status !== 'ok' || reissued.resumeToken ===
|
|
6371
|
+
if (reissued.status !== 'ok' || typeof reissued.resumeToken !== 'string' || reissued.resumeToken.trim().length === 0) {
|
|
6323
6372
|
throw markControlFailure(
|
|
6324
6373
|
new CaptainContinuityError(
|
|
6325
6374
|
reissued.error ??
|
|
@@ -6328,6 +6377,7 @@ export function createPlaybookCaptainShell(
|
|
|
6328
6377
|
);
|
|
6329
6378
|
}
|
|
6330
6379
|
conversation = { kind: 'pinned', token: reissued.resumeToken };
|
|
6380
|
+
continuity?.acknowledged('captain', reissued.resumeToken);
|
|
6331
6381
|
if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
|
|
6332
6382
|
return {
|
|
6333
6383
|
...(reissued.finalText !== undefined
|