narraeon 0.5.7 → 0.6.1

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 (62) hide show
  1. package/README.md +13 -0
  2. package/README.zh-CN.md +10 -0
  3. package/dist/node/protocol/gameplay.js +69 -0
  4. package/dist/node/protocol/resolution.js +274 -0
  5. package/dist/node/protocol/v1.js +15 -2
  6. package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
  7. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  8. package/dist/node/runtime/V1Runtime.js +58 -7
  9. package/dist/node/runtime/archive/WorldArchives.js +14 -46
  10. package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
  11. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
  12. package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
  13. package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
  14. package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
  15. package/dist/node/runtime/config/dataRootLock.js +108 -0
  16. package/dist/node/runtime/content/ContentWorkspace.js +10 -12
  17. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
  18. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  19. package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
  20. package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
  21. package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
  22. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
  23. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
  24. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  25. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
  26. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  27. package/dist/node/runtime/play/PlayCallChain.js +359 -359
  28. package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
  29. package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
  30. package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
  31. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  32. package/dist/node/runtime/play/TabletopRules.js +141 -0
  33. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
  34. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
  35. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
  36. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  37. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  38. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
  39. package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
  40. package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
  41. package/dist/node/runtime/world/WorldActivity.js +14 -22
  42. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
  43. package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
  44. package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
  45. package/dist/node/runtime/world/WorldQueue.js +46 -0
  46. package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
  47. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
  48. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  49. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  50. package/dist/node/server/ConversationStream.js +2 -0
  51. package/dist/node/server/mobile.js +20 -0
  52. package/dist/node/server/startWebServer.js +15 -8
  53. package/dist/node/shared/ordered-author-prompts.js +8 -0
  54. package/dist/node/shared/ordered-play-prompts.js +23 -0
  55. package/dist/node/shared/tabletop-guidance.js +100 -0
  56. package/dist/web/assets/index-7GzLSUpU.js +1323 -0
  57. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  58. package/dist/web/index.html +2 -2
  59. package/package.json +2 -1
  60. package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
  61. package/dist/web/assets/index-DxNtxzLR.js +0 -1255
  62. package/dist/web/assets/index-DzOnu9uv.css +0 -1
@@ -1,5 +1,6 @@
1
1
  import { projectLegacyModelBindingFailure } from "./LegacyModelBindingFailure.js";
2
2
  import { classifyPlayFailure } from "./PlayFailure.js";
3
+ import { isResolutionTool, resolutionMarkdown, resolutionToolNames, resolveTabletop, seededRandom, } from "./TabletopResolution.js";
3
4
  import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
4
5
  import { isDeepStrictEqual } from "node:util";
5
6
  import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
@@ -7,7 +8,7 @@ import { PlayCallChainError } from "./PlayCallChainError.js";
7
8
  import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, independentContextCopy, } from "./PlayTraceRetention.js";
8
9
  import { currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
9
10
  import { comparePromptPrefixes } from "../prompt/WorldPromptDiagnostics.js";
10
- import { createHash } from "node:crypto";
11
+ import { createHash, randomBytes } from "node:crypto";
11
12
  import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, } from "./PlayContinuity.js";
12
13
  import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
13
14
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
@@ -18,6 +19,7 @@ import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
18
19
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
19
20
  import { FileNativePlayDocuments, fingerprintControl, } from "./PlayDocumentTools.js";
20
21
  const callChainToolNames = new Set([
22
+ ...resolutionToolNames,
21
23
  "state_list",
22
24
  "history_list",
23
25
  "history_search",
@@ -32,6 +34,24 @@ const callChainToolNames = new Set([
32
34
  "world_checkpoint",
33
35
  ]);
34
36
  const projectedEventTail = 40;
37
+ function isPlayerRevisionIntent(value) {
38
+ if (typeof value !== "object" || value === null)
39
+ return false;
40
+ const record = value;
41
+ return (record.schemaVersion === 1 &&
42
+ record.kind === "player_revision_intent" &&
43
+ typeof record.operationId === "string" &&
44
+ typeof record.worldId === "string" &&
45
+ typeof record.chainId === "string" &&
46
+ Number.isSafeInteger(record.eventId) &&
47
+ typeof record.replacementExchangeId === "string" &&
48
+ typeof record.replacementText === "string" &&
49
+ (record.continuation === "continue_context" ||
50
+ record.continuation === "fresh_context") &&
51
+ typeof record.restoresHead === "string" &&
52
+ (record.continuation === "continue_context" ||
53
+ typeof record.freshContext === "object"));
54
+ }
35
55
  export { PlayCallChainError } from "./PlayCallChainError.js";
36
56
  /**
37
57
  * The production play loop: one durable logical context, no Runtime-authored
@@ -48,8 +68,12 @@ export class PlayCallChain {
48
68
  #failureLog;
49
69
  #active = new Map();
50
70
  #worldChains = new Map();
71
+ /** One in-flight disk hydration per world; concurrent callers share it. */
72
+ #hydrating = new Map();
51
73
  #loadedCursors = new Map();
52
74
  #activeInvocations = new Map();
75
+ /** Resolves when the matching invocation has left the active table. */
76
+ #invocationSettled = new Map();
53
77
  constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog, changed = () => {
54
78
  /* No observers attached. */
55
79
  }) {
@@ -134,12 +158,6 @@ export class PlayCallChain {
134
158
  transcript: [],
135
159
  events: [],
136
160
  completedTools: [],
137
- documentAuthorizationCheckpoints: [
138
- {
139
- afterEventId: 0,
140
- authorization: documents.authorizationCheckpoint(),
141
- },
142
- ],
143
161
  changedDocuments: [],
144
162
  nextMaterials: structuredClone(binding.additionalMaterials),
145
163
  nextEventId: 1,
@@ -156,7 +174,6 @@ export class PlayCallChain {
156
174
  eventCount: 0,
157
175
  transcriptCount: 0,
158
176
  completedToolCount: 0,
159
- authorizationCheckpointCount: 0,
160
177
  },
161
178
  };
162
179
  if (activeId !== undefined)
@@ -256,12 +273,7 @@ export class PlayCallChain {
256
273
  playPresetScriptsEnabled: playPreset.scriptsEnabled,
257
274
  presetFiles: structuredClone(playPreset.files),
258
275
  });
259
- session.documentAuthorizationCheckpoints.push({
260
- afterEventId: session.nextEventId,
261
- authorization: session.documents.authorizationCheckpoint(),
262
- });
263
276
  await this.#persist(session);
264
- crashAtPlayAdvanceEdge("after_prompt_run_prepared");
265
277
  }
266
278
  cancel(input) {
267
279
  validateIdentity(input.worldId, "world ID");
@@ -287,6 +299,10 @@ export class PlayCallChain {
287
299
  [...this.#activeInvocations.values()].some(({ worldId }) => worldId === input.worldId))
288
300
  throw new PlayCallChainError("A model request is already running for this world or call chain.");
289
301
  const startedAt = Date.now();
302
+ let markSettled;
303
+ const settled = new Promise((resolve) => {
304
+ markSettled = resolve;
305
+ });
290
306
  const invocation = {
291
307
  worldId: input.worldId,
292
308
  chainId: input.chainId,
@@ -305,6 +321,7 @@ export class PlayCallChain {
305
321
  const controlUseId = operationId(input.chainId, `control-use:${input.exchangeId}`);
306
322
  this.#worlds.freezeControl(input.worldId, controlUseId);
307
323
  this.#activeInvocations.set(input.chainId, invocation);
324
+ this.#invocationSettled.set(input.chainId, settled);
308
325
  this.changed(input.worldId);
309
326
  try {
310
327
  await this.#worlds.assertWorldRevisionUnlocked(input.worldId);
@@ -315,6 +332,8 @@ export class PlayCallChain {
315
332
  this.#activeInvocations.delete(input.chainId);
316
333
  this.#worlds.releaseControl(input.worldId, controlUseId);
317
334
  this.changed(input.worldId);
335
+ this.#invocationSettled.delete(input.chainId);
336
+ markSettled();
318
337
  }
319
338
  }
320
339
  #observeInvocation(chainId, phase, increments = {}) {
@@ -376,6 +395,14 @@ export class PlayCallChain {
376
395
  return view;
377
396
  }
378
397
  async #worldSession(worldId) {
398
+ return this.#loadWorldSession(worldId);
399
+ }
400
+ /**
401
+ * Returns the live session for a world, hydrating it from disk at most once
402
+ * at a time. Concurrent callers share one hydration, so a session object is
403
+ * never replaced by a stale copy that finished hydrating later.
404
+ */
405
+ async #loadWorldSession(worldId) {
379
406
  const activeId = this.#worldChains.get(worldId);
380
407
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
381
408
  if (active !== undefined) {
@@ -383,17 +410,38 @@ export class PlayCallChain {
383
410
  await this.#reconcileSessionAdvance(active);
384
411
  return active;
385
412
  }
413
+ const pending = this.#hydrating.get(worldId);
414
+ if (pending !== undefined)
415
+ return pending.session;
416
+ const token = Symbol(worldId);
417
+ const session = this.#hydrateWorldSession(worldId, token);
418
+ this.#hydrating.set(worldId, { token, session });
419
+ try {
420
+ return await session;
421
+ }
422
+ finally {
423
+ if (this.#hydrating.get(worldId)?.token === token)
424
+ this.#hydrating.delete(worldId);
425
+ }
426
+ }
427
+ async #hydrateWorldSession(worldId, token) {
428
+ await this.#recoverPlayerRevision(worldId);
386
429
  const persisted = await this.#readPersisted(worldId);
387
430
  if (persisted === null)
388
431
  return null;
389
432
  const advance = await this.#worlds.playAdvances.readCurrent(worldId, persisted.chainId);
390
- const session = await this.#hydrateSession(persisted, advance);
391
- this.#active.set(session.chainId, session);
392
- this.#worldChains.set(worldId, session.chainId);
393
- await this.#reconcileSessionAdvance(session, advance);
394
- if (session.status === "running")
395
- await this.#interruptAbandonedDispatch(session);
396
- return session;
433
+ const hydrated = await this.#hydrateSession(persisted, advance);
434
+ // A live session registered while this disk read was in flight wins.
435
+ const live = this.#active.get(hydrated.chainId);
436
+ if (live !== undefined)
437
+ return live;
438
+ // forgetWorld ran during hydration; do not resurrect the old session.
439
+ if (this.#hydrating.get(worldId)?.token !== token)
440
+ return null;
441
+ this.#active.set(hydrated.chainId, hydrated);
442
+ this.#worldChains.set(worldId, hydrated.chainId);
443
+ await this.#reconcileSessionAdvance(hydrated, advance);
444
+ return hydrated;
397
445
  }
398
446
  async inspectReading(worldId, worldHead) {
399
447
  const view = await this.inspectWorld(worldId);
@@ -565,9 +613,10 @@ export class PlayCallChain {
565
613
  });
566
614
  }
567
615
  /**
568
- * Revise one committed player message in place. Authority remains
569
- * append-only: one timeline-revision commit restores the player's logical
570
- * parent snapshot and appends the replacement text atomically.
616
+ * Edit one committed player message as a rewind followed by an ordinary
617
+ * submission (ADR-0044, ADR-0045). The intent is written before the first
618
+ * world change; every later step is repeatable, so an exit at any point is
619
+ * completed by the same path on the next load.
571
620
  */
572
621
  async revisePlayer(input) {
573
622
  validateIdentity(input.operationId, "timeline-revision operation ID");
@@ -581,173 +630,229 @@ export class PlayCallChain {
581
630
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
582
631
  if (active?.status === "running")
583
632
  throw new PlayCallChainError("A model request is still running; wait for it to return before revising committed history.");
633
+ await this.#recoverPlayerRevision(input.worldId);
584
634
  const source = await this.#readPersisted(input.worldId);
585
635
  if (source === null)
586
636
  throw new PlayCallChainError("The current world has no model call chain to revise.");
587
- const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.worldId);
588
- const appliedContext = sourceContexts.find(({ events }) => events.some((event) => event.kind === "player" &&
589
- event.exchangeId === input.replacementExchangeId &&
590
- event.committedHead !== undefined));
591
- const appliedEvent = appliedContext?.events.find((event) => event.kind === "player" &&
637
+ const contexts = await this.#worlds.playTimeline.readAllContexts(input.worldId);
638
+ // A replacement exchange that already reached the timeline is a repeat of
639
+ // the same request: answer with the current view and do not redo it.
640
+ const applied = contexts
641
+ .flatMap(({ events }) => events)
642
+ .find((event) => event.kind === "player" &&
592
643
  event.exchangeId === input.replacementExchangeId &&
593
644
  event.committedHead !== undefined);
594
- if (appliedEvent !== undefined) {
595
- if (appliedEvent.text !== input.replacementText)
645
+ if (applied !== undefined) {
646
+ if (applied.kind === "player" && applied.text !== input.replacementText)
596
647
  throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
597
- const requestedSource = await this.#worlds.playTimeline.readContext(input.worldId, input.chainId);
598
- const requestedEventIndex = requestedSource?.value.events.findIndex(({ id }) => id === input.eventId);
599
- const requestedEvent = requestedEventIndex === undefined || requestedEventIndex < 0
600
- ? undefined
601
- : requestedSource?.value.events[requestedEventIndex];
602
- if (requestedSource === null ||
603
- requestedEventIndex === undefined ||
604
- requestedEventIndex < 0 ||
605
- requestedEvent?.kind !== "player" ||
606
- requestedEvent.committedHead === undefined)
607
- throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
608
- const requestedRestoresHead = playerRevisionRestoresHead(requestedSource.value, requestedEventIndex);
609
- const authority = await this.#worlds.readAuthorityHistory(input.worldId);
610
- const commit = authority.commits.find(({ head }) => head === appliedEvent.committedHead);
611
- const operation = await this.#worlds.getOperationOutcome(input.operationId);
612
- if (commit?.mode !== "timeline_revision" ||
613
- (operation.outcome !== "committed" &&
614
- operation.outcome !== "committed_materialization_pending") ||
615
- operation.worldId !== input.worldId ||
616
- operation.head !== appliedEvent.committedHead ||
617
- commit.timelineRevision?.restoresHead !== requestedRestoresHead ||
618
- commit.timelineRevision.replacesHead !== requestedEvent.committedHead ||
619
- commit.timelineRevision.requestFingerprint !==
620
- playerRevisionRequestFingerprint(input, requestedEvent.committedHead, requestedRestoresHead))
621
- throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
622
648
  return {
623
649
  outcome: "revised",
624
650
  worldId: input.worldId,
625
651
  playCallChain: projectView(source),
626
652
  };
627
653
  }
628
- const selectedContextIndex = sourceContexts.findIndex(({ chainId }) => chainId === input.chainId);
629
- if (selectedContextIndex < 0)
654
+ const contextIndex = contexts.findIndex(({ chainId }) => chainId === input.chainId);
655
+ if (contextIndex < 0)
630
656
  throw new PlayCallChainError("The model context to revise does not exist.");
631
- const sourceContext = sourceContexts[selectedContextIndex];
657
+ const sourceContext = contexts[contextIndex];
632
658
  if (input.continuation === "continue_context" &&
633
659
  sourceContext.importedFromArchive)
634
660
  throw new PlayCallChainError("This model context was imported from a backup; start a fresh context before continuing.");
635
- const selectedEventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
636
- const selectedEvent = sourceContext.events[selectedEventIndex];
637
- if (selectedEventIndex < 0 ||
661
+ const eventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
662
+ const selectedEvent = sourceContext.events[eventIndex];
663
+ if (eventIndex < 0 ||
638
664
  selectedEvent?.kind !== "player" ||
639
665
  selectedEvent.committedHead === undefined)
640
666
  throw new PlayCallChainError("The selected event is not a committed player message.");
641
- return this.#applyPlayerRevision({
642
- request: input,
643
- source,
644
- sourceContexts,
645
- selectedContextIndex,
646
- selectedEventIndex,
647
- selectedEvent: {
648
- ...selectedEvent,
649
- committedHead: selectedEvent.committedHead,
650
- },
667
+ const intent = {
668
+ schemaVersion: 1,
669
+ kind: "player_revision_intent",
670
+ operationId: input.operationId,
671
+ worldId: input.worldId,
672
+ chainId: input.chainId,
673
+ eventId: input.eventId,
674
+ replacementExchangeId: input.replacementExchangeId,
675
+ replacementText: input.replacementText,
676
+ continuation: input.continuation,
677
+ restoresHead: playerRevisionRestoresHead(sourceContext, eventIndex),
678
+ discardChainIds: contexts
679
+ .slice(contextIndex)
680
+ .map(({ chainId }) => chainId),
681
+ ...(input.continuation === "fresh_context"
682
+ ? { freshContext: structuredClone(input.freshContext) }
683
+ : {}),
684
+ };
685
+ await this.#worlds.writePlayerRevisionIntent(input.worldId, intent);
686
+ const revised = await this.#completePlayerRevision(intent);
687
+ return {
688
+ outcome: "revised",
689
+ worldId: input.worldId,
690
+ playCallChain: projectView(revised),
691
+ };
692
+ }
693
+ /** Complete an interrupted revision recorded by a previous process. */
694
+ async #recoverPlayerRevision(worldId) {
695
+ await this.#worlds.operations.withExclusiveWorldStateMutation(worldId, async () => {
696
+ const intent = await this.#worlds.readPlayerRevisionIntent(worldId);
697
+ if (intent === null)
698
+ return;
699
+ if (!isPlayerRevisionIntent(intent) || intent.worldId !== worldId)
700
+ throw new PlayCallChainError("The interrupted player revision record is invalid.");
701
+ await this.#completePlayerRevision(intent);
651
702
  });
652
703
  }
653
- async #applyPlayerRevision(input) {
654
- const request = input.request;
655
- const sourceContext = input.sourceContexts[input.selectedContextIndex];
656
- const restoresHead = playerRevisionRestoresHead(sourceContext, input.selectedEventIndex);
657
- const freshPreparation = request.continuation === "fresh_context"
658
- ? await this.#prepareFreshPlayerRevision(request, restoresHead)
704
+ /**
705
+ * Run the revision steps in order. Each step either checks whether it is
706
+ * already done or repeats harmlessly: rewinding to the restored endpoint is a
707
+ * no-op at that endpoint, the commit uses the intent's operation ID, and the
708
+ * context is published last, so it is the only step that changes what the
709
+ * next load sees.
710
+ */
711
+ async #completePlayerRevision(intent) {
712
+ return this.#worlds.operations.withExclusiveWorldStateMutation(intent.worldId, () => this.#completePlayerRevisionQueued(intent));
713
+ }
714
+ async #completePlayerRevisionQueued(intent) {
715
+ const worldId = intent.worldId;
716
+ const resultChainId = revisionChainId(intent.chainId, intent.operationId, worldId, intent.continuation);
717
+ const current = await this.#readPersisted(worldId);
718
+ if (current?.chainId === resultChainId) {
719
+ await this.#discardRevisedContexts(intent, resultChainId);
720
+ await this.#worlds.clearPlayerRevisionIntent(worldId);
721
+ return current;
722
+ }
723
+ const contexts = await this.#worlds.playTimeline.readAllContexts(worldId);
724
+ const sourceIndex = contexts.findIndex(({ chainId }) => chainId === intent.chainId);
725
+ const sourceContext = contexts[sourceIndex];
726
+ if (sourceContext === undefined)
727
+ throw new PlayCallChainError("The model context to revise does not exist.");
728
+ const eventIndex = sourceContext.events.findIndex(({ id }) => id === intent.eventId);
729
+ if (eventIndex < 0)
730
+ throw new PlayCallChainError("The selected event is no longer in its model context.");
731
+ // Fresh-context bootstrap is compiled before any world change.
732
+ const fresh = intent.continuation === "fresh_context" && intent.freshContext
733
+ ? await this.#prepareFreshPlayerRevision(worldId, intent.freshContext, intent.restoresHead, intent.replacementText)
659
734
  : null;
660
- const timelineGeneration = playerRevisionTimelineGeneration(input.source.timelineGeneration, request.operationId, request.worldId, request.continuation);
661
- const requestFingerprint = playerRevisionRequestFingerprint(request, input.selectedEvent.committedHead, restoresHead);
662
- const traceRetention = preparePlayerRevisionTrace(sourceContext, input.selectedEventIndex);
663
- const outcome = await this.#worlds.reviseTimeline({
664
- operationId: request.operationId,
665
- worldId: request.worldId,
666
- expectedCurrentHead: input.source.parentHead,
667
- restoresHead,
668
- replacesHead: input.selectedEvent.committedHead,
669
- replacementText: request.replacementText,
670
- requestFingerprint,
671
- });
735
+ const head = await this.#commitPlayerRevision(intent);
672
736
  const [baseline, selected, binding] = await Promise.all([
673
- this.#worlds.recoverEndpoint(request.worldId, sourceContext.baselineHead),
674
- this.#worlds.recoverEndpoint(request.worldId, restoresHead),
675
- this.#worlds.bindPlayCallChain(request.worldId),
737
+ this.#worlds.recoverEndpoint(worldId, sourceContext.baselineHead),
738
+ this.#worlds.recoverEndpoint(worldId, intent.restoresHead),
739
+ this.#worlds.bindPlayCallChain(worldId),
676
740
  ]);
677
- const continuedTrace = traceRetention.withReplacement({
678
- head: outcome.head,
679
- exchangeId: request.replacementExchangeId,
680
- text: request.replacementText,
681
- });
682
- if (binding.parentHead !== outcome.head)
683
- throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
684
- if (request.continuation === "fresh_context") {
685
- if (freshPreparation === null)
686
- throw new PlayCallChainError("The fresh model context was not prepared before committing the timeline revision.");
687
- const revised = await this.#buildFreshPlayerRevision({
688
- request,
741
+ if (binding.parentHead !== head)
742
+ throw new PlayCallChainError("The player revision was committed, but current-world materialization has not reached the new endpoint.");
743
+ const timelineGeneration = revisionTimelineGeneration(current?.timelineGeneration ?? "none", intent.operationId, worldId, intent.continuation);
744
+ const previousChainId = sourceIndex === 0 ? null : contexts[sourceIndex - 1].chainId;
745
+ const traceRetention = preparePlayerRevisionTrace(sourceContext, eventIndex);
746
+ let revised;
747
+ if (intent.continuation === "fresh_context") {
748
+ if (fresh === null || intent.freshContext === undefined)
749
+ throw new PlayCallChainError("The fresh model context was not prepared before committing the player revision.");
750
+ revised = await this.#buildFreshPlayerRevision({
751
+ intent,
752
+ freshContext: intent.freshContext,
689
753
  sourceContext,
690
- sourceContexts: input.sourceContexts,
691
- selectedContextIndex: input.selectedContextIndex,
692
754
  traceRetention,
693
755
  baseline,
694
756
  selected,
695
757
  binding,
696
- restoresHead,
697
- outcomeHead: outcome.head,
758
+ head,
759
+ chainId: resultChainId,
760
+ previousChainId,
698
761
  timelineGeneration,
699
- freshPreparation,
762
+ fresh,
700
763
  });
701
- await this.#worlds.playTimeline.persist(structuredClone(revised));
702
- await this.#worlds.completeArchiveFreshContext(request.worldId);
703
- const activeId = this.#worldChains.get(request.worldId);
704
- if (activeId !== undefined)
705
- this.#active.delete(activeId);
706
- this.#active.delete(sourceContext.chainId);
707
- this.#worldChains.delete(request.worldId);
708
- return {
709
- outcome: "revised",
710
- worldId: request.worldId,
711
- playCallChain: projectView(revised),
764
+ }
765
+ else {
766
+ revised = {
767
+ ...traceRetention
768
+ .withReplacement({
769
+ head,
770
+ exchangeId: intent.replacementExchangeId,
771
+ text: intent.replacementText,
772
+ })
773
+ .atEndpoint({ baseline, selected, binding }),
774
+ schemaVersion: 3,
775
+ kind: "play_call_chain",
776
+ chainId: resultChainId,
777
+ worldId,
778
+ previousContexts: [],
779
+ previousChainId,
780
+ timelineGeneration,
781
+ parentHead: head,
782
+ updatedAt: Date.now(),
712
783
  };
713
784
  }
714
- const revised = {
715
- ...continuedTrace.atEndpoint({ baseline, selected, binding }),
716
- schemaVersion: 3,
717
- kind: "play_call_chain",
718
- chainId: playerRevisionChainId(sourceContext.chainId, request.operationId, request.worldId, request.continuation),
719
- worldId: request.worldId,
720
- previousContexts: [],
721
- previousChainId: input.selectedContextIndex === 0
722
- ? null
723
- : input.sourceContexts[input.selectedContextIndex - 1].chainId,
724
- timelineGeneration,
725
- parentHead: outcome.head,
726
- updatedAt: Date.now(),
727
- };
728
785
  await this.#worlds.playTimeline.persist(structuredClone(revised));
729
- const activeId = this.#worldChains.get(request.worldId);
786
+ if (intent.continuation === "fresh_context")
787
+ await this.#worlds.completeArchiveFreshContext(worldId);
788
+ await this.#discardRevisedContexts(intent, resultChainId);
789
+ const activeId = this.#worldChains.get(worldId);
730
790
  if (activeId !== undefined)
731
791
  this.#active.delete(activeId);
732
792
  this.#active.delete(sourceContext.chainId);
733
- this.#worldChains.delete(request.worldId);
734
- return {
735
- outcome: "revised",
736
- worldId: request.worldId,
737
- playCallChain: projectView(revised),
738
- };
793
+ this.#worldChains.delete(worldId);
794
+ await this.#worlds.clearPlayerRevisionIntent(worldId);
795
+ return revised;
796
+ }
797
+ /**
798
+ * Delete the segments of the edited context and every later one. Their
799
+ * surviving content has been copied into the revised context, so only the
800
+ * discarded rounds and their in-progress markers are removed from disk.
801
+ */
802
+ async #discardRevisedContexts(intent, keepChainId) {
803
+ const discarded = intent.discardChainIds.filter((chainId) => chainId !== keepChainId);
804
+ await this.#worlds.playTimeline.discardContexts(intent.worldId, discarded);
805
+ for (const chainId of discarded)
806
+ await this.#worlds.playAdvances.discardChain(intent.worldId, chainId);
807
+ }
808
+ /**
809
+ * Rewind the world to the message's logical parent and commit the replacement
810
+ * as an ordinary player step. Returns the committed head. A commit already
811
+ * made in this process is reused rather than rewound and repeated.
812
+ */
813
+ async #commitPlayerRevision(intent) {
814
+ const done = await this.#worlds.getOperationOutcome(intent.operationId);
815
+ if (done.outcome === "committed")
816
+ return done.head;
817
+ await this.#worlds.rewindPlayerRevision(intent.worldId, intent.restoresHead);
818
+ await this.#artifacts?.discardAfterHead(intent.worldId, intent.restoresHead);
819
+ const binding = await this.#worlds.bindPlayCallChainAt(intent.worldId, intent.restoresHead);
820
+ const origin = captureNarrativeOrigin(WorldDocumentStore.open({
821
+ layout: "world_state",
822
+ files: Object.entries(binding.files).map(([path, contents]) => ({
823
+ path,
824
+ contents,
825
+ })),
826
+ }), intent.eventId);
827
+ const outcome = await this.#worlds.commitPlayStep({
828
+ operationId: intent.operationId,
829
+ worldId: intent.worldId,
830
+ parentHead: intent.restoresHead,
831
+ historyAppend: [
832
+ {
833
+ role: "player",
834
+ exactText: intent.replacementText,
835
+ ...(origin.value == null
836
+ ? {}
837
+ : { background: snapshotBackground(origin.value) }),
838
+ },
839
+ ],
840
+ nextMaterials: structuredClone(binding.additionalMaterials),
841
+ stateChanges: [],
842
+ });
843
+ return outcome.head;
739
844
  }
740
- async #prepareFreshPlayerRevision(request, restoresHead) {
741
- const binding = await this.#worlds.bindPlayCallChainAt(request.worldId, restoresHead);
845
+ async #prepareFreshPlayerRevision(worldId, freshContext, restoresHead, replacementText) {
846
+ const binding = await this.#worlds.bindPlayCallChainAt(worldId, restoresHead);
742
847
  const documents = new FileNativePlayDocuments(binding.files);
743
- const documentMaintenance = await this.#bindDocumentMaintenance(documents, request.worldId, binding.parentHead);
848
+ const documentMaintenance = await this.#bindDocumentMaintenance(documents, worldId, binding.parentHead);
744
849
  const compilation = this.#compiler.compilePlayCallChain({
745
- extensionControls: await this.#worlds.extensionControls(request.worldId, request.freshContext.playPreset, this.#compiler.locale),
850
+ extensionControls: await this.#worlds.extensionControls(worldId, freshContext.playPreset, this.#compiler.locale),
746
851
  endpoint: {
747
- id: `${request.worldId}:${binding.parentHead}`,
852
+ id: `${worldId}:${binding.parentHead}`,
748
853
  commit: binding.parentHead,
749
854
  },
750
- hostBinding: structuredClone(request.freshContext.hostBinding),
855
+ hostBinding: structuredClone(freshContext.hostBinding),
751
856
  world: {
752
857
  controlFingerprint: fingerprintControl(binding.files),
753
858
  documentSnapshot: documents.snapshot,
@@ -759,24 +864,20 @@ export class PlayCallChain {
759
864
  documentMaintenanceUnavailableReason: documents.maintenanceWarning,
760
865
  },
761
866
  playerInputPlacement: "append",
762
- playerInput: request.replacementText,
763
- modelBinding: structuredClone(request.freshContext.modelBinding),
764
- }, request.freshContext.playPreset);
765
- const bootstrap = structuredClone(compilation.bootstrap);
766
- documents.bindBootstrap(bootstrap);
867
+ playerInput: replacementText,
868
+ modelBinding: structuredClone(freshContext.modelBinding),
869
+ }, freshContext.playPreset);
870
+ documents.bindBootstrap(compilation.bootstrap);
767
871
  return {
768
872
  binding,
769
- bootstrap,
873
+ bootstrap: structuredClone(compilation.bootstrap),
770
874
  tools: structuredClone(compilation.toolUniverse),
771
875
  followups: structuredClone(compilation.followups ?? []),
772
- authorization: documents.authorizationCheckpoint(),
773
876
  };
774
877
  }
775
878
  async #buildFreshPlayerRevision(input) {
776
- const { request, sourceContext } = input;
777
- let previousChainId = input.selectedContextIndex === 0
778
- ? null
779
- : input.sourceContexts[input.selectedContextIndex - 1].chainId;
879
+ const { intent, sourceContext, fresh } = input;
880
+ let previousChainId = input.previousChainId;
780
881
  const retainedPrefix = input.traceRetention.pagePrefixAtEndpoint({
781
882
  baseline: input.baseline,
782
883
  selected: input.selected,
@@ -787,38 +888,37 @@ export class PlayCallChain {
787
888
  ...retainedPrefix,
788
889
  schemaVersion: 3,
789
890
  kind: "play_call_chain",
790
- chainId: derivedChainId(sourceContext.chainId, "timeline-revision-prefix:" + request.operationId, request.worldId),
791
- worldId: request.worldId,
891
+ chainId: derivedChainId(sourceContext.chainId, "timeline-revision-prefix:" + intent.operationId, intent.worldId),
892
+ worldId: intent.worldId,
792
893
  previousContexts: [],
793
894
  previousChainId,
794
895
  timelineGeneration: input.timelineGeneration,
795
- parentHead: input.restoresHead,
896
+ parentHead: intent.restoresHead,
796
897
  updatedAt: Date.now(),
797
898
  };
798
899
  await this.#worlds.playTimeline.persistDetached(structuredClone(prefix));
799
900
  previousChainId = prefix.chainId;
800
901
  }
801
- const fresh = input.freshPreparation;
802
902
  return {
803
903
  schemaVersion: 3,
804
904
  kind: "play_call_chain",
805
- chainId: playerRevisionChainId(sourceContext.chainId, request.operationId, request.worldId, request.continuation),
806
- worldId: request.worldId,
905
+ chainId: input.chainId,
906
+ worldId: intent.worldId,
807
907
  previousContexts: [],
808
908
  previousChainId,
809
909
  timelineGeneration: input.timelineGeneration,
810
- baselineHead: input.restoresHead,
910
+ baselineHead: intent.restoresHead,
811
911
  baselineHistoryLength: Object.keys(fresh.binding.history).length,
812
- parentHead: input.outcomeHead,
912
+ parentHead: input.head,
813
913
  playPreset: {
814
- id: request.freshContext.playPreset.id,
815
- name: request.freshContext.playPreset.name,
816
- revision: request.freshContext.playPreset.revision,
914
+ id: input.freshContext.playPreset.id,
915
+ name: input.freshContext.playPreset.name,
916
+ revision: input.freshContext.playPreset.revision,
817
917
  },
818
918
  followups: structuredClone(fresh.followups),
819
- playPresetScriptsEnabled: request.freshContext.playPreset.scriptsEnabled,
820
- presetFiles: structuredClone(request.freshContext.playPreset.files),
821
- modelBinding: structuredClone(request.freshContext.modelBinding),
919
+ playPresetScriptsEnabled: input.freshContext.playPreset.scriptsEnabled,
920
+ presetFiles: structuredClone(input.freshContext.playPreset.files),
921
+ modelBinding: structuredClone(input.freshContext.modelBinding),
822
922
  status: "ready",
823
923
  canRetry: false,
824
924
  bootstrap: structuredClone(fresh.bootstrap),
@@ -827,24 +927,18 @@ export class PlayCallChain {
827
927
  layout: "world_state",
828
928
  files: Object.entries(input.binding.files).map(([path, contents]) => ({ path, contents })),
829
929
  }), 1),
830
- transcript: playerInputAppend(request.replacementText),
930
+ transcript: playerInputAppend(intent.replacementText),
831
931
  events: [
832
932
  {
833
933
  id: 1,
834
934
  kind: "player",
835
- exchangeId: request.replacementExchangeId,
836
- text: request.replacementText,
935
+ exchangeId: intent.replacementExchangeId,
936
+ text: intent.replacementText,
837
937
  context: "fresh",
838
- committedHead: input.outcomeHead,
938
+ committedHead: input.head,
839
939
  },
840
940
  ],
841
941
  completedTools: [],
842
- documentAuthorizationCheckpoints: [
843
- {
844
- afterEventId: 0,
845
- authorization: structuredClone(fresh.authorization),
846
- },
847
- ],
848
942
  changedDocuments: [],
849
943
  nextMaterials: structuredClone(input.binding.additionalMaterials),
850
944
  nextEventId: 2,
@@ -948,28 +1042,24 @@ export class PlayCallChain {
948
1042
  ? { retainCompleteContext: true }
949
1043
  : {}),
950
1044
  });
951
- if (input.targetWorldRoot !== undefined) {
952
- const retainedContexts = [
953
- ...input.sourceContexts.slice(0, input.selectedContextIndex),
954
- { ...sourceContext, events: input.sourceEvents },
955
- ];
956
- await this.#artifacts?.stageFork?.({
957
- sourceWorldId: input.sourceWorldId,
958
- targetWorldId: input.targetWorldId,
959
- targetWorldRoot: input.targetWorldRoot,
960
- timeline: retainedContexts.flatMap((context) => context.events.flatMap((event) => event.kind === "assistant" && event.committedHead !== undefined
961
- ? [
962
- {
963
- contextId: context.continuityContextId ?? context.chainId,
964
- eventId: event.id,
965
- head: event.committedHead,
966
- },
967
- ]
968
- : [])),
969
- });
970
- }
971
1045
  return projectView(derived);
972
1046
  }
1047
+ /**
1048
+ * Stops every in-flight model request for a world and waits until each has
1049
+ * left the active table, so deletion never races a play write.
1050
+ */
1051
+ async abortWorld(worldId) {
1052
+ validateIdentity(worldId, "world ID");
1053
+ const invocations = [...this.#activeInvocations.values()].filter((invocation) => invocation.worldId === worldId);
1054
+ for (const invocation of invocations) {
1055
+ if (invocation.abortable && !invocation.controller.signal.aborted) {
1056
+ invocation.phase = "cancelling";
1057
+ invocation.lastActivityAt = Date.now();
1058
+ invocation.controller.abort();
1059
+ }
1060
+ }
1061
+ await Promise.all(invocations.map((invocation) => this.#invocationSettled.get(invocation.chainId) ?? Promise.resolve()));
1062
+ }
973
1063
  forgetWorld(worldId) {
974
1064
  const activeId = this.#worldChains.get(worldId);
975
1065
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
@@ -978,6 +1068,7 @@ export class PlayCallChain {
978
1068
  if (activeId !== undefined)
979
1069
  this.#active.delete(activeId);
980
1070
  this.#worldChains.delete(worldId);
1071
+ this.#hydrating.delete(worldId);
981
1072
  }
982
1073
  async #captureOrigin(session) {
983
1074
  const binding = await this.#worlds.bindPlayCallChain(session.worldId);
@@ -1029,14 +1120,6 @@ export class PlayCallChain {
1029
1120
  await this.#settlePlayerAdvance(session, advance);
1030
1121
  }
1031
1122
  catch (error) {
1032
- const accepted = await this.#worlds
1033
- .getOperationOutcome(advance.operationId)
1034
- .catch(() => ({ outcome: "not_started" }));
1035
- if (accepted.outcome === "committed" ||
1036
- accepted.outcome === "committed_materialization_pending") {
1037
- await this.#settlePlayerAdvance(session, advance);
1038
- return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
1039
- }
1040
1123
  const message = error instanceof Error
1041
1124
  ? error.message
1042
1125
  : "Failed to write the original player text.";
@@ -1052,7 +1135,7 @@ export class PlayCallChain {
1052
1135
  session.lastFailure = message;
1053
1136
  session.lastFailureKind = failureKind;
1054
1137
  await this.#persist(session);
1055
- await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1138
+ await this.#worlds.playAdvances.markSettled(advance);
1056
1139
  throw new PlayCallChainError(message);
1057
1140
  }
1058
1141
  return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
@@ -1116,8 +1199,7 @@ export class PlayCallChain {
1116
1199
  "The player message is committed; the frozen model request is ready to continue.";
1117
1200
  session.lastFailureKind = "retryable";
1118
1201
  await this.#persist(session);
1119
- crashAtPlayAdvanceEdge("after_timeline_settled");
1120
- await this.#worlds.playAdvances.markSettled(advance, outcome.head);
1202
+ await this.#worlds.playAdvances.markSettled(advance);
1121
1203
  }
1122
1204
  async #prepareResponseSettlement(session, advance, response) {
1123
1205
  const existing = session.events.find(({ id }) => id === advance.eventId);
@@ -1152,8 +1234,13 @@ export class PlayCallChain {
1152
1234
  const prepared = [];
1153
1235
  const trailingEvents = [];
1154
1236
  let nextEventId = advance.eventId + 1;
1237
+ // Fresh in-memory seed per settlement. It is never persisted: a crashed
1238
+ // exchange is retried by the player and re-rolls (ADR-0044).
1239
+ const resolutionSeed = calls.some(({ name }) => isResolutionTool(name))
1240
+ ? randomBytes(32).toString("hex")
1241
+ : undefined;
1155
1242
  for (const call of calls) {
1156
- const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale);
1243
+ const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale, resolutionSeed);
1157
1244
  prepared.push(item);
1158
1245
  trailingEvents.push({
1159
1246
  id: nextEventId++,
@@ -1268,7 +1355,6 @@ export class PlayCallChain {
1268
1355
  : []),
1269
1356
  ],
1270
1357
  completedTools: [...workingTools.values()].map((item) => structuredClone(item)),
1271
- authorizationCheckpoint: session.documents.authorizationCheckpoint(),
1272
1358
  stateChanges: structuredClone(stateChanges),
1273
1359
  visibleText,
1274
1360
  ...(narrativeCheckpoint === undefined ? {} : { narrativeCheckpoint }),
@@ -1329,7 +1415,6 @@ export class PlayCallChain {
1329
1415
  historyMessageId: `message.${outcome.head.slice("commit:".length)}.1.narrator`,
1330
1416
  };
1331
1417
  }
1332
- crashAtPlayAdvanceEdge("after_authority_accepted");
1333
1418
  appendCommittedHistory(session, outcome.head, outcome.historyAppend);
1334
1419
  }
1335
1420
  finalizePreparedReceipts(settlement);
@@ -1351,29 +1436,10 @@ export class PlayCallChain {
1351
1436
  item.key,
1352
1437
  structuredClone(item),
1353
1438
  ]));
1354
- const lastToolEventId = settlement.trailingEvents.findLast(({ kind }) => kind === "tool_result")?.id;
1355
- if (lastToolEventId !== undefined) {
1356
- session.documentAuthorizationCheckpoints =
1357
- session.documentAuthorizationCheckpoints.filter(({ afterEventId }) => afterEventId < advance.eventId);
1358
- session.documentAuthorizationCheckpoints.push({
1359
- afterEventId: lastToolEventId,
1360
- authorization: structuredClone(settlement.authorizationCheckpoint),
1361
- });
1362
- }
1363
1439
  if (committedHead !== undefined)
1364
1440
  session.parentHead = committedHead;
1365
1441
  if (settlement.stateChanges.length > 0) {
1366
- const currentAuthorization = session.documents.authorizationCheckpoint();
1367
- if (currentAuthorization.stateFingerprint ===
1368
- settlement.authorizationCheckpoint.stateFingerprint)
1369
- session.documents.acceptCommittedState();
1370
- else {
1371
- const binding = await this.#worlds.bindPlayCallChain(session.worldId);
1372
- session.documents = new FileNativePlayDocuments(binding.files);
1373
- session.documents.restoreAuthorizationCheckpoint(settlement.authorizationCheckpoint);
1374
- session.history = historyEntries(binding.history, binding.historyBackgrounds);
1375
- session.nextMaterials = structuredClone(binding.additionalMaterials);
1376
- }
1442
+ session.documents.acceptCommittedState();
1377
1443
  mergeChangedDocuments(session, settlement.stateChanges);
1378
1444
  }
1379
1445
  if (settlement.stateChanges.length > 0)
@@ -1426,24 +1492,9 @@ export class PlayCallChain {
1426
1492
  result = { kind: "terminal", status: session.status };
1427
1493
  }
1428
1494
  await this.#persist(session);
1429
- crashAtPlayAdvanceEdge("after_timeline_settled");
1430
- await this.#worlds.playAdvances.markSettled(advance, committedHead ?? session.parentHead);
1495
+ await this.#worlds.playAdvances.markSettled(advance);
1431
1496
  return result;
1432
1497
  }
1433
- async #recoverAcceptedResponseAdvance(session, advance) {
1434
- const outcome = await this.#worlds
1435
- .getOperationOutcome(advance.operationId)
1436
- .catch(() => ({ outcome: "not_started" }));
1437
- if (outcome.outcome !== "committed" &&
1438
- outcome.outcome !== "committed_materialization_pending")
1439
- return null;
1440
- const current = await this.#worlds.playAdvances.readCurrent(advance.worldId, advance.chainId);
1441
- if (current?.base.advanceKind !== "response" ||
1442
- current.base.advanceId !== advance.advanceId ||
1443
- current.settlementPrepared === null)
1444
- throw new PlayCallChainError("Authority accepted a response whose durable settlement is missing.");
1445
- return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
1446
- }
1447
1498
  async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
1448
1499
  let request = modelHost.freezeRequest?.(firstRequest) ?? structuredClone(firstRequest);
1449
1500
  let attempt = firstAttempt;
@@ -1489,7 +1540,6 @@ export class PlayCallChain {
1489
1540
  createdAt: Date.now(),
1490
1541
  };
1491
1542
  await this.#worlds.playAdvances.begin(advance);
1492
- crashAtPlayAdvanceEdge("after_response_advance_began");
1493
1543
  this.#notifySnapshot(observer, session);
1494
1544
  if (signal.aborted)
1495
1545
  return this.#cancelBeforeProviderDispatch(session, request, attempt, observer, advance);
@@ -1573,17 +1623,13 @@ export class PlayCallChain {
1573
1623
  });
1574
1624
  }
1575
1625
  await this.#persist(session);
1576
- await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1626
+ await this.#worlds.playAdvances.markSettled(advance);
1577
1627
  this.#notifySnapshot(observer, session);
1578
1628
  return projectView(session);
1579
1629
  }
1580
1630
  const durableResponse = durableModelResponse(response);
1581
1631
  try {
1582
- await this.#worlds.playAdvances.recordProviderCompleted(advance, durableResponse);
1583
- crashAtPlayAdvanceEdge("after_provider_completed");
1584
1632
  const settlement = await this.#prepareResponseSettlement(session, advance, durableResponse);
1585
- await this.#worlds.playAdvances.recordSettlementPrepared(advance, settlement);
1586
- crashAtPlayAdvanceEdge("after_settlement_prepared");
1587
1633
  const failedTools = settlement.trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
1588
1634
  if (failedTools.length > 0 && response.diagnostics !== undefined)
1589
1635
  await this.#failureLog?.recordFailure({
@@ -1665,17 +1711,6 @@ export class PlayCallChain {
1665
1711
  attempt = 1;
1666
1712
  }
1667
1713
  catch (error) {
1668
- const recovered = await this.#recoverAcceptedResponseAdvance(session, advance);
1669
- if (recovered !== null) {
1670
- if (recovered.kind === "continue") {
1671
- request = recovered.nextRequest;
1672
- attempt = 1;
1673
- this.#notifySnapshot(observer, session);
1674
- continue;
1675
- }
1676
- this.#notifySnapshot(observer, session);
1677
- return projectView(session);
1678
- }
1679
1714
  const message = error instanceof Error
1680
1715
  ? error.message
1681
1716
  : "Call-chain processing failed.";
@@ -1726,7 +1761,7 @@ export class PlayCallChain {
1726
1761
  delete session.lastFailureKind;
1727
1762
  await this.#persist(session);
1728
1763
  if (advance !== undefined)
1729
- await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1764
+ await this.#worlds.playAdvances.markSettled(advance);
1730
1765
  this.#notifySnapshot(observer, session);
1731
1766
  return projectView(session);
1732
1767
  }
@@ -1845,16 +1880,9 @@ export class PlayCallChain {
1845
1880
  await this.#reconcileSessionAdvance(active);
1846
1881
  return active;
1847
1882
  }
1848
- const persisted = await this.#readPersisted(worldId);
1849
- if (persisted?.chainId !== chainId)
1883
+ const session = await this.#loadWorldSession(worldId);
1884
+ if (session?.chainId !== chainId)
1850
1885
  throw new PlayCallChainError("The call chain does not exist; start from the latest world state.");
1851
- const advance = await this.#worlds.playAdvances.readCurrent(worldId, chainId);
1852
- const session = await this.#hydrateSession(persisted, advance);
1853
- this.#active.set(chainId, session);
1854
- this.#worldChains.set(worldId, chainId);
1855
- await this.#reconcileSessionAdvance(session, advance);
1856
- if (session.status === "running")
1857
- await this.#interruptAbandonedDispatch(session);
1858
1886
  const currentHead = await this.#worlds.currentHead(worldId);
1859
1887
  if (currentHead !== session.parentHead)
1860
1888
  throw new PlayCallChainError("The call-chain record differs from the current world endpoint; use a fresh context.");
@@ -1872,27 +1900,15 @@ export class PlayCallChain {
1872
1900
  const currentBinding = await this.#worlds.bindPlayCallChain(persisted.worldId);
1873
1901
  // A completed chain still describes its own immutable endpoint after a
1874
1902
  // world revision. Restore its reads against that state, not the revised
1875
- // current tree. An unsettled advance keeps the existing crash-recovery
1876
- // path because Authority may already contain its prepared result.
1877
- const binding = currentBinding.parentHead !== persisted.parentHead &&
1878
- advance?.settled !== null
1903
+ // current tree. An in-progress advance reads the current tree; it is
1904
+ // interrupted on load and retried against that tree.
1905
+ const binding = currentBinding.parentHead !== persisted.parentHead && advance === null
1879
1906
  ? await this.#worlds.bindPlayCallChainAt(persisted.worldId, persisted.parentHead)
1880
1907
  : currentBinding;
1881
- let documents;
1882
- try {
1883
- documents = restorePlayDocuments(binding.files, persisted, persisted.events);
1884
- }
1885
- catch (error) {
1886
- const prepared = advance?.settlementPrepared?.settlement;
1887
- if (prepared === undefined)
1888
- throw error;
1889
- documents = new FileNativePlayDocuments(binding.files);
1890
- documents.restoreAuthorizationCheckpoint(prepared.authorizationCheckpoint);
1891
- }
1908
+ const documents = restorePlayDocuments(binding.files, persisted, persisted.events);
1892
1909
  await this.#bindDocumentMaintenance(documents, persisted.worldId, binding.parentHead);
1893
1910
  return {
1894
1911
  ...structuredClone(persisted),
1895
- documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
1896
1912
  nextMaterials: structuredClone(binding.additionalMaterials),
1897
1913
  documents,
1898
1914
  history: historyEntries(binding.history, binding.historyBackgrounds),
@@ -1905,49 +1921,45 @@ export class PlayCallChain {
1905
1921
  eventCount: 0,
1906
1922
  transcriptCount: 0,
1907
1923
  completedToolCount: 0,
1908
- authorizationCheckpointCount: 0,
1909
1924
  },
1910
1925
  };
1911
1926
  }
1927
+ /**
1928
+ * An in-progress advance, or a session still marked running, means a model
1929
+ * dispatch or settlement was cut off by a process exit. Nothing is replayed:
1930
+ * the exchange is interrupted and the player retries (ADR-0044).
1931
+ */
1912
1932
  async #reconcileSessionAdvance(session, loaded) {
1913
- const current = loaded ??
1914
- (await this.#worlds.playAdvances.readCurrent(session.worldId, session.chainId));
1915
- if (current?.settled !== null)
1916
- return;
1917
- if (current.base.advanceKind === "player") {
1918
- await this.#settlePlayerAdvance(session, current.base);
1919
- return;
1920
- }
1921
- if (current.providerCompleted === null) {
1922
- await this.#interruptAbandonedDispatch(session, current.base);
1923
- await this.#worlds.playAdvances.markSettled(current.base, session.parentHead);
1933
+ const advance = loaded === undefined
1934
+ ? await this.#worlds.playAdvances.readCurrent(session.worldId, session.chainId)
1935
+ : loaded;
1936
+ if (advance === null && session.status !== "running")
1924
1937
  return;
1925
- }
1926
- let settlement = current.settlementPrepared?.settlement;
1927
- if (settlement === undefined) {
1928
- settlement = await this.#prepareResponseSettlement(session, current.base, current.providerCompleted.response);
1929
- await this.#worlds.playAdvances.recordSettlementPrepared(current.base, settlement);
1930
- }
1931
- await this.#settleResponseAdvance(session, current.base, settlement);
1938
+ await this.#interruptAbandonedDispatch(session, advance);
1939
+ if (advance !== null)
1940
+ await this.#worlds.playAdvances.markSettled(advance);
1932
1941
  }
1933
1942
  async #interruptAbandonedDispatch(session, advance) {
1934
- const streaming = session.events.findLast((event) => event.kind === "assistant" &&
1935
- event.status === "streaming" &&
1936
- (advance === undefined || event.id === advance.eventId));
1943
+ const headMoved = (await this.#worlds.currentHead(session.worldId)) !== session.parentHead;
1944
+ const playerPhase = advance?.advanceKind === "player";
1945
+ const streaming = playerPhase
1946
+ ? undefined
1947
+ : session.events.findLast((event) => event.kind === "assistant" &&
1948
+ event.status === "streaming" &&
1949
+ (advance === null || event.id === advance.eventId));
1937
1950
  if (streaming !== undefined)
1938
1951
  streaming.status = "interrupted";
1939
- const dispatchMayHaveStarted = advance !== undefined;
1940
- const canRetry = !dispatchMayHaveStarted && session.lastRequest !== null;
1941
- const message = dispatchMayHaveStarted
1942
- ? "The service stopped after model dispatch may have started; the outcome is unknown and the request cannot be replayed. Use a fresh context."
1943
- : canRetry
1944
- ? "The service stopped before model dispatch; the frozen request can be retried."
1945
- : "The service stopped before preparing a model request; use a fresh context.";
1946
- const failureKind = dispatchMayHaveStarted
1947
- ? "outcome_unknown"
1948
- : canRetry
1949
- ? "retryable"
1950
- : "other";
1952
+ // Authority already moved past the persisted parent head: the world
1953
+ // cannot be replayed onto that head, so a fresh context is required.
1954
+ const canRetry = !headMoved && !playerPhase && session.lastRequest !== null;
1955
+ const message = headMoved
1956
+ ? "The service stopped after the world was updated; use a fresh context."
1957
+ : playerPhase
1958
+ ? "The service stopped while saving the player message; use a fresh context."
1959
+ : canRetry
1960
+ ? "The service stopped while the model request was in progress; retry to call the model again."
1961
+ : "The service stopped before preparing a model request; use a fresh context.";
1962
+ const failureKind = canRetry ? "retryable" : "other";
1951
1963
  session.events.push({
1952
1964
  id: session.nextEventId++,
1953
1965
  kind: "failure",
@@ -2068,7 +2080,7 @@ function appendCommittedHistory(session, head, messages) {
2068
2080
  });
2069
2081
  }
2070
2082
  }
2071
- function prepareTool(session, working, exchange, call, locale) {
2083
+ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2072
2084
  const key = `${exchange}:${call.id}`;
2073
2085
  const signature = JSON.stringify(call.arguments);
2074
2086
  const completed = working.get(key);
@@ -2095,8 +2107,9 @@ function prepareTool(session, working, exchange, call, locale) {
2095
2107
  }
2096
2108
  else {
2097
2109
  try {
2098
- result =
2099
- call.name === "world_checkpoint"
2110
+ result = isResolutionTool(call.name)
2111
+ ? resolutionToolResult(call.name, call.arguments, resolutionSeed, key, locale)
2112
+ : call.name === "world_checkpoint"
2100
2113
  ? typeof call.arguments === "object" &&
2101
2114
  call.arguments !== null &&
2102
2115
  !Array.isArray(call.arguments) &&
@@ -2139,6 +2152,9 @@ function prepareTool(session, working, exchange, call, locale) {
2139
2152
  name: call.name,
2140
2153
  ok: result.ok,
2141
2154
  markdown: result.markdown,
2155
+ ...(result.resolution === undefined
2156
+ ? {}
2157
+ : { resolution: structuredClone(result.resolution) }),
2142
2158
  replayed,
2143
2159
  };
2144
2160
  return {
@@ -2330,31 +2346,10 @@ function playerRevisionRestoresHead(source, selectedEventIndex) {
2330
2346
  restoresHead = event.committedHead;
2331
2347
  return restoresHead;
2332
2348
  }
2333
- function playerRevisionRequestFingerprint(request, selectedHead, restoresHead) {
2334
- const base = {
2335
- schema: "narraeon.timeline-revision-request/v1",
2336
- chainId: request.chainId,
2337
- eventId: request.eventId,
2338
- selectedHead,
2339
- restoresHead,
2340
- replacementExchangeId: request.replacementExchangeId,
2341
- replacementText: request.replacementText,
2342
- };
2343
- // Keep the released continuation fingerprint byte-for-byte recoverable;
2344
- // the fresh strategy owns a new request shape with an explicit mode.
2345
- const value = request.continuation === "continue_context"
2346
- ? base
2347
- : {
2348
- ...base,
2349
- schema: "narraeon.timeline-revision-request/v2",
2350
- continuation: request.continuation,
2351
- };
2352
- return ("sha256:" + createHash("sha256").update(JSON.stringify(value)).digest("hex"));
2353
- }
2354
- function playerRevisionChainId(sourceChainId, operationId, worldId, continuation) {
2349
+ function revisionChainId(sourceChainId, operationId, worldId, continuation) {
2355
2350
  return derivedChainId(sourceChainId, `${continuation === "fresh_context" ? "timeline-revision-fresh" : "timeline-revision"}:${operationId}`, worldId);
2356
2351
  }
2357
- function playerRevisionTimelineGeneration(sourceGeneration, operationId, worldId, continuation) {
2352
+ function revisionTimelineGeneration(sourceGeneration, operationId, worldId, continuation) {
2358
2353
  return `timeline:${createHash("sha256")
2359
2354
  .update(JSON.stringify({
2360
2355
  schema: "narraeon.player-revision-timeline-generation/v1",
@@ -2409,10 +2404,15 @@ function notifyAssistantDelta(observer, delta) {
2409
2404
  // Like snapshot projection, the Provider exchange outlives its browser stream.
2410
2405
  }
2411
2406
  }
2412
- function crashAtPlayAdvanceEdge(edge) {
2413
- if (process.env.NODE_ENV === "test" &&
2414
- process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PLAY_ADVANCE_EDGE === edge)
2415
- throw new Error(`Simulated process exit at play advance edge: ${edge}`);
2407
+ function resolutionToolResult(name, args, seed, identity, locale) {
2408
+ if (seed === undefined)
2409
+ throw new Error("The resolution seed for this advance is unavailable.");
2410
+ const resolution = resolveTabletop(name, args, seededRandom(seed, identity));
2411
+ return {
2412
+ ok: true,
2413
+ markdown: resolutionMarkdown(resolution, locale),
2414
+ resolution,
2415
+ };
2416
2416
  }
2417
2417
  function frozenFollowupPresentations(session) {
2418
2418
  const files = session.promptRuns?.at(-1)?.presetFiles ?? session.presetFiles;