narraeon 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/README.zh-CN.md +8 -0
- package/dist/node/protocol/v1.js +12 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/V1Runtime.js +5 -5
- package/dist/node/runtime/archive/WorldArchives.js +14 -46
- package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
- package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
- package/dist/node/runtime/authoring/ModelToolConversation.js +25 -2
- package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
- package/dist/node/runtime/config/dataRootLock.js +108 -0
- package/dist/node/runtime/content/ContentWorkspace.js +0 -10
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +7 -44
- package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
- package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
- package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +32 -207
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +29 -239
- package/dist/node/runtime/play/PlayCallChain.js +338 -366
- package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
- package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
- package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
- package/dist/node/runtime/prompt/WorldMaintenanceReport.js +39 -7
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +14 -6
- package/dist/node/runtime/setting/SettingImprovementSession.js +39 -25
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +230 -781
- package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
- package/dist/node/runtime/world/WorldActivity.js +14 -22
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
- package/dist/node/runtime/world/WorldQueue.js +46 -0
- package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +1 -13
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +29 -2
- package/dist/node/server/mobile.js +52 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/web/assets/index-CXnFaYK9.js +1323 -0
- package/dist/web/assets/{index-BJ-1Mb0e.css → index-LPMV0pl3.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
- package/dist/web/assets/index-BgBL8G-A.js +0 -1323
|
@@ -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
|
|
393
|
-
this
|
|
394
|
-
this.#
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
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
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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 (
|
|
597
|
-
if (
|
|
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
|
|
631
|
-
if (
|
|
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 =
|
|
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
|
|
638
|
-
const selectedEvent = sourceContext.events[
|
|
639
|
-
if (
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
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(
|
|
681
|
-
this.#worlds.recoverEndpoint(
|
|
682
|
-
this.#worlds.bindPlayCallChain(
|
|
737
|
+
this.#worlds.recoverEndpoint(worldId, sourceContext.baselineHead),
|
|
738
|
+
this.#worlds.recoverEndpoint(worldId, intent.restoresHead),
|
|
739
|
+
this.#worlds.bindPlayCallChain(worldId),
|
|
683
740
|
]);
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
704
|
-
|
|
758
|
+
head,
|
|
759
|
+
chainId: resultChainId,
|
|
760
|
+
previousChainId,
|
|
705
761
|
timelineGeneration,
|
|
706
|
-
|
|
762
|
+
fresh,
|
|
707
763
|
});
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
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(
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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,
|
|
848
|
+
const documentMaintenance = await this.#bindDocumentMaintenance(documents, worldId, binding.parentHead);
|
|
751
849
|
const compilation = this.#compiler.compilePlayCallChain({
|
|
752
|
-
extensionControls: await this.#worlds.extensionControls(
|
|
850
|
+
extensionControls: await this.#worlds.extensionControls(worldId, freshContext.playPreset, this.#compiler.locale),
|
|
753
851
|
endpoint: {
|
|
754
|
-
id: `${
|
|
852
|
+
id: `${worldId}:${binding.parentHead}`,
|
|
755
853
|
commit: binding.parentHead,
|
|
756
854
|
},
|
|
757
|
-
hostBinding: structuredClone(
|
|
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:
|
|
770
|
-
modelBinding: structuredClone(
|
|
771
|
-
},
|
|
772
|
-
|
|
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 {
|
|
784
|
-
let previousChainId = input.
|
|
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:" +
|
|
798
|
-
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:
|
|
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:
|
|
813
|
-
worldId:
|
|
905
|
+
chainId: input.chainId,
|
|
906
|
+
worldId: intent.worldId,
|
|
814
907
|
previousContexts: [],
|
|
815
908
|
previousChainId,
|
|
816
909
|
timelineGeneration: input.timelineGeneration,
|
|
817
|
-
baselineHead:
|
|
910
|
+
baselineHead: intent.restoresHead,
|
|
818
911
|
baselineHistoryLength: Object.keys(fresh.binding.history).length,
|
|
819
|
-
parentHead: input.
|
|
912
|
+
parentHead: input.head,
|
|
820
913
|
playPreset: {
|
|
821
|
-
id:
|
|
822
|
-
name:
|
|
823
|
-
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:
|
|
827
|
-
presetFiles: structuredClone(
|
|
828
|
-
modelBinding: structuredClone(
|
|
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(
|
|
930
|
+
transcript: playerInputAppend(intent.replacementText),
|
|
838
931
|
events: [
|
|
839
932
|
{
|
|
840
933
|
id: 1,
|
|
841
934
|
kind: "player",
|
|
842
|
-
exchangeId:
|
|
843
|
-
text:
|
|
935
|
+
exchangeId: intent.replacementExchangeId,
|
|
936
|
+
text: intent.replacementText,
|
|
844
937
|
context: "fresh",
|
|
845
|
-
committedHead: input.
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1163
|
-
//
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1863
|
-
if (
|
|
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
|
|
1890
|
-
//
|
|
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
|
-
|
|
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
|
|
1928
|
-
|
|
1929
|
-
|
|
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
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
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
|
|
1949
|
-
|
|
1950
|
-
|
|
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
|
-
|
|
1954
|
-
|
|
1955
|
-
const
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
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
|
|
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
|
|
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)
|