dsh-ptc-plus 0.4.2 → 0.4.3
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.en.md +166 -76
- package/README.md +167 -77
- package/assets/dsh-ptc-plus-banner-en.webp +0 -0
- package/assets/dsh-ptc-plus-banner-zh.webp +0 -0
- package/assets/ptc-plus-icon.svg +5 -0
- package/client.js +593 -306
- package/compiler-core.cjs +162 -160
- package/compiler-core.cjs.map +3 -3
- package/docs/README.md +3 -1
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +8 -7
- package/docs/adr/0008-define-agent-operating-contract.md +2 -2
- package/docs/adr/0010-session-log-derived-recovery-tips.md +7 -0
- package/docs/adr/0016-archive-resolved-review-findings.md +3 -3
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +3 -3
- package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -0
- package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +14 -3
- package/docs/adr/0023-global-user-bindings.md +1 -1
- package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +23 -7
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +4 -0
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +26 -6
- package/docs/adr/0031-close-semantic-obligations-by-owner.md +29 -0
- package/docs/adr/README.md +4 -4
- package/docs/architecture.md +6 -5
- package/docs/binding-review-placement-design.md +1 -1
- package/docs/client-ui.md +8 -4
- package/docs/durability-design.md +7 -7
- package/docs/installation.md +26 -8
- package/docs/runtime-reference.md +18 -11
- package/docs/semantic-validation.md +7 -3
- package/docs/user-bindings.en.md +7 -6
- package/docs/user-bindings.md +6 -5
- package/docs/value-wire.md +1 -1
- package/docs/verification.md +38 -1
- package/index.js +39 -34
- package/internal/async-local-storage-intrinsics.js +12 -7
- package/internal/callable-source-facts.js +37 -1
- package/internal/cell-analysis.js +64 -9
- package/internal/config-spec.js +1 -1
- package/internal/cordis-tools-owner.js +20 -10
- package/internal/edit-transport-owner.js +80 -20
- package/internal/execution-seam-compat.js +192 -30
- package/internal/kernel-worker.js +162 -72
- package/internal/message-sources.js +14 -0
- package/internal/module-compilation.js +2 -1
- package/internal/repl-memory-projection.js +10 -0
- package/internal/repl-scope-normalizer.js +72 -35
- package/internal/repl-value-observer.js +141 -45
- package/internal/runtime-config.js +62 -2
- package/internal/runtime-intrinsics.js +3 -0
- package/internal/runtime-messages.js +6 -4
- package/internal/session-cell-executor.js +24 -1
- package/internal/session-journal-recovery.js +267 -67
- package/internal/session-journal.js +466 -40
- package/internal/session-log-view.js +5 -4
- package/internal/session-runtime.js +45 -21
- package/internal/session-state.js +7 -29
- package/internal/settings-compat.js +66 -1
- package/internal/source-position-map.js +19 -0
- package/internal/user-binding-console-worker.js +23 -9
- package/internal/user-binding-draft-projection.js +3 -2
- package/internal/user-bindings-owner.js +2 -1
- package/internal/user-bindings.js +6 -1
- package/internal/validated-parse-repair.js +6 -2
- package/internal/value-wire.js +95 -30
- package/internal/worker-client.js +99 -8
- package/internal/worker-cwd-virtualization.js +10 -9
- package/internal/worker-output-capture.js +187 -0
- package/internal/worker-output-fence.js +26 -0
- package/internal/worker-realm-surfaces.js +197 -0
- package/internal/worker-repl-realm.js +10 -4
- package/package.json +36 -4
- package/src/client-activity.js +4 -1
- package/src/client-authoring-view.js +121 -62
- package/src/client-console.js +4 -2
- package/src/client-copy.js +4 -2
- package/src/client-feature-gates.js +7 -9
- package/src/client-host-compat.js +104 -27
- package/src/client-repl-view.js +3 -1
- package/src/client-styles.js +12 -1
- package/src/client-tool-view.js +3 -1
- package/src/client-transport.js +2 -2
- package/src/client-workbench.js +4 -3
- package/src/client.js +56 -17
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
1
2
|
import { isRecord } from './record-utils.js'
|
|
2
3
|
import { sessionEvents } from './session-events.js'
|
|
3
4
|
import {
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
REPL_TOOL_NAMES,
|
|
9
10
|
} from './session-journal-schema.js'
|
|
10
11
|
import {
|
|
12
|
+
isCanonicalSequence,
|
|
11
13
|
normalizeDerivedEditResult,
|
|
12
14
|
normalizeJournal,
|
|
13
15
|
normalizeRecoveryBoundaries,
|
|
@@ -16,6 +18,15 @@ import {
|
|
|
16
18
|
validatedRewrites,
|
|
17
19
|
} from './session-journal.js'
|
|
18
20
|
|
|
21
|
+
const RECOVERY_BOUNDARY_EVIDENCE = Symbol('recoveryBoundaryEvidence')
|
|
22
|
+
const MODEL_VISIBLE_SURFACE_EVENTS = new Set([
|
|
23
|
+
'system/message',
|
|
24
|
+
'developer/message',
|
|
25
|
+
'user/message',
|
|
26
|
+
'assistant/message',
|
|
27
|
+
'tool/result',
|
|
28
|
+
])
|
|
29
|
+
|
|
19
30
|
function sourceForRunCall(call) {
|
|
20
31
|
try {
|
|
21
32
|
const args = JSON.parse(call.data.arguments)
|
|
@@ -96,7 +107,11 @@ function applyRecord(state, record, invalidCallSeqs) {
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
function recordEventSeq(record) {
|
|
99
|
-
|
|
110
|
+
// A settlement that the Host re-published as a pruned clone carries the
|
|
111
|
+
// clone's row sequence, but it is still the same settlement: ordering and
|
|
112
|
+
// boundary application use its settlement position so a recorded boundary
|
|
113
|
+
// finds the call it names already folded.
|
|
114
|
+
return record.result?.positionSeq ?? record.result?.eventSeq ?? record.call.seq
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
function foldRecords(records, invalidCallSeqs, options = {}) {
|
|
@@ -123,24 +138,32 @@ function dependsOn(nodes, index, ancestor) {
|
|
|
123
138
|
|
|
124
139
|
/** Return executable call sequences whose provenance remains model-visible. */
|
|
125
140
|
export function visibleExecutableCallSeqs(session) {
|
|
126
|
-
|
|
141
|
+
let events
|
|
127
142
|
let nodes
|
|
128
143
|
try {
|
|
144
|
+
events = sessionEvents(session)
|
|
129
145
|
nodes = session?.surface?.nodes
|
|
130
146
|
} catch {
|
|
131
|
-
return
|
|
147
|
+
return new Set()
|
|
132
148
|
}
|
|
133
|
-
if (!Array.isArray(events) || !Array.isArray(nodes)) return
|
|
149
|
+
if (!Array.isArray(events) || !Array.isArray(nodes)) return new Set()
|
|
134
150
|
const eventBySeq = new Map()
|
|
135
|
-
|
|
151
|
+
const duplicateEventSeqs = new Set()
|
|
152
|
+
for (const event of events) {
|
|
153
|
+
if (!isCanonicalSequence(event?.seq)) return new Set()
|
|
154
|
+
if (eventBySeq.has(event.seq)) duplicateEventSeqs.add(event.seq)
|
|
155
|
+
else eventBySeq.set(event.seq, event)
|
|
156
|
+
}
|
|
157
|
+
const visible = new Set()
|
|
136
158
|
for (const seq of nodes) {
|
|
137
|
-
|
|
159
|
+
const event = eventBySeq.get(seq)
|
|
160
|
+
if (!isCanonicalSequence(seq) || event === undefined
|
|
161
|
+
|| duplicateEventSeqs.has(seq) || !MODEL_VISIBLE_SURFACE_EVENTS.has(event.type)
|
|
162
|
+
|| visible.has(seq)) return new Set()
|
|
163
|
+
visible.add(seq)
|
|
138
164
|
}
|
|
139
|
-
const visible = new Set(nodes)
|
|
140
|
-
const resultSources = new Set()
|
|
141
165
|
const visibleAssistantCallSeqs = new Set()
|
|
142
166
|
const pendingAssistantCalls = new Map()
|
|
143
|
-
let assistantCallEvidence = false
|
|
144
167
|
for (const event of events) {
|
|
145
168
|
if (event?.type === 'turn/start' || event?.type === 'turn/end') {
|
|
146
169
|
pendingAssistantCalls.clear()
|
|
@@ -153,41 +176,34 @@ export function visibleExecutableCallSeqs(session) {
|
|
|
153
176
|
for (const part of content) {
|
|
154
177
|
if (part?.type === 'tool-call' && typeof part.id === 'string') {
|
|
155
178
|
const isVisible = visible.has(event.seq)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
pendingAssistantCalls.set(part.id, occurrences)
|
|
179
|
+
const candidate = {
|
|
180
|
+
isVisible,
|
|
181
|
+
name: typeof part.name === 'string' ? part.name : undefined,
|
|
182
|
+
arguments: typeof part.arguments === 'string' ? part.arguments : undefined,
|
|
161
183
|
}
|
|
162
|
-
|
|
184
|
+
pendingAssistantCalls.set(part.id,
|
|
185
|
+
pendingAssistantCalls.has(part.id) ? null : candidate)
|
|
163
186
|
}
|
|
164
187
|
}
|
|
165
188
|
}
|
|
166
189
|
continue
|
|
167
190
|
}
|
|
168
191
|
if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
192
|
+
const candidate = pendingAssistantCalls.get(event.data.callId)
|
|
193
|
+
pendingAssistantCalls.delete(event.data.callId)
|
|
194
|
+
if (candidate !== undefined && candidate !== null) {
|
|
195
|
+
if (candidate.isVisible && REPL_TOOL_NAMES.has(event.data.name)
|
|
196
|
+
&& candidate.name === event.data.name
|
|
197
|
+
&& candidate.arguments === event.data.arguments) {
|
|
174
198
|
visibleAssistantCallSeqs.add(event.seq)
|
|
175
199
|
}
|
|
176
200
|
}
|
|
177
201
|
}
|
|
178
|
-
if (visible.has(event?.seq)
|
|
179
|
-
&& event?.type === 'tool/result' && Array.isArray(event.sourceEventSeqs)) {
|
|
180
|
-
for (const sourceSeq of event.sourceEventSeqs) {
|
|
181
|
-
if (Number.isSafeInteger(sourceSeq) && sourceSeq >= 0) resultSources.add(sourceSeq)
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
202
|
}
|
|
185
203
|
const calls = new Set()
|
|
186
204
|
for (const event of events) {
|
|
187
205
|
if (event?.type !== 'tool/call' || !REPL_TOOL_NAMES.has(event.data?.name)) continue
|
|
188
|
-
if (
|
|
189
|
-
? visibleAssistantCallSeqs.has(event.seq)
|
|
190
|
-
: (visible.has(event.seq) || resultSources.has(event.seq))) calls.add(event.seq)
|
|
206
|
+
if (visibleAssistantCallSeqs.has(event.seq)) calls.add(event.seq)
|
|
191
207
|
}
|
|
192
208
|
return calls
|
|
193
209
|
}
|
|
@@ -202,7 +218,7 @@ function timelineRun(call, result, eventIndex, journal) {
|
|
|
202
218
|
}
|
|
203
219
|
return Object.freeze({
|
|
204
220
|
index: eventIndex,
|
|
205
|
-
callSeq:
|
|
221
|
+
callSeq: isCanonicalSequence(call.seq) ? call.seq : undefined,
|
|
206
222
|
args,
|
|
207
223
|
source: typeof args?.code === 'string' ? args.code : undefined,
|
|
208
224
|
journal,
|
|
@@ -213,7 +229,7 @@ function timelineRun(call, result, eventIndex, journal) {
|
|
|
213
229
|
function timelineDerivedRun(call, result, eventIndex, derived) {
|
|
214
230
|
return Object.freeze({
|
|
215
231
|
index: eventIndex,
|
|
216
|
-
callSeq:
|
|
232
|
+
callSeq: isCanonicalSequence(call.seq) ? call.seq : undefined,
|
|
217
233
|
args: Object.freeze({ description: derived.description }),
|
|
218
234
|
source: derived.code,
|
|
219
235
|
journal: derived.journal,
|
|
@@ -235,7 +251,7 @@ function pruneWindowForEvent(event, eventIndex) {
|
|
|
235
251
|
&& shadowedRange.end === shadowedSeqs[shadowedSeqs.length - 1])
|
|
236
252
|
const valid = shadowedSeqs.length > 0
|
|
237
253
|
&& seqs.size === shadowedSeqs.length
|
|
238
|
-
&& shadowedSeqs.every(
|
|
254
|
+
&& shadowedSeqs.every(isCanonicalSequence)
|
|
239
255
|
&& shadowedSeqs.every((seq, index) => index === 0 || seq > shadowedSeqs[index - 1])
|
|
240
256
|
&& rangeValid
|
|
241
257
|
return { seqs: valid ? seqs : new Set(), lastEventIndex: eventIndex }
|
|
@@ -247,7 +263,7 @@ function continuesPruneWindow(window, event, eventIndex) {
|
|
|
247
263
|
|
|
248
264
|
function identifiesPrunedResult(window, event, eventIndex) {
|
|
249
265
|
const sourceSeq = event.sourceEventSeqs?.[0]
|
|
250
|
-
return
|
|
266
|
+
return isCanonicalSequence(sourceSeq)
|
|
251
267
|
&& window?.seqs.has(sourceSeq)
|
|
252
268
|
&& eventIndex === window.lastEventIndex + 1
|
|
253
269
|
&& Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length === 1
|
|
@@ -260,16 +276,43 @@ function replacesPendingCall(candidate, event, pruneSeq) {
|
|
|
260
276
|
const sourceSeq = event.sourceEventSeqs[0]
|
|
261
277
|
const surfaceOp = event.surfaceOp
|
|
262
278
|
const orderedIdentity = REPL_TOOL_NAMES.has(candidate.event.data.name)
|
|
263
|
-
&&
|
|
279
|
+
&& isCanonicalSequence(candidateSeq)
|
|
264
280
|
&& sourceSeq > candidateSeq
|
|
265
|
-
&&
|
|
266
|
-
&&
|
|
281
|
+
&& isCanonicalSequence(pruneSeq)
|
|
282
|
+
&& isCanonicalSequence(event.seq) && event.seq - pruneSeq === 1
|
|
267
283
|
&& sourceSeq < pruneSeq
|
|
268
|
-
const
|
|
269
|
-
|
|
284
|
+
const legacyReplacement = isRecord(surfaceOp)
|
|
285
|
+
&& Reflect.ownKeys(surfaceOp).length === 3
|
|
286
|
+
&& surfaceOp.op === 'replace'
|
|
287
|
+
&& surfaceOp.start === sourceSeq
|
|
288
|
+
&& surfaceOp.end === sourceSeq
|
|
289
|
+
const currentReplacement = isRecord(surfaceOp)
|
|
290
|
+
&& Reflect.ownKeys(surfaceOp).length === 3
|
|
291
|
+
&& surfaceOp.op === 'replace'
|
|
292
|
+
&& surfaceOp.startSeq === sourceSeq
|
|
293
|
+
&& surfaceOp.endSeq === sourceSeq
|
|
294
|
+
const exactReplacement = surfaceOp === undefined || legacyReplacement || currentReplacement
|
|
270
295
|
return orderedIdentity && exactReplacement
|
|
271
296
|
}
|
|
272
297
|
|
|
298
|
+
/** The exact single-event surface replacement the Host pruner appends. */
|
|
299
|
+
function exactResultReplacement(event, sourceSeq) {
|
|
300
|
+
const surfaceOp = event.surfaceOp
|
|
301
|
+
if (!isRecord(surfaceOp) || Reflect.ownKeys(surfaceOp).length !== 3 || surfaceOp.op !== 'replace') {
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
return (surfaceOp.start === sourceSeq && surfaceOp.end === sourceSeq)
|
|
305
|
+
|| (surfaceOp.startSeq === sourceSeq && surfaceOp.endSeq === sourceSeq)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A replacement representation may differ from its shadowed result only in message content. */
|
|
309
|
+
function sameResultExceptContent(previous, next) {
|
|
310
|
+
const withoutContent = data => isRecord(data) && isRecord(data.message)
|
|
311
|
+
? { ...data, message: { ...data.message, content: undefined } }
|
|
312
|
+
: data
|
|
313
|
+
return isDeepStrictEqual(withoutContent(previous), withoutContent(next))
|
|
314
|
+
}
|
|
315
|
+
|
|
273
316
|
/**
|
|
274
317
|
* Fold persisted tool events once into the call/result and edit-target timeline
|
|
275
318
|
* shared by prompt projection and cold journal recovery.
|
|
@@ -283,6 +326,7 @@ export function foldSessionTimeline(events) {
|
|
|
283
326
|
boundaries: [],
|
|
284
327
|
found: false,
|
|
285
328
|
unavailableResultSeq: undefined,
|
|
329
|
+
unavailableResultReason: undefined,
|
|
286
330
|
lastSuccessfulRunIndex: undefined,
|
|
287
331
|
latestRun: undefined,
|
|
288
332
|
editableRun: undefined,
|
|
@@ -295,7 +339,8 @@ export function foldSessionTimeline(events) {
|
|
|
295
339
|
const seenCallIds = new Set()
|
|
296
340
|
const claimedEditTargets = new Set()
|
|
297
341
|
const editClaims = new Map()
|
|
298
|
-
const
|
|
342
|
+
const ordinaryResultSeqs = new Set()
|
|
343
|
+
const settledResultsByEventSeq = new Map()
|
|
299
344
|
let pruneReplacementWindow
|
|
300
345
|
let scope = 0
|
|
301
346
|
|
|
@@ -324,7 +369,7 @@ export function foldSessionTimeline(events) {
|
|
|
324
369
|
continue
|
|
325
370
|
}
|
|
326
371
|
if (event?.type === RECOVERY_BOUNDARY_EVENT) {
|
|
327
|
-
state.unavailableResultSeq ??=
|
|
372
|
+
state.unavailableResultSeq ??= isCanonicalSequence(event.seq) ? event.seq : eventIndex
|
|
328
373
|
continue
|
|
329
374
|
}
|
|
330
375
|
if (event?.type === 'compaction/prune' && Array.isArray(event.data?.shadowedSeqs)) {
|
|
@@ -335,12 +380,19 @@ export function foldSessionTimeline(events) {
|
|
|
335
380
|
const executable = REPL_TOOL_NAMES.has(event.data.name)
|
|
336
381
|
let entry = { event, eventIndex, scope }
|
|
337
382
|
if (executable) {
|
|
338
|
-
if (
|
|
383
|
+
if (isCanonicalSequence(event.seq) && state.executableCalls.has(event.seq)) {
|
|
339
384
|
// A sequence collision disproves both sources, but not the earlier
|
|
340
385
|
// verified frontier. Keep its position for persistent contraction.
|
|
341
386
|
const previous = state.executableCalls.get(event.seq)
|
|
342
387
|
previous.ambiguous = true
|
|
343
388
|
state.unavailableResultSeq = Math.min(state.unavailableResultSeq ?? event.seq, event.seq)
|
|
389
|
+
if (state.unavailableResultReason === undefined
|
|
390
|
+
|| event.seq < state.unavailableResultReason.seq) {
|
|
391
|
+
state.unavailableResultReason = {
|
|
392
|
+
seq: event.seq,
|
|
393
|
+
reason: `duplicate executable tool call sequence ${event.seq}`,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
344
396
|
state.found = true
|
|
345
397
|
state.latestRun = undefined
|
|
346
398
|
state.editableRun = undefined
|
|
@@ -363,7 +415,7 @@ export function foldSessionTimeline(events) {
|
|
|
363
415
|
}
|
|
364
416
|
}
|
|
365
417
|
state.calls.push(event)
|
|
366
|
-
if (
|
|
418
|
+
if (isCanonicalSequence(event.seq)) {
|
|
367
419
|
state.executableCalls.set(event.seq, entry)
|
|
368
420
|
}
|
|
369
421
|
}
|
|
@@ -376,9 +428,14 @@ export function foldSessionTimeline(events) {
|
|
|
376
428
|
}
|
|
377
429
|
if (event?.type !== 'tool/result') continue
|
|
378
430
|
|
|
379
|
-
const
|
|
431
|
+
const hasSourceRelation = Object.hasOwn(event, 'sourceEventSeqs')
|
|
432
|
+
const sourceRelation = event.sourceEventSeqs
|
|
433
|
+
const sourceSeq = sourceRelation?.[0]
|
|
434
|
+
const canonicalSourceRelation = Array.isArray(sourceRelation)
|
|
435
|
+
&& sourceRelation.length === 1
|
|
436
|
+
&& isCanonicalSequence(sourceSeq)
|
|
380
437
|
const callId = event.data?.message?.source?.callId
|
|
381
|
-
let entry =
|
|
438
|
+
let entry = canonicalSourceRelation ? state.executableCalls.get(sourceSeq) : undefined
|
|
382
439
|
let prunedReplacement = false
|
|
383
440
|
if (entry === undefined && identifiesPrunedResult(pruneReplacementWindow, event, eventIndex)) {
|
|
384
441
|
const candidate = pendingByCallId.get(callId)
|
|
@@ -389,9 +446,48 @@ export function foldSessionTimeline(events) {
|
|
|
389
446
|
pruneReplacementWindow.lastEventIndex = eventIndex
|
|
390
447
|
}
|
|
391
448
|
}
|
|
392
|
-
if (entry === undefined && !
|
|
449
|
+
if (entry === undefined && !hasSourceRelation && typeof callId === 'string') {
|
|
393
450
|
entry = pendingByCallId.get(callId)
|
|
394
451
|
}
|
|
452
|
+
if (entry === undefined && canonicalSourceRelation) {
|
|
453
|
+
// The Host pruner may replace an already-settled result with a
|
|
454
|
+
// content-only clone that cites the shadowed result event. It is the same
|
|
455
|
+
// call's settlement representation, not a new unknown boundary.
|
|
456
|
+
const settled = settledResultsByEventSeq.get(sourceSeq)
|
|
457
|
+
if (settled !== undefined && typeof callId === 'string'
|
|
458
|
+
&& settled.call?.data?.callId === callId
|
|
459
|
+
&& exactResultReplacement(event, sourceSeq)
|
|
460
|
+
&& sameResultExceptContent(settled.data, event.data)) {
|
|
461
|
+
entry = settled.entry
|
|
462
|
+
prunedReplacement = true
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (hasSourceRelation && !canonicalSourceRelation) {
|
|
466
|
+
const identityEntry = typeof callId === 'string' ? pendingByCallId.get(callId) : undefined
|
|
467
|
+
const callSeqs = new Set(Array.isArray(sourceRelation)
|
|
468
|
+
? sourceRelation
|
|
469
|
+
.filter(isCanonicalSequence)
|
|
470
|
+
.map(candidateSeq => state.executableCalls.get(candidateSeq)?.event?.seq)
|
|
471
|
+
.filter(isCanonicalSequence)
|
|
472
|
+
: [])
|
|
473
|
+
if (isCanonicalSequence(identityEntry?.event?.seq)) {
|
|
474
|
+
callSeqs.add(identityEntry.event.seq)
|
|
475
|
+
}
|
|
476
|
+
if (callSeqs.size > 0) {
|
|
477
|
+
const callSeq = Math.min(...callSeqs)
|
|
478
|
+
state.unavailableResultSeq = Math.min(state.unavailableResultSeq ?? callSeq, callSeq)
|
|
479
|
+
state.results.set(callSeq, {
|
|
480
|
+
eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq,
|
|
481
|
+
eventIndex,
|
|
482
|
+
error: `tool result has an invalid source relation for call seq ${callSeq}`,
|
|
483
|
+
})
|
|
484
|
+
state.found = true
|
|
485
|
+
}
|
|
486
|
+
if (typeof callId === 'string') pendingByCallId.delete(callId)
|
|
487
|
+
state.latestRun = undefined
|
|
488
|
+
state.editableRun = undefined
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
395
491
|
if (typeof callId === 'string') pendingByCallId.delete(callId)
|
|
396
492
|
if (entry === null) {
|
|
397
493
|
state.latestRun = undefined
|
|
@@ -407,38 +503,65 @@ export function foldSessionTimeline(events) {
|
|
|
407
503
|
}
|
|
408
504
|
const callSeq = call?.seq
|
|
409
505
|
const meta = event.data?.meta
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
state.
|
|
414
|
-
|
|
415
|
-
eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : sourceSeq,
|
|
506
|
+
if (!prunedReplacement && isCanonicalSequence(callSeq)) {
|
|
507
|
+
if (ordinaryResultSeqs.has(callSeq)) {
|
|
508
|
+
state.unavailableResultSeq ??= callSeq
|
|
509
|
+
state.results.set(callSeq, {
|
|
510
|
+
eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq,
|
|
416
511
|
eventIndex,
|
|
417
|
-
error: `session log contains duplicate
|
|
512
|
+
error: `session log contains duplicate ordinary tool results for call seq ${callSeq}`,
|
|
418
513
|
})
|
|
419
514
|
state.found = true
|
|
515
|
+
state.latestRun = undefined
|
|
516
|
+
state.editableRun = undefined
|
|
420
517
|
continue
|
|
421
518
|
}
|
|
422
|
-
|
|
519
|
+
ordinaryResultSeqs.add(callSeq)
|
|
520
|
+
}
|
|
521
|
+
if (call !== undefined && typeof callId === 'string' && call.data.callId !== callId) {
|
|
522
|
+
state.latestRun = undefined
|
|
523
|
+
state.editableRun = undefined
|
|
524
|
+
if (isCanonicalSequence(callSeq)) {
|
|
525
|
+
state.unavailableResultSeq ??= callSeq
|
|
526
|
+
state.results.set(callSeq, {
|
|
527
|
+
eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq,
|
|
528
|
+
eventIndex,
|
|
529
|
+
error: `tool result identities disagree for call seq ${callSeq}`,
|
|
530
|
+
})
|
|
531
|
+
state.found = true
|
|
532
|
+
}
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
let normalized
|
|
536
|
+
if (isRecord(meta) && Object.hasOwn(meta, JOURNAL_KEY)) {
|
|
423
537
|
if (entry === undefined) {
|
|
424
|
-
if (
|
|
538
|
+
if (isCanonicalSequence(sourceSeq)) {
|
|
425
539
|
state.unavailableResultSeq ??= sourceSeq
|
|
426
540
|
}
|
|
427
541
|
continue
|
|
428
542
|
}
|
|
429
543
|
const resultSeq = prunedReplacement
|
|
430
544
|
? callSeq
|
|
431
|
-
:
|
|
545
|
+
: isCanonicalSequence(sourceSeq) ? sourceSeq : callSeq
|
|
546
|
+
// A pruned clone re-publishes an existing settlement, so it keeps that
|
|
547
|
+
// settlement's position instead of the clone's later row sequence. A
|
|
548
|
+
// recorded recovery boundary names the call it follows, and ordering the
|
|
549
|
+
// clone by its own row would apply the boundary before that call exists
|
|
550
|
+
// in the verified frontier.
|
|
551
|
+
const settlementPosition = prunedReplacement
|
|
552
|
+
? state.results.get(resultSeq)?.eventSeq ?? resultSeq
|
|
553
|
+
: undefined
|
|
432
554
|
const raw = {
|
|
433
555
|
meta,
|
|
434
|
-
eventSeq:
|
|
556
|
+
eventSeq: isCanonicalSequence(event.seq) ? event.seq : resultSeq,
|
|
557
|
+
...(settlementPosition === undefined ? {} : { positionSeq: settlementPosition }),
|
|
435
558
|
eventIndex,
|
|
436
559
|
}
|
|
437
560
|
if (Object.hasOwn(meta, RECOVERY_BOUNDARY_KEY)) {
|
|
438
561
|
try {
|
|
439
562
|
state.boundaries.push(...normalizeRecoveryBoundaries(
|
|
440
563
|
meta[RECOVERY_BOUNDARY_KEY], raw.eventSeq,
|
|
441
|
-
))
|
|
564
|
+
).map(boundary => ({ ...boundary, carrierCallSeq: resultSeq })))
|
|
442
565
|
} catch {
|
|
443
566
|
state.unavailableResultSeq ??= callSeq
|
|
444
567
|
}
|
|
@@ -475,7 +598,15 @@ export function foldSessionTimeline(events) {
|
|
|
475
598
|
} catch (error) {
|
|
476
599
|
normalized = { ...raw, error: error.message }
|
|
477
600
|
}
|
|
478
|
-
if (
|
|
601
|
+
if (normalized === undefined) {
|
|
602
|
+
// PTC metadata on a settlement whose call is not a REPL tool is
|
|
603
|
+
// unproved evidence: it must contract the frontier, not escape the fold.
|
|
604
|
+
normalized = { ...raw, error: 'PTC journal metadata appeared on a non-REPL tool result' }
|
|
605
|
+
}
|
|
606
|
+
if (isCanonicalSequence(resultSeq)) state.results.set(resultSeq, normalized)
|
|
607
|
+
if (!prunedReplacement && isCanonicalSequence(event.seq)) {
|
|
608
|
+
settledResultsByEventSeq.set(event.seq, { call, entry, data: event.data })
|
|
609
|
+
}
|
|
479
610
|
state.found = true
|
|
480
611
|
}
|
|
481
612
|
|
|
@@ -518,6 +649,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
518
649
|
const calls = timeline.calls.filter(call => call.seq !== currentCallSeq)
|
|
519
650
|
const { executableCalls, results, found } = timeline
|
|
520
651
|
const visibleCalls = options?.visibleCallSeqs
|
|
652
|
+
const appliedBoundaries = options?.[RECOVERY_BOUNDARY_EVIDENCE]
|
|
521
653
|
const invalidCallSeqs = new Set()
|
|
522
654
|
let unavailableBoundary
|
|
523
655
|
const registerUnavailable = ({ seq, reason, code }, recoveryState) => {
|
|
@@ -528,17 +660,22 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
528
660
|
invalidCallSeqs.add(seq)
|
|
529
661
|
recoveryState.trusted = false
|
|
530
662
|
if (!recoveryState.volatileSuffix.some(item => item.seq === seq)) {
|
|
531
|
-
|
|
663
|
+
const item = { seq, code, reason }
|
|
664
|
+
const index = recoveryState.volatileSuffix.findIndex(existing => existing.seq > seq)
|
|
665
|
+
if (index < 0) recoveryState.volatileSuffix.push(item)
|
|
666
|
+
else recoveryState.volatileSuffix.splice(index, 0, item)
|
|
532
667
|
}
|
|
533
668
|
}
|
|
534
669
|
if (timeline.unavailableResultSeq !== undefined) {
|
|
535
670
|
registerUnavailable({
|
|
536
671
|
seq: timeline.unavailableResultSeq,
|
|
537
|
-
reason:
|
|
672
|
+
reason: timeline.unavailableResultReason?.seq === timeline.unavailableResultSeq
|
|
673
|
+
? timeline.unavailableResultReason.reason
|
|
674
|
+
: 'unavailable or malformed dsh-ptc-plus journal result',
|
|
538
675
|
})
|
|
539
676
|
}
|
|
540
677
|
for (const [resultSeq, result] of results.entries()) {
|
|
541
|
-
if (result?.error !== undefined &&
|
|
678
|
+
if (result?.error !== undefined && isCanonicalSequence(resultSeq)) {
|
|
542
679
|
registerUnavailable({ seq: resultSeq, reason: result.error })
|
|
543
680
|
}
|
|
544
681
|
}
|
|
@@ -578,27 +715,71 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
578
715
|
}
|
|
579
716
|
const records = []
|
|
580
717
|
const orderedRecords = []
|
|
718
|
+
const contractedCallRanges = []
|
|
581
719
|
let state = foldRecords(records, invalidCallSeqs)
|
|
582
720
|
let boundaryIndex = 0
|
|
721
|
+
const carrierCallSeqForBoundary = boundary => boundary.carrierCallSeq
|
|
722
|
+
const isContractedCallSeq = (callSeq) => contractedCallRanges.some(range => (
|
|
723
|
+
callSeq >= range.start && callSeq < range.end
|
|
724
|
+
))
|
|
725
|
+
const rejectBoundary = (boundary, reason) => {
|
|
726
|
+
const carrierCallSeq = carrierCallSeqForBoundary(boundary)
|
|
727
|
+
const contractionSeq = carrierCallSeq === undefined
|
|
728
|
+
? boundary.failedCallSeq
|
|
729
|
+
: Math.min(boundary.failedCallSeq, carrierCallSeq)
|
|
730
|
+
// A rejected boundary still establishes a conservative contraction point:
|
|
731
|
+
// the same window an accepted boundary would contract stays unproved, so
|
|
732
|
+
// settlements folded after this rejection cannot re-enter the frontier.
|
|
733
|
+
if (carrierCallSeq !== undefined) {
|
|
734
|
+
contractedCallRanges.push({ start: contractionSeq, end: carrierCallSeq })
|
|
735
|
+
}
|
|
736
|
+
for (const record of records) {
|
|
737
|
+
if (record.call.seq >= contractionSeq) invalidCallSeqs.add(record.call.seq)
|
|
738
|
+
}
|
|
739
|
+
state = foldRecords(records, invalidCallSeqs, {
|
|
740
|
+
surfaceContracted: state.surfaceContracted,
|
|
741
|
+
})
|
|
742
|
+
registerUnavailable({ seq: carrierCallSeq ?? boundary.failedCallSeq, reason }, state)
|
|
743
|
+
}
|
|
583
744
|
const applyBoundariesBefore = (seq) => {
|
|
584
745
|
while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq <= seq) {
|
|
585
746
|
const boundary = boundaries[boundaryIndex++]
|
|
747
|
+
const carrierCallSeq = carrierCallSeqForBoundary(boundary)
|
|
586
748
|
const failedCall = executableCalls.get(boundary.failedCallSeq)
|
|
587
|
-
if (failedCall === undefined || failedCall.event.seq >= boundary.eventSeq)
|
|
749
|
+
if (failedCall === undefined || failedCall.event.seq >= boundary.eventSeq) {
|
|
750
|
+
rejectBoundary(boundary, 'recovery boundary does not identify an earlier executable call')
|
|
751
|
+
continue
|
|
752
|
+
}
|
|
588
753
|
const failedIndex = state.nodes.findIndex(node => node.callSeq === boundary.failedCallSeq)
|
|
589
754
|
const expectedFrontier = failedIndex < 0 ? state.head : state.nodes[failedIndex].parent
|
|
590
755
|
const frontierIndex = boundary.frontierCallSeq === null
|
|
591
756
|
? undefined
|
|
592
757
|
: state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
|
|
593
|
-
if (frontierIndex !== expectedFrontier)
|
|
758
|
+
if (frontierIndex !== expectedFrontier) {
|
|
759
|
+
rejectBoundary(boundary, 'recovery boundary frontier does not match the failed call parent')
|
|
760
|
+
continue
|
|
761
|
+
}
|
|
594
762
|
const resolvesUnavailable = unavailableBoundary?.seq === boundary.failedCallSeq
|
|
595
|
-
|
|
763
|
+
const resolvesConfirmedNoop = confirmedNoops.has(boundary.failedCallSeq)
|
|
764
|
+
if (failedIndex < 0 && !resolvesUnavailable && !resolvesConfirmedNoop) {
|
|
765
|
+
rejectBoundary(boundary, 'recovery boundary failed call is outside the verified frontier')
|
|
766
|
+
continue
|
|
767
|
+
}
|
|
768
|
+
if (carrierCallSeq !== undefined && carrierCallSeq <= boundary.failedCallSeq) {
|
|
769
|
+
rejectBoundary(boundary, 'recovery boundary carrier does not follow the failed call')
|
|
770
|
+
continue
|
|
771
|
+
}
|
|
772
|
+
if (Array.isArray(appliedBoundaries)) appliedBoundaries.push(boundary)
|
|
773
|
+
if (carrierCallSeq !== undefined) {
|
|
774
|
+
contractedCallRanges.push({ start: boundary.failedCallSeq, end: carrierCallSeq })
|
|
775
|
+
}
|
|
596
776
|
for (let index = 0; index < state.nodes.length; index += 1) {
|
|
597
777
|
if (dependsOn(state.nodes, index, failedIndex)) invalidCallSeqs.add(state.nodes[index].callSeq)
|
|
598
778
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
779
|
+
for (const record of records) {
|
|
780
|
+
if ((failedIndex < 0 && record.call.seq >= boundary.failedCallSeq)
|
|
781
|
+
|| isContractedCallSeq(record.call.seq)) {
|
|
782
|
+
invalidCallSeqs.add(record.call.seq)
|
|
602
783
|
}
|
|
603
784
|
}
|
|
604
785
|
state = foldRecords(records, invalidCallSeqs, {
|
|
@@ -628,6 +809,10 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
628
809
|
for (const record of orderedRecords) {
|
|
629
810
|
applyBoundariesBefore(recordEventSeq(record))
|
|
630
811
|
records.push(record)
|
|
812
|
+
if (isContractedCallSeq(record.call.seq)) {
|
|
813
|
+
invalidCallSeqs.add(record.call.seq)
|
|
814
|
+
continue
|
|
815
|
+
}
|
|
631
816
|
if (record.hidden) {
|
|
632
817
|
state.surfaceContracted = true
|
|
633
818
|
registerUnavailable({
|
|
@@ -670,6 +855,21 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
670
855
|
}
|
|
671
856
|
}
|
|
672
857
|
|
|
858
|
+
/** Prove that recovery consumes every persisted boundary at its declared frontier. */
|
|
859
|
+
export function validateRecoveryBoundaryApplication(events) {
|
|
860
|
+
const applied = []
|
|
861
|
+
const recovered = recoverJournal(
|
|
862
|
+
{ events },
|
|
863
|
+
undefined,
|
|
864
|
+
{ [RECOVERY_BOUNDARY_EVIDENCE]: applied },
|
|
865
|
+
)
|
|
866
|
+
const declared = foldSessionTimeline(events).boundaries.length
|
|
867
|
+
if (!recovered.available || applied.length !== declared) {
|
|
868
|
+
throw new Error('migrated PTC recovery boundary does not prove its declared frontier')
|
|
869
|
+
}
|
|
870
|
+
return recovered
|
|
871
|
+
}
|
|
872
|
+
|
|
673
873
|
/** Return source nodes from the empty state to a selected durable head. */
|
|
674
874
|
export function pathToHead(state) {
|
|
675
875
|
const path = []
|