@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.
Files changed (38) hide show
  1. package/docs/cli.md +56 -52
  2. package/docs/configuration.md +2 -2
  3. package/docs/embedding.md +68 -32
  4. package/package.json +11 -3
  5. package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
  6. package/reference/sdlc/code.playbook/bin/launch-config.js +13 -7
  7. package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
  8. package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
  9. package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
  10. package/reference/sdlc/code.playbook/bin/run.js +62 -18
  11. package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
  12. package/reference/sdlc/code.playbook/bin/session-store.js +638 -70
  13. package/reference/sdlc/code.playbook/code.fsm.js +43 -5
  14. package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
  15. package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
  16. package/reference/sdlc/code.playbook/playbook-captain.js +75 -19
  17. package/reference/sdlc/code.playbook/playbook-captain.ts +93 -26
  18. package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
  19. package/reference/sdlc/code.playbook/session-host.js +18 -0
  20. package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
  21. package/reference/sdlc/code.playbook/session-store.js +22 -6
  22. package/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
  23. package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
  24. package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
  25. package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
  26. package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
  27. package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
  28. package/reference/sdlc/review.playbook/review.fsm.js +11 -1
  29. package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
  30. package/slc/gears2fsm.md +21 -3
  31. package/slc/link.md +21 -2
  32. package/src/runtime.d.ts +7 -0
  33. package/src/runtime.ts +12 -0
  34. package/src/xstate-playbook-runtime.d.ts +13 -0
  35. package/src/xstate-playbook-runtime.js +75 -13
  36. package/src/xstate-playbook-runtime.ts +87 -21
  37. package/src/xstate-runtime.js +35 -4
  38. 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' && result.status !== 'error') ||
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?.error !== undefined)
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: playbookMeta('reportedReviewFailure'),
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: playbookMeta('done'),
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
- readonly status: 'aborted' | 'error';
424
- readonly error?: CompactError;
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' && result.status !== 'error') ||
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?.error !== undefined) return failure.error;
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: playbookMeta('reportedReviewFailure'),
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: playbookMeta('done'),
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 ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
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) => typeof role !== 'string' ||
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 (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
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
- rawResult = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, prompt, {
2608
- resume: options.resume,
2609
- settings,
2610
- })));
2611
- hostResolved = true;
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 could not be resynchronized after one reseeded re-issue', { cause });
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
- // CAPTAIN-35: unsynchronized when the call throws, returns non-`ok`, or
4121
- // returns `ok` without a token. Exactly one re-issue on a fresh conversation
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 === undefined;
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 === undefined) {
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 {