narraeon 0.6.0 → 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 (41) hide show
  1. package/README.md +11 -0
  2. package/README.zh-CN.md +8 -0
  3. package/dist/node/protocol/v1.js +0 -2
  4. package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
  5. package/dist/node/runtime/V1Runtime.js +1 -5
  6. package/dist/node/runtime/archive/WorldArchives.js +14 -46
  7. package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
  8. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
  9. package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
  10. package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
  11. package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
  12. package/dist/node/runtime/config/dataRootLock.js +108 -0
  13. package/dist/node/runtime/content/ContentWorkspace.js +0 -10
  14. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +7 -44
  15. package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
  16. package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
  17. package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
  18. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
  19. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +32 -207
  20. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +29 -239
  21. package/dist/node/runtime/play/PlayCallChain.js +338 -366
  22. package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
  23. package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
  24. package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
  25. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +3 -15
  26. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
  27. package/dist/node/runtime/world/FileNativeWorldStore.js +230 -781
  28. package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
  29. package/dist/node/runtime/world/WorldActivity.js +14 -22
  30. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
  31. package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
  32. package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
  33. package/dist/node/runtime/world/WorldQueue.js +46 -0
  34. package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
  35. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +0 -12
  36. package/dist/node/server/mobile.js +20 -0
  37. package/dist/node/server/startWebServer.js +15 -8
  38. package/dist/web/assets/{index-BgBL8G-A.js → index-7GzLSUpU.js} +2 -2
  39. package/dist/web/index.html +1 -1
  40. package/package.json +2 -1
  41. package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
@@ -8,7 +8,7 @@ import { PlayCallChainError } from "./PlayCallChainError.js";
8
8
  import { preparePlayerRevisionTrace, prepareForkTrace, restorePlayDocuments, independentContextCopy, } from "./PlayTraceRetention.js";
9
9
  import { currentPlayPrompt, recordedPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
10
10
  import { comparePromptPrefixes } from "../prompt/WorldPromptDiagnostics.js";
11
- import { createHash } from "node:crypto";
11
+ import { createHash, randomBytes } from "node:crypto";
12
12
  import { completedPlayerRounds, continuationNotice, playerInputAppend, toolStepNotice, } from "./PlayContinuity.js";
13
13
  import { renderFreshContextCoverage } from "../prompt/WorldMaterialCoverage.js";
14
14
  import { renderWriteSizeAdvice, readDeclaredWorldClock, } from "../prompt/WorldMaintenanceReport.js";
@@ -34,6 +34,24 @@ const callChainToolNames = new Set([
34
34
  "world_checkpoint",
35
35
  ]);
36
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
+ }
37
55
  export { PlayCallChainError } from "./PlayCallChainError.js";
38
56
  /**
39
57
  * The production play loop: one durable logical context, no Runtime-authored
@@ -50,8 +68,12 @@ export class PlayCallChain {
50
68
  #failureLog;
51
69
  #active = new Map();
52
70
  #worldChains = new Map();
71
+ /** One in-flight disk hydration per world; concurrent callers share it. */
72
+ #hydrating = new Map();
53
73
  #loadedCursors = new Map();
54
74
  #activeInvocations = new Map();
75
+ /** Resolves when the matching invocation has left the active table. */
76
+ #invocationSettled = new Map();
55
77
  constructor(worlds, compiler = new FileNativePromptCompiler(), artifacts, failureLog, changed = () => {
56
78
  /* No observers attached. */
57
79
  }) {
@@ -136,12 +158,6 @@ export class PlayCallChain {
136
158
  transcript: [],
137
159
  events: [],
138
160
  completedTools: [],
139
- documentAuthorizationCheckpoints: [
140
- {
141
- afterEventId: 0,
142
- authorization: documents.authorizationCheckpoint(),
143
- },
144
- ],
145
161
  changedDocuments: [],
146
162
  nextMaterials: structuredClone(binding.additionalMaterials),
147
163
  nextEventId: 1,
@@ -158,7 +174,6 @@ export class PlayCallChain {
158
174
  eventCount: 0,
159
175
  transcriptCount: 0,
160
176
  completedToolCount: 0,
161
- authorizationCheckpointCount: 0,
162
177
  },
163
178
  };
164
179
  if (activeId !== undefined)
@@ -258,12 +273,7 @@ export class PlayCallChain {
258
273
  playPresetScriptsEnabled: playPreset.scriptsEnabled,
259
274
  presetFiles: structuredClone(playPreset.files),
260
275
  });
261
- session.documentAuthorizationCheckpoints.push({
262
- afterEventId: session.nextEventId,
263
- authorization: session.documents.authorizationCheckpoint(),
264
- });
265
276
  await this.#persist(session);
266
- crashAtPlayAdvanceEdge("after_prompt_run_prepared");
267
277
  }
268
278
  cancel(input) {
269
279
  validateIdentity(input.worldId, "world ID");
@@ -289,6 +299,10 @@ export class PlayCallChain {
289
299
  [...this.#activeInvocations.values()].some(({ worldId }) => worldId === input.worldId))
290
300
  throw new PlayCallChainError("A model request is already running for this world or call chain.");
291
301
  const startedAt = Date.now();
302
+ let markSettled;
303
+ const settled = new Promise((resolve) => {
304
+ markSettled = resolve;
305
+ });
292
306
  const invocation = {
293
307
  worldId: input.worldId,
294
308
  chainId: input.chainId,
@@ -307,6 +321,7 @@ export class PlayCallChain {
307
321
  const controlUseId = operationId(input.chainId, `control-use:${input.exchangeId}`);
308
322
  this.#worlds.freezeControl(input.worldId, controlUseId);
309
323
  this.#activeInvocations.set(input.chainId, invocation);
324
+ this.#invocationSettled.set(input.chainId, settled);
310
325
  this.changed(input.worldId);
311
326
  try {
312
327
  await this.#worlds.assertWorldRevisionUnlocked(input.worldId);
@@ -317,6 +332,8 @@ export class PlayCallChain {
317
332
  this.#activeInvocations.delete(input.chainId);
318
333
  this.#worlds.releaseControl(input.worldId, controlUseId);
319
334
  this.changed(input.worldId);
335
+ this.#invocationSettled.delete(input.chainId);
336
+ markSettled();
320
337
  }
321
338
  }
322
339
  #observeInvocation(chainId, phase, increments = {}) {
@@ -378,6 +395,14 @@ export class PlayCallChain {
378
395
  return view;
379
396
  }
380
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) {
381
406
  const activeId = this.#worldChains.get(worldId);
382
407
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
383
408
  if (active !== undefined) {
@@ -385,17 +410,38 @@ export class PlayCallChain {
385
410
  await this.#reconcileSessionAdvance(active);
386
411
  return active;
387
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);
388
429
  const persisted = await this.#readPersisted(worldId);
389
430
  if (persisted === null)
390
431
  return null;
391
432
  const advance = await this.#worlds.playAdvances.readCurrent(worldId, persisted.chainId);
392
- const session = await this.#hydrateSession(persisted, advance);
393
- this.#active.set(session.chainId, session);
394
- this.#worldChains.set(worldId, session.chainId);
395
- await this.#reconcileSessionAdvance(session, advance);
396
- if (session.status === "running")
397
- await this.#interruptAbandonedDispatch(session);
398
- 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;
399
445
  }
400
446
  async inspectReading(worldId, worldHead) {
401
447
  const view = await this.inspectWorld(worldId);
@@ -567,9 +613,10 @@ export class PlayCallChain {
567
613
  });
568
614
  }
569
615
  /**
570
- * Revise one committed player message in place. Authority remains
571
- * append-only: one timeline-revision commit restores the player's logical
572
- * 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.
573
620
  */
574
621
  async revisePlayer(input) {
575
622
  validateIdentity(input.operationId, "timeline-revision operation ID");
@@ -583,178 +630,229 @@ export class PlayCallChain {
583
630
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
584
631
  if (active?.status === "running")
585
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);
586
634
  const source = await this.#readPersisted(input.worldId);
587
635
  if (source === null)
588
636
  throw new PlayCallChainError("The current world has no model call chain to revise.");
589
- const sourceContexts = await this.#worlds.playTimeline.readAllContexts(input.worldId);
590
- const appliedContext = sourceContexts.find(({ events }) => events.some((event) => event.kind === "player" &&
591
- event.exchangeId === input.replacementExchangeId &&
592
- event.committedHead !== undefined));
593
- 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" &&
594
643
  event.exchangeId === input.replacementExchangeId &&
595
644
  event.committedHead !== undefined);
596
- if (appliedEvent !== undefined) {
597
- if (appliedEvent.text !== input.replacementText)
645
+ if (applied !== undefined) {
646
+ if (applied.kind === "player" && applied.text !== input.replacementText)
598
647
  throw new PlayCallChainError("The same replacement exchange ID is already bound to different player text.");
599
- const requestedSource = await this.#worlds.playTimeline.readContext(input.worldId, input.chainId);
600
- const requestedEventIndex = requestedSource?.value.events.findIndex(({ id }) => id === input.eventId);
601
- const requestedEvent = requestedEventIndex === undefined || requestedEventIndex < 0
602
- ? undefined
603
- : requestedSource?.value.events[requestedEventIndex];
604
- if (requestedSource === null ||
605
- requestedEventIndex === undefined ||
606
- requestedEventIndex < 0 ||
607
- requestedEvent?.kind !== "player" ||
608
- requestedEvent.committedHead === undefined)
609
- throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
610
- const requestedRestoresHead = playerRevisionRestoresHead(requestedSource.value, requestedEventIndex);
611
- const authority = await this.#worlds.readAuthorityHistory(input.worldId);
612
- const commit = authority.commits.find(({ head }) => head === appliedEvent.committedHead);
613
- const operation = await this.#worlds.getOperationOutcome(input.operationId);
614
- if (commit?.mode !== "timeline_revision" ||
615
- (operation.outcome !== "committed" &&
616
- operation.outcome !== "committed_materialization_pending") ||
617
- operation.worldId !== input.worldId ||
618
- operation.head !== appliedEvent.committedHead ||
619
- commit.timelineRevision?.restoresHead !== requestedRestoresHead ||
620
- commit.timelineRevision.replacesHead !== requestedEvent.committedHead ||
621
- commit.timelineRevision.requestFingerprint !==
622
- playerRevisionRequestFingerprint(input, requestedEvent.committedHead, requestedRestoresHead))
623
- throw new PlayCallChainError("The replacement exchange ID is already used by another commit.");
624
648
  return {
625
649
  outcome: "revised",
626
650
  worldId: input.worldId,
627
651
  playCallChain: projectView(source),
628
652
  };
629
653
  }
630
- const selectedContextIndex = sourceContexts.findIndex(({ chainId }) => chainId === input.chainId);
631
- if (selectedContextIndex < 0)
654
+ const contextIndex = contexts.findIndex(({ chainId }) => chainId === input.chainId);
655
+ if (contextIndex < 0)
632
656
  throw new PlayCallChainError("The model context to revise does not exist.");
633
- const sourceContext = sourceContexts[selectedContextIndex];
657
+ const sourceContext = contexts[contextIndex];
634
658
  if (input.continuation === "continue_context" &&
635
659
  sourceContext.importedFromArchive)
636
660
  throw new PlayCallChainError("This model context was imported from a backup; start a fresh context before continuing.");
637
- const selectedEventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
638
- const selectedEvent = sourceContext.events[selectedEventIndex];
639
- if (selectedEventIndex < 0 ||
661
+ const eventIndex = sourceContext.events.findIndex((event) => event.id === input.eventId);
662
+ const selectedEvent = sourceContext.events[eventIndex];
663
+ if (eventIndex < 0 ||
640
664
  selectedEvent?.kind !== "player" ||
641
665
  selectedEvent.committedHead === undefined)
642
666
  throw new PlayCallChainError("The selected event is not a committed player message.");
643
- return this.#applyPlayerRevision({
644
- request: input,
645
- source,
646
- sourceContexts,
647
- selectedContextIndex,
648
- selectedEventIndex,
649
- selectedEvent: {
650
- ...selectedEvent,
651
- committedHead: selectedEvent.committedHead,
652
- },
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);
653
702
  });
654
703
  }
655
- async #applyPlayerRevision(input) {
656
- const request = input.request;
657
- const sourceContext = input.sourceContexts[input.selectedContextIndex];
658
- const restoresHead = playerRevisionRestoresHead(sourceContext, input.selectedEventIndex);
659
- const freshPreparation = request.continuation === "fresh_context"
660
- ? 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)
661
734
  : null;
662
- const timelineGeneration = playerRevisionTimelineGeneration(input.source.timelineGeneration, request.operationId, request.worldId, request.continuation);
663
- const requestFingerprint = playerRevisionRequestFingerprint(request, input.selectedEvent.committedHead, restoresHead);
664
- const traceRetention = preparePlayerRevisionTrace(sourceContext, input.selectedEventIndex);
665
- // A timeline revision appends on the current audit head and restores the
666
- // selected message's logical parent, so a chain that lags the world (an
667
- // out-of-story world revision, or an earlier revision whose chain was not
668
- // published) must not block it. The commit still rechecks this head under
669
- // the world lock.
670
- const outcome = await this.#worlds.reviseTimeline({
671
- operationId: request.operationId,
672
- worldId: request.worldId,
673
- expectedCurrentHead: await this.#worlds.currentHead(request.worldId),
674
- restoresHead,
675
- replacesHead: input.selectedEvent.committedHead,
676
- replacementText: request.replacementText,
677
- requestFingerprint,
678
- });
735
+ const head = await this.#commitPlayerRevision(intent);
679
736
  const [baseline, selected, binding] = await Promise.all([
680
- this.#worlds.recoverEndpoint(request.worldId, sourceContext.baselineHead),
681
- this.#worlds.recoverEndpoint(request.worldId, restoresHead),
682
- this.#worlds.bindPlayCallChain(request.worldId),
737
+ this.#worlds.recoverEndpoint(worldId, sourceContext.baselineHead),
738
+ this.#worlds.recoverEndpoint(worldId, intent.restoresHead),
739
+ this.#worlds.bindPlayCallChain(worldId),
683
740
  ]);
684
- const continuedTrace = traceRetention.withReplacement({
685
- head: outcome.head,
686
- exchangeId: request.replacementExchangeId,
687
- text: request.replacementText,
688
- });
689
- if (binding.parentHead !== outcome.head)
690
- throw new PlayCallChainError("The timeline revision was committed, but current-world materialization has not reached the new endpoint.");
691
- if (request.continuation === "fresh_context") {
692
- if (freshPreparation === null)
693
- throw new PlayCallChainError("The fresh model context was not prepared before committing the timeline revision.");
694
- const revised = await this.#buildFreshPlayerRevision({
695
- 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,
696
753
  sourceContext,
697
- sourceContexts: input.sourceContexts,
698
- selectedContextIndex: input.selectedContextIndex,
699
754
  traceRetention,
700
755
  baseline,
701
756
  selected,
702
757
  binding,
703
- restoresHead,
704
- outcomeHead: outcome.head,
758
+ head,
759
+ chainId: resultChainId,
760
+ previousChainId,
705
761
  timelineGeneration,
706
- freshPreparation,
762
+ fresh,
707
763
  });
708
- await this.#worlds.playTimeline.persist(structuredClone(revised));
709
- await this.#worlds.completeArchiveFreshContext(request.worldId);
710
- const activeId = this.#worldChains.get(request.worldId);
711
- if (activeId !== undefined)
712
- this.#active.delete(activeId);
713
- this.#active.delete(sourceContext.chainId);
714
- this.#worldChains.delete(request.worldId);
715
- return {
716
- outcome: "revised",
717
- worldId: request.worldId,
718
- 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(),
719
783
  };
720
784
  }
721
- const revised = {
722
- ...continuedTrace.atEndpoint({ baseline, selected, binding }),
723
- schemaVersion: 3,
724
- kind: "play_call_chain",
725
- chainId: playerRevisionChainId(sourceContext.chainId, request.operationId, request.worldId, request.continuation),
726
- worldId: request.worldId,
727
- previousContexts: [],
728
- previousChainId: input.selectedContextIndex === 0
729
- ? null
730
- : input.sourceContexts[input.selectedContextIndex - 1].chainId,
731
- timelineGeneration,
732
- parentHead: outcome.head,
733
- updatedAt: Date.now(),
734
- };
735
785
  await this.#worlds.playTimeline.persist(structuredClone(revised));
736
- 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);
737
790
  if (activeId !== undefined)
738
791
  this.#active.delete(activeId);
739
792
  this.#active.delete(sourceContext.chainId);
740
- this.#worldChains.delete(request.worldId);
741
- return {
742
- outcome: "revised",
743
- worldId: request.worldId,
744
- playCallChain: projectView(revised),
745
- };
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);
746
807
  }
747
- async #prepareFreshPlayerRevision(request, restoresHead) {
748
- const binding = await this.#worlds.bindPlayCallChainAt(request.worldId, restoresHead);
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;
844
+ }
845
+ async #prepareFreshPlayerRevision(worldId, freshContext, restoresHead, replacementText) {
846
+ const binding = await this.#worlds.bindPlayCallChainAt(worldId, restoresHead);
749
847
  const documents = new FileNativePlayDocuments(binding.files);
750
- const documentMaintenance = await this.#bindDocumentMaintenance(documents, request.worldId, binding.parentHead);
848
+ const documentMaintenance = await this.#bindDocumentMaintenance(documents, worldId, binding.parentHead);
751
849
  const compilation = this.#compiler.compilePlayCallChain({
752
- 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),
753
851
  endpoint: {
754
- id: `${request.worldId}:${binding.parentHead}`,
852
+ id: `${worldId}:${binding.parentHead}`,
755
853
  commit: binding.parentHead,
756
854
  },
757
- hostBinding: structuredClone(request.freshContext.hostBinding),
855
+ hostBinding: structuredClone(freshContext.hostBinding),
758
856
  world: {
759
857
  controlFingerprint: fingerprintControl(binding.files),
760
858
  documentSnapshot: documents.snapshot,
@@ -766,24 +864,20 @@ export class PlayCallChain {
766
864
  documentMaintenanceUnavailableReason: documents.maintenanceWarning,
767
865
  },
768
866
  playerInputPlacement: "append",
769
- playerInput: request.replacementText,
770
- modelBinding: structuredClone(request.freshContext.modelBinding),
771
- }, request.freshContext.playPreset);
772
- const bootstrap = structuredClone(compilation.bootstrap);
773
- documents.bindBootstrap(bootstrap);
867
+ playerInput: replacementText,
868
+ modelBinding: structuredClone(freshContext.modelBinding),
869
+ }, freshContext.playPreset);
870
+ documents.bindBootstrap(compilation.bootstrap);
774
871
  return {
775
872
  binding,
776
- bootstrap,
873
+ bootstrap: structuredClone(compilation.bootstrap),
777
874
  tools: structuredClone(compilation.toolUniverse),
778
875
  followups: structuredClone(compilation.followups ?? []),
779
- authorization: documents.authorizationCheckpoint(),
780
876
  };
781
877
  }
782
878
  async #buildFreshPlayerRevision(input) {
783
- const { request, sourceContext } = input;
784
- let previousChainId = input.selectedContextIndex === 0
785
- ? null
786
- : input.sourceContexts[input.selectedContextIndex - 1].chainId;
879
+ const { intent, sourceContext, fresh } = input;
880
+ let previousChainId = input.previousChainId;
787
881
  const retainedPrefix = input.traceRetention.pagePrefixAtEndpoint({
788
882
  baseline: input.baseline,
789
883
  selected: input.selected,
@@ -794,38 +888,37 @@ export class PlayCallChain {
794
888
  ...retainedPrefix,
795
889
  schemaVersion: 3,
796
890
  kind: "play_call_chain",
797
- chainId: derivedChainId(sourceContext.chainId, "timeline-revision-prefix:" + request.operationId, request.worldId),
798
- worldId: request.worldId,
891
+ chainId: derivedChainId(sourceContext.chainId, "timeline-revision-prefix:" + intent.operationId, intent.worldId),
892
+ worldId: intent.worldId,
799
893
  previousContexts: [],
800
894
  previousChainId,
801
895
  timelineGeneration: input.timelineGeneration,
802
- parentHead: input.restoresHead,
896
+ parentHead: intent.restoresHead,
803
897
  updatedAt: Date.now(),
804
898
  };
805
899
  await this.#worlds.playTimeline.persistDetached(structuredClone(prefix));
806
900
  previousChainId = prefix.chainId;
807
901
  }
808
- const fresh = input.freshPreparation;
809
902
  return {
810
903
  schemaVersion: 3,
811
904
  kind: "play_call_chain",
812
- chainId: playerRevisionChainId(sourceContext.chainId, request.operationId, request.worldId, request.continuation),
813
- worldId: request.worldId,
905
+ chainId: input.chainId,
906
+ worldId: intent.worldId,
814
907
  previousContexts: [],
815
908
  previousChainId,
816
909
  timelineGeneration: input.timelineGeneration,
817
- baselineHead: input.restoresHead,
910
+ baselineHead: intent.restoresHead,
818
911
  baselineHistoryLength: Object.keys(fresh.binding.history).length,
819
- parentHead: input.outcomeHead,
912
+ parentHead: input.head,
820
913
  playPreset: {
821
- id: request.freshContext.playPreset.id,
822
- name: request.freshContext.playPreset.name,
823
- revision: request.freshContext.playPreset.revision,
914
+ id: input.freshContext.playPreset.id,
915
+ name: input.freshContext.playPreset.name,
916
+ revision: input.freshContext.playPreset.revision,
824
917
  },
825
918
  followups: structuredClone(fresh.followups),
826
- playPresetScriptsEnabled: request.freshContext.playPreset.scriptsEnabled,
827
- presetFiles: structuredClone(request.freshContext.playPreset.files),
828
- modelBinding: structuredClone(request.freshContext.modelBinding),
919
+ playPresetScriptsEnabled: input.freshContext.playPreset.scriptsEnabled,
920
+ presetFiles: structuredClone(input.freshContext.playPreset.files),
921
+ modelBinding: structuredClone(input.freshContext.modelBinding),
829
922
  status: "ready",
830
923
  canRetry: false,
831
924
  bootstrap: structuredClone(fresh.bootstrap),
@@ -834,24 +927,18 @@ export class PlayCallChain {
834
927
  layout: "world_state",
835
928
  files: Object.entries(input.binding.files).map(([path, contents]) => ({ path, contents })),
836
929
  }), 1),
837
- transcript: playerInputAppend(request.replacementText),
930
+ transcript: playerInputAppend(intent.replacementText),
838
931
  events: [
839
932
  {
840
933
  id: 1,
841
934
  kind: "player",
842
- exchangeId: request.replacementExchangeId,
843
- text: request.replacementText,
935
+ exchangeId: intent.replacementExchangeId,
936
+ text: intent.replacementText,
844
937
  context: "fresh",
845
- committedHead: input.outcomeHead,
938
+ committedHead: input.head,
846
939
  },
847
940
  ],
848
941
  completedTools: [],
849
- documentAuthorizationCheckpoints: [
850
- {
851
- afterEventId: 0,
852
- authorization: structuredClone(fresh.authorization),
853
- },
854
- ],
855
942
  changedDocuments: [],
856
943
  nextMaterials: structuredClone(input.binding.additionalMaterials),
857
944
  nextEventId: 2,
@@ -955,28 +1042,24 @@ export class PlayCallChain {
955
1042
  ? { retainCompleteContext: true }
956
1043
  : {}),
957
1044
  });
958
- if (input.targetWorldRoot !== undefined) {
959
- const retainedContexts = [
960
- ...input.sourceContexts.slice(0, input.selectedContextIndex),
961
- { ...sourceContext, events: input.sourceEvents },
962
- ];
963
- await this.#artifacts?.stageFork?.({
964
- sourceWorldId: input.sourceWorldId,
965
- targetWorldId: input.targetWorldId,
966
- targetWorldRoot: input.targetWorldRoot,
967
- timeline: retainedContexts.flatMap((context) => context.events.flatMap((event) => event.kind === "assistant" && event.committedHead !== undefined
968
- ? [
969
- {
970
- contextId: context.continuityContextId ?? context.chainId,
971
- eventId: event.id,
972
- head: event.committedHead,
973
- },
974
- ]
975
- : [])),
976
- });
977
- }
978
1045
  return projectView(derived);
979
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
+ }
980
1063
  forgetWorld(worldId) {
981
1064
  const activeId = this.#worldChains.get(worldId);
982
1065
  const active = activeId === undefined ? undefined : this.#active.get(activeId);
@@ -985,6 +1068,7 @@ export class PlayCallChain {
985
1068
  if (activeId !== undefined)
986
1069
  this.#active.delete(activeId);
987
1070
  this.#worldChains.delete(worldId);
1071
+ this.#hydrating.delete(worldId);
988
1072
  }
989
1073
  async #captureOrigin(session) {
990
1074
  const binding = await this.#worlds.bindPlayCallChain(session.worldId);
@@ -1036,14 +1120,6 @@ export class PlayCallChain {
1036
1120
  await this.#settlePlayerAdvance(session, advance);
1037
1121
  }
1038
1122
  catch (error) {
1039
- const accepted = await this.#worlds
1040
- .getOperationOutcome(advance.operationId)
1041
- .catch(() => ({ outcome: "not_started" }));
1042
- if (accepted.outcome === "committed" ||
1043
- accepted.outcome === "committed_materialization_pending") {
1044
- await this.#settlePlayerAdvance(session, advance);
1045
- return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
1046
- }
1047
1123
  const message = error instanceof Error
1048
1124
  ? error.message
1049
1125
  : "Failed to write the original player text.";
@@ -1059,7 +1135,7 @@ export class PlayCallChain {
1059
1135
  session.lastFailure = message;
1060
1136
  session.lastFailureKind = failureKind;
1061
1137
  await this.#persist(session);
1062
- await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1138
+ await this.#worlds.playAdvances.markSettled(advance);
1063
1139
  throw new PlayCallChainError(message);
1064
1140
  }
1065
1141
  return this.#dispatch(session, modelHost, structuredClone(nextRequest), 1, signal, observer);
@@ -1123,8 +1199,7 @@ export class PlayCallChain {
1123
1199
  "The player message is committed; the frozen model request is ready to continue.";
1124
1200
  session.lastFailureKind = "retryable";
1125
1201
  await this.#persist(session);
1126
- crashAtPlayAdvanceEdge("after_timeline_settled");
1127
- await this.#worlds.playAdvances.markSettled(advance, outcome.head);
1202
+ await this.#worlds.playAdvances.markSettled(advance);
1128
1203
  }
1129
1204
  async #prepareResponseSettlement(session, advance, response) {
1130
1205
  const existing = session.events.find(({ id }) => id === advance.eventId);
@@ -1159,15 +1234,13 @@ export class PlayCallChain {
1159
1234
  const prepared = [];
1160
1235
  const trailingEvents = [];
1161
1236
  let nextEventId = advance.eventId + 1;
1162
- // One persisted seed per advance: every resolution call draws from it by
1163
- // call identity, so recovery reproduces the same receipts.
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).
1164
1239
  const resolutionSeed = calls.some(({ name }) => isResolutionTool(name))
1165
- ? await this.#worlds.playAdvances.resolutionSeed(advance)
1240
+ ? randomBytes(32).toString("hex")
1166
1241
  : undefined;
1167
1242
  for (const call of calls) {
1168
1243
  const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale, resolutionSeed);
1169
- if (item.result.resolution !== undefined)
1170
- crashAtPlayAdvanceEdge("after_resolution");
1171
1244
  prepared.push(item);
1172
1245
  trailingEvents.push({
1173
1246
  id: nextEventId++,
@@ -1282,7 +1355,6 @@ export class PlayCallChain {
1282
1355
  : []),
1283
1356
  ],
1284
1357
  completedTools: [...workingTools.values()].map((item) => structuredClone(item)),
1285
- authorizationCheckpoint: session.documents.authorizationCheckpoint(),
1286
1358
  stateChanges: structuredClone(stateChanges),
1287
1359
  visibleText,
1288
1360
  ...(narrativeCheckpoint === undefined ? {} : { narrativeCheckpoint }),
@@ -1343,7 +1415,6 @@ export class PlayCallChain {
1343
1415
  historyMessageId: `message.${outcome.head.slice("commit:".length)}.1.narrator`,
1344
1416
  };
1345
1417
  }
1346
- crashAtPlayAdvanceEdge("after_authority_accepted");
1347
1418
  appendCommittedHistory(session, outcome.head, outcome.historyAppend);
1348
1419
  }
1349
1420
  finalizePreparedReceipts(settlement);
@@ -1365,29 +1436,10 @@ export class PlayCallChain {
1365
1436
  item.key,
1366
1437
  structuredClone(item),
1367
1438
  ]));
1368
- const lastToolEventId = settlement.trailingEvents.findLast(({ kind }) => kind === "tool_result")?.id;
1369
- if (lastToolEventId !== undefined) {
1370
- session.documentAuthorizationCheckpoints =
1371
- session.documentAuthorizationCheckpoints.filter(({ afterEventId }) => afterEventId < advance.eventId);
1372
- session.documentAuthorizationCheckpoints.push({
1373
- afterEventId: lastToolEventId,
1374
- authorization: structuredClone(settlement.authorizationCheckpoint),
1375
- });
1376
- }
1377
1439
  if (committedHead !== undefined)
1378
1440
  session.parentHead = committedHead;
1379
1441
  if (settlement.stateChanges.length > 0) {
1380
- const currentAuthorization = session.documents.authorizationCheckpoint();
1381
- if (currentAuthorization.stateFingerprint ===
1382
- settlement.authorizationCheckpoint.stateFingerprint)
1383
- session.documents.acceptCommittedState();
1384
- else {
1385
- const binding = await this.#worlds.bindPlayCallChain(session.worldId);
1386
- session.documents = new FileNativePlayDocuments(binding.files);
1387
- session.documents.restoreAuthorizationCheckpoint(settlement.authorizationCheckpoint);
1388
- session.history = historyEntries(binding.history, binding.historyBackgrounds);
1389
- session.nextMaterials = structuredClone(binding.additionalMaterials);
1390
- }
1442
+ session.documents.acceptCommittedState();
1391
1443
  mergeChangedDocuments(session, settlement.stateChanges);
1392
1444
  }
1393
1445
  if (settlement.stateChanges.length > 0)
@@ -1440,24 +1492,9 @@ export class PlayCallChain {
1440
1492
  result = { kind: "terminal", status: session.status };
1441
1493
  }
1442
1494
  await this.#persist(session);
1443
- crashAtPlayAdvanceEdge("after_timeline_settled");
1444
- await this.#worlds.playAdvances.markSettled(advance, committedHead ?? session.parentHead);
1495
+ await this.#worlds.playAdvances.markSettled(advance);
1445
1496
  return result;
1446
1497
  }
1447
- async #recoverAcceptedResponseAdvance(session, advance) {
1448
- const outcome = await this.#worlds
1449
- .getOperationOutcome(advance.operationId)
1450
- .catch(() => ({ outcome: "not_started" }));
1451
- if (outcome.outcome !== "committed" &&
1452
- outcome.outcome !== "committed_materialization_pending")
1453
- return null;
1454
- const current = await this.#worlds.playAdvances.readCurrent(advance.worldId, advance.chainId);
1455
- if (current?.base.advanceKind !== "response" ||
1456
- current.base.advanceId !== advance.advanceId ||
1457
- current.settlementPrepared === null)
1458
- throw new PlayCallChainError("Authority accepted a response whose durable settlement is missing.");
1459
- return this.#settleResponseAdvance(session, advance, current.settlementPrepared.settlement);
1460
- }
1461
1498
  async #dispatch(session, modelHost, firstRequest, firstAttempt, signal, observer) {
1462
1499
  let request = modelHost.freezeRequest?.(firstRequest) ?? structuredClone(firstRequest);
1463
1500
  let attempt = firstAttempt;
@@ -1503,7 +1540,6 @@ export class PlayCallChain {
1503
1540
  createdAt: Date.now(),
1504
1541
  };
1505
1542
  await this.#worlds.playAdvances.begin(advance);
1506
- crashAtPlayAdvanceEdge("after_response_advance_began");
1507
1543
  this.#notifySnapshot(observer, session);
1508
1544
  if (signal.aborted)
1509
1545
  return this.#cancelBeforeProviderDispatch(session, request, attempt, observer, advance);
@@ -1587,17 +1623,13 @@ export class PlayCallChain {
1587
1623
  });
1588
1624
  }
1589
1625
  await this.#persist(session);
1590
- await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1626
+ await this.#worlds.playAdvances.markSettled(advance);
1591
1627
  this.#notifySnapshot(observer, session);
1592
1628
  return projectView(session);
1593
1629
  }
1594
1630
  const durableResponse = durableModelResponse(response);
1595
1631
  try {
1596
- await this.#worlds.playAdvances.recordProviderCompleted(advance, durableResponse);
1597
- crashAtPlayAdvanceEdge("after_provider_completed");
1598
1632
  const settlement = await this.#prepareResponseSettlement(session, advance, durableResponse);
1599
- await this.#worlds.playAdvances.recordSettlementPrepared(advance, settlement);
1600
- crashAtPlayAdvanceEdge("after_settlement_prepared");
1601
1633
  const failedTools = settlement.trailingEvents.filter((candidate) => candidate.kind === "tool_result" && !candidate.ok);
1602
1634
  if (failedTools.length > 0 && response.diagnostics !== undefined)
1603
1635
  await this.#failureLog?.recordFailure({
@@ -1679,17 +1711,6 @@ export class PlayCallChain {
1679
1711
  attempt = 1;
1680
1712
  }
1681
1713
  catch (error) {
1682
- const recovered = await this.#recoverAcceptedResponseAdvance(session, advance);
1683
- if (recovered !== null) {
1684
- if (recovered.kind === "continue") {
1685
- request = recovered.nextRequest;
1686
- attempt = 1;
1687
- this.#notifySnapshot(observer, session);
1688
- continue;
1689
- }
1690
- this.#notifySnapshot(observer, session);
1691
- return projectView(session);
1692
- }
1693
1714
  const message = error instanceof Error
1694
1715
  ? error.message
1695
1716
  : "Call-chain processing failed.";
@@ -1740,7 +1761,7 @@ export class PlayCallChain {
1740
1761
  delete session.lastFailureKind;
1741
1762
  await this.#persist(session);
1742
1763
  if (advance !== undefined)
1743
- await this.#worlds.playAdvances.markSettled(advance, session.parentHead);
1764
+ await this.#worlds.playAdvances.markSettled(advance);
1744
1765
  this.#notifySnapshot(observer, session);
1745
1766
  return projectView(session);
1746
1767
  }
@@ -1859,16 +1880,9 @@ export class PlayCallChain {
1859
1880
  await this.#reconcileSessionAdvance(active);
1860
1881
  return active;
1861
1882
  }
1862
- const persisted = await this.#readPersisted(worldId);
1863
- if (persisted?.chainId !== chainId)
1883
+ const session = await this.#loadWorldSession(worldId);
1884
+ if (session?.chainId !== chainId)
1864
1885
  throw new PlayCallChainError("The call chain does not exist; start from the latest world state.");
1865
- const advance = await this.#worlds.playAdvances.readCurrent(worldId, chainId);
1866
- const session = await this.#hydrateSession(persisted, advance);
1867
- this.#active.set(chainId, session);
1868
- this.#worldChains.set(worldId, chainId);
1869
- await this.#reconcileSessionAdvance(session, advance);
1870
- if (session.status === "running")
1871
- await this.#interruptAbandonedDispatch(session);
1872
1886
  const currentHead = await this.#worlds.currentHead(worldId);
1873
1887
  if (currentHead !== session.parentHead)
1874
1888
  throw new PlayCallChainError("The call-chain record differs from the current world endpoint; use a fresh context.");
@@ -1886,27 +1900,15 @@ export class PlayCallChain {
1886
1900
  const currentBinding = await this.#worlds.bindPlayCallChain(persisted.worldId);
1887
1901
  // A completed chain still describes its own immutable endpoint after a
1888
1902
  // world revision. Restore its reads against that state, not the revised
1889
- // current tree. An unsettled advance keeps the existing crash-recovery
1890
- // path because Authority may already contain its prepared result.
1891
- const binding = currentBinding.parentHead !== persisted.parentHead &&
1892
- 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
1893
1906
  ? await this.#worlds.bindPlayCallChainAt(persisted.worldId, persisted.parentHead)
1894
1907
  : currentBinding;
1895
- let documents;
1896
- try {
1897
- documents = restorePlayDocuments(binding.files, persisted, persisted.events);
1898
- }
1899
- catch (error) {
1900
- const prepared = advance?.settlementPrepared?.settlement;
1901
- if (prepared === undefined)
1902
- throw error;
1903
- documents = new FileNativePlayDocuments(binding.files);
1904
- documents.restoreAuthorizationCheckpoint(prepared.authorizationCheckpoint);
1905
- }
1908
+ const documents = restorePlayDocuments(binding.files, persisted, persisted.events);
1906
1909
  await this.#bindDocumentMaintenance(documents, persisted.worldId, binding.parentHead);
1907
1910
  return {
1908
1911
  ...structuredClone(persisted),
1909
- documentAuthorizationCheckpoints: persisted.documentAuthorizationCheckpoints?.map((checkpoint) => structuredClone(checkpoint)) ?? [],
1910
1912
  nextMaterials: structuredClone(binding.additionalMaterials),
1911
1913
  documents,
1912
1914
  history: historyEntries(binding.history, binding.historyBackgrounds),
@@ -1919,49 +1921,45 @@ export class PlayCallChain {
1919
1921
  eventCount: 0,
1920
1922
  transcriptCount: 0,
1921
1923
  completedToolCount: 0,
1922
- authorizationCheckpointCount: 0,
1923
1924
  },
1924
1925
  };
1925
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
+ */
1926
1932
  async #reconcileSessionAdvance(session, loaded) {
1927
- const current = loaded ??
1928
- (await this.#worlds.playAdvances.readCurrent(session.worldId, session.chainId));
1929
- if (current?.settled !== null)
1933
+ const advance = loaded === undefined
1934
+ ? await this.#worlds.playAdvances.readCurrent(session.worldId, session.chainId)
1935
+ : loaded;
1936
+ if (advance === null && session.status !== "running")
1930
1937
  return;
1931
- if (current.base.advanceKind === "player") {
1932
- await this.#settlePlayerAdvance(session, current.base);
1933
- return;
1934
- }
1935
- if (current.providerCompleted === null) {
1936
- await this.#interruptAbandonedDispatch(session, current.base);
1937
- await this.#worlds.playAdvances.markSettled(current.base, session.parentHead);
1938
- return;
1939
- }
1940
- let settlement = current.settlementPrepared?.settlement;
1941
- if (settlement === undefined) {
1942
- settlement = await this.#prepareResponseSettlement(session, current.base, current.providerCompleted.response);
1943
- await this.#worlds.playAdvances.recordSettlementPrepared(current.base, settlement);
1944
- }
1945
- await this.#settleResponseAdvance(session, current.base, settlement);
1938
+ await this.#interruptAbandonedDispatch(session, advance);
1939
+ if (advance !== null)
1940
+ await this.#worlds.playAdvances.markSettled(advance);
1946
1941
  }
1947
1942
  async #interruptAbandonedDispatch(session, advance) {
1948
- const streaming = session.events.findLast((event) => event.kind === "assistant" &&
1949
- event.status === "streaming" &&
1950
- (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));
1951
1950
  if (streaming !== undefined)
1952
1951
  streaming.status = "interrupted";
1953
- const dispatchMayHaveStarted = advance !== undefined;
1954
- const canRetry = !dispatchMayHaveStarted && session.lastRequest !== null;
1955
- const message = dispatchMayHaveStarted
1956
- ? "The service stopped after model dispatch may have started; the outcome is unknown and the request cannot be replayed. Use a fresh context."
1957
- : canRetry
1958
- ? "The service stopped before model dispatch; the frozen request can be retried."
1959
- : "The service stopped before preparing a model request; use a fresh context.";
1960
- const failureKind = dispatchMayHaveStarted
1961
- ? "outcome_unknown"
1962
- : canRetry
1963
- ? "retryable"
1964
- : "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";
1965
1963
  session.events.push({
1966
1964
  id: session.nextEventId++,
1967
1965
  kind: "failure",
@@ -2348,31 +2346,10 @@ function playerRevisionRestoresHead(source, selectedEventIndex) {
2348
2346
  restoresHead = event.committedHead;
2349
2347
  return restoresHead;
2350
2348
  }
2351
- function playerRevisionRequestFingerprint(request, selectedHead, restoresHead) {
2352
- const base = {
2353
- schema: "narraeon.timeline-revision-request/v1",
2354
- chainId: request.chainId,
2355
- eventId: request.eventId,
2356
- selectedHead,
2357
- restoresHead,
2358
- replacementExchangeId: request.replacementExchangeId,
2359
- replacementText: request.replacementText,
2360
- };
2361
- // Keep the released continuation fingerprint byte-for-byte recoverable;
2362
- // the fresh strategy owns a new request shape with an explicit mode.
2363
- const value = request.continuation === "continue_context"
2364
- ? base
2365
- : {
2366
- ...base,
2367
- schema: "narraeon.timeline-revision-request/v2",
2368
- continuation: request.continuation,
2369
- };
2370
- return ("sha256:" + createHash("sha256").update(JSON.stringify(value)).digest("hex"));
2371
- }
2372
- function playerRevisionChainId(sourceChainId, operationId, worldId, continuation) {
2349
+ function revisionChainId(sourceChainId, operationId, worldId, continuation) {
2373
2350
  return derivedChainId(sourceChainId, `${continuation === "fresh_context" ? "timeline-revision-fresh" : "timeline-revision"}:${operationId}`, worldId);
2374
2351
  }
2375
- function playerRevisionTimelineGeneration(sourceGeneration, operationId, worldId, continuation) {
2352
+ function revisionTimelineGeneration(sourceGeneration, operationId, worldId, continuation) {
2376
2353
  return `timeline:${createHash("sha256")
2377
2354
  .update(JSON.stringify({
2378
2355
  schema: "narraeon.player-revision-timeline-generation/v1",
@@ -2437,11 +2414,6 @@ function resolutionToolResult(name, args, seed, identity, locale) {
2437
2414
  resolution,
2438
2415
  };
2439
2416
  }
2440
- function crashAtPlayAdvanceEdge(edge) {
2441
- if (process.env.NODE_ENV === "test" &&
2442
- process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PLAY_ADVANCE_EDGE === edge)
2443
- throw new Error(`Simulated process exit at play advance edge: ${edge}`);
2444
- }
2445
2417
  function frozenFollowupPresentations(session) {
2446
2418
  const files = session.promptRuns?.at(-1)?.presetFiles ?? session.presetFiles;
2447
2419
  if (files === undefined)