dsh-ptc-plus 0.2.4 → 0.3.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.
Files changed (49) hide show
  1. package/README.en.md +177 -0
  2. package/README.md +85 -69
  3. package/assets/ptc-plus-bindings-en.png +0 -0
  4. package/assets/ptc-plus-bindings-zh.png +0 -0
  5. package/assets/ptc-plus-repair-en.png +0 -0
  6. package/assets/ptc-plus-settings-en.png +0 -0
  7. package/assets/ptc-plus-settings-zh.png +0 -0
  8. package/client.js +1168 -144
  9. package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +1 -1
  10. package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +15 -3
  11. package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +7 -3
  12. package/docs/adr/0014-persist-cell-rewrite-policy.md +7 -5
  13. package/docs/adr/0019-plugin-settings-and-kill-switch.md +19 -4
  14. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -2
  15. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +5 -3
  16. package/docs/adr/0022-stage-npm-releases-with-oidc.md +2 -2
  17. package/docs/architecture.md +23 -14
  18. package/docs/client-ui.md +29 -11
  19. package/docs/durability-design.md +42 -20
  20. package/docs/installation.md +11 -3
  21. package/docs/program-data-plane.md +6 -0
  22. package/docs/runtime-reference.md +27 -14
  23. package/docs/value-wire.md +1 -1
  24. package/index.js +38 -4
  25. package/internal/cell-analysis.js +89 -12
  26. package/internal/cell-rewriter.js +162 -4
  27. package/internal/config-spec.js +192 -117
  28. package/internal/direct-surface-owner.js +56 -5
  29. package/internal/edit-transport-owner.js +11 -2
  30. package/internal/failure-reporting.js +15 -0
  31. package/internal/kernel-worker.js +11 -0
  32. package/internal/rejected-cell-editor.js +21 -12
  33. package/internal/repl-convenience.js +94 -6
  34. package/internal/repl-memory-projection.js +277 -0
  35. package/internal/repl-rewrite-contract.js +15 -0
  36. package/internal/run-code-description.js +34 -0
  37. package/internal/runtime-bridge-owner.js +63 -15
  38. package/internal/runtime-config.js +3 -0
  39. package/internal/session-cell-executor.js +74 -14
  40. package/internal/session-events.js +7 -0
  41. package/internal/session-journal.js +331 -63
  42. package/internal/session-log-view.js +2 -1
  43. package/internal/session-runtime.js +110 -9
  44. package/internal/session-state.js +116 -4
  45. package/package.json +10 -7
  46. package/src/client-activity.js +501 -0
  47. package/src/client.js +456 -20
  48. package/README.zh.md +0 -161
  49. /package/assets/{ptc-plus-repair.png → ptc-plus-repair-zh.png} +0 -0
@@ -1,22 +1,35 @@
1
1
  import { decodeValue, encodeValue, normalizeValueWire } from './value-wire.js'
2
2
  import { normalizeDiagnostic } from './diagnostic.js'
3
3
  import { assertOwnFields, isRecord } from './record-utils.js'
4
+ import { sessionEvents } from './session-events.js'
5
+ import {
6
+ LEGACY_DEFAULT_EXPORT_BINDING,
7
+ LIVE_DEFAULT_EXPORT_BINDING,
8
+ } from './repl-rewrite-contract.js'
4
9
 
5
10
  export const JOURNAL_KEY = 'dshPtcPlus'
6
11
  export const EDIT_TARGET_KEY = 'dshPtcPlusEdit'
7
12
  export const DERIVED_RUN_KEY = 'dshPtcPlusDerivedRun'
8
13
  export const REWRITES_KEY = 'dshPtcPlusRewrites'
9
14
  export const RECOVERY_BOUNDARY_KEY = 'dshPtcPlusRecoveryBoundaries'
10
- export const JOURNAL_VERSION = 3
15
+ export const JOURNAL_VERSION = 4
11
16
  const LEGACY_JOURNAL_VERSION = 1
12
17
  const INTERMEDIATE_JOURNAL_VERSION = 2
18
+ const PREVIOUS_JOURNAL_VERSION = 3
13
19
  export const RECOVERY_BOUNDARY_EVENT = 'ptc-plus/recovery-boundary'
14
20
 
15
21
  const STATUSES = new Set(['durable', 'volatile', 'discarded', 'noop'])
16
22
  const BINDING_MODES = new Set(['loose', 'strict'])
17
- const JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
18
- const LEGACY_JOURNAL_FIELDS = new Set([...JOURNAL_FIELDS].filter(field => field !== 'rewritePolicy'))
23
+ const JOURNAL_FIELDS = new Set(['version', 'bindingPolicy', 'rewritePolicy', 'moduleSemantics', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
24
+ const PREDECESSOR_JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
25
+ const LEGACY_JOURNAL_FIELDS = new Set([...PREDECESSOR_JOURNAL_FIELDS].filter(field => field !== 'rewritePolicy'))
26
+ const BINDING_POLICY_FIELDS = new Set(['variableRedeclarations', 'functionClassRedeclarations'])
19
27
  const REWRITE_POLICY_FIELDS = new Set(['autoRewriteImports', 'autoStripExports', 'autoSplitRedeclarations'])
28
+ const MODULE_SEMANTICS_FIELDS = new Set(['defaultExportBinding'])
29
+ const DEFAULT_EXPORT_BINDINGS = new Set([
30
+ LEGACY_DEFAULT_EXPORT_BINDING,
31
+ LIVE_DEFAULT_EXPORT_BINDING,
32
+ ])
20
33
  const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'ok', 'value', 'settle'])
21
34
  const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'ok', 'error', 'settle'])
22
35
  const OPERATION_FIELDS = new Set(['action', 'name'])
@@ -166,28 +179,64 @@ function normalizeRewritePolicy(value) {
166
179
  })
167
180
  }
168
181
 
182
+ function normalizeBindingPolicy(value) {
183
+ if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal binding policy')
184
+ assertOwnFields(value, BINDING_POLICY_FIELDS, 'journal binding policy')
185
+ for (const key of BINDING_POLICY_FIELDS) {
186
+ if (typeof value[key] !== 'boolean') throw new Error(`invalid dsh-ptc-plus journal binding policy ${key}`)
187
+ }
188
+ return Object.freeze({
189
+ variableRedeclarations: value.variableRedeclarations,
190
+ functionClassRedeclarations: value.functionClassRedeclarations,
191
+ })
192
+ }
193
+
194
+ function normalizeModuleSemantics(value) {
195
+ if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal module semantics')
196
+ assertOwnFields(value, MODULE_SEMANTICS_FIELDS, 'journal module semantics')
197
+ if (!DEFAULT_EXPORT_BINDINGS.has(value.defaultExportBinding)) {
198
+ throw new Error('invalid dsh-ptc-plus journal default export binding semantics')
199
+ }
200
+ return Object.freeze({ defaultExportBinding: value.defaultExportBinding })
201
+ }
202
+
169
203
  function migrateJournal(value, resolveLegacyConfirm) {
170
- if (value.version !== LEGACY_JOURNAL_VERSION) return value
171
- assertOwnFields(value, LEGACY_JOURNAL_FIELDS, 'dsh-ptc-plus journal')
204
+ if (value.version === JOURNAL_VERSION) return value
205
+ const legacy = value.version === LEGACY_JOURNAL_VERSION
206
+ assertOwnFields(
207
+ value,
208
+ legacy ? LEGACY_JOURNAL_FIELDS : PREDECESSOR_JOURNAL_FIELDS,
209
+ 'dsh-ptc-plus journal',
210
+ )
211
+ if (!BINDING_MODES.has(value.bindingMode)) throw new Error('invalid dsh-ptc-plus journal binding mode')
212
+ const { bindingMode, ...rest } = value
172
213
  return {
173
- ...value,
214
+ ...rest,
174
215
  version: JOURNAL_VERSION,
175
- rewritePolicy: LEGACY_REWRITE_POLICY,
176
- confirms: normalizeLegacyConfirms(value.confirms, resolveLegacyConfirm),
216
+ bindingPolicy: {
217
+ variableRedeclarations: bindingMode === 'loose',
218
+ functionClassRedeclarations: false,
219
+ },
220
+ rewritePolicy: legacy ? LEGACY_REWRITE_POLICY : value.rewritePolicy,
221
+ moduleSemantics: { defaultExportBinding: LEGACY_DEFAULT_EXPORT_BINDING },
222
+ confirms: legacy
223
+ ? normalizeLegacyConfirms(value.confirms, resolveLegacyConfirm)
224
+ : value.confirms,
177
225
  }
178
226
  }
179
227
 
180
228
  /** Validate and detach one journal emitted by the runtime. */
181
229
  export function normalizeJournal(value, options = {}) {
182
230
  if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal')
183
- if (![LEGACY_JOURNAL_VERSION, INTERMEDIATE_JOURNAL_VERSION, JOURNAL_VERSION].includes(value.version)
231
+ if (![LEGACY_JOURNAL_VERSION, INTERMEDIATE_JOURNAL_VERSION, PREVIOUS_JOURNAL_VERSION, JOURNAL_VERSION].includes(value.version)
184
232
  || !STATUSES.has(value.status)) {
185
233
  throw new Error('invalid dsh-ptc-plus journal')
186
234
  }
187
235
  const migrated = migrateJournal(value, options.resolveLegacyConfirm)
188
236
  assertOwnFields(migrated, JOURNAL_FIELDS, 'dsh-ptc-plus journal')
189
- if (!BINDING_MODES.has(migrated.bindingMode)) throw new Error('invalid dsh-ptc-plus journal binding mode')
237
+ const bindingPolicy = normalizeBindingPolicy(migrated.bindingPolicy)
190
238
  const rewritePolicy = normalizeRewritePolicy(migrated.rewritePolicy)
239
+ const moduleSemantics = normalizeModuleSemantics(migrated.moduleSemantics)
191
240
  const calls = normalizeCalls(migrated.calls)
192
241
  const operations = normalizeOperations(migrated.operations)
193
242
  const confirms = normalizeConfirms(migrated.confirms)
@@ -208,8 +257,9 @@ export function normalizeJournal(value, options = {}) {
208
257
  }
209
258
  return Object.freeze({
210
259
  version: JOURNAL_VERSION,
211
- bindingMode: migrated.bindingMode,
260
+ bindingPolicy,
212
261
  rewritePolicy,
262
+ moduleSemantics,
213
263
  status: migrated.status,
214
264
  calls: Object.freeze(calls),
215
265
  operations: Object.freeze(operations),
@@ -365,7 +415,7 @@ export function migrateRecoveryBoundaryEvents(events) {
365
415
 
366
416
  /** Resolve the persisted event identity for one named tool call being dispatched. */
367
417
  export function liveToolCallSeq(session, callId, toolName) {
368
- const events = session?.events
418
+ const events = sessionEvents(session)
369
419
  if (!Array.isArray(events) || typeof callId !== 'string' || callId.length === 0
370
420
  || typeof toolName !== 'string' || toolName.length === 0) return undefined
371
421
 
@@ -393,12 +443,21 @@ export function liveToolCallSeq(session, callId, toolName) {
393
443
  }
394
444
 
395
445
  /** Start a mutable journal for one live cell. */
396
- export function createJournal(confirms = [], bindingMode, rewritePolicy) {
397
- if (!BINDING_MODES.has(bindingMode)) throw new TypeError('invalid dsh-ptc-plus journal binding mode')
446
+ export function createJournal(confirms = [], bindingPolicy, rewritePolicy) {
447
+ if (typeof bindingPolicy === 'string') {
448
+ if (!BINDING_MODES.has(bindingPolicy)) throw new TypeError('invalid dsh-ptc-plus journal binding mode')
449
+ bindingPolicy = {
450
+ variableRedeclarations: bindingPolicy === 'loose',
451
+ functionClassRedeclarations: false,
452
+ }
453
+ }
398
454
  return {
399
455
  version: JOURNAL_VERSION,
400
- bindingMode,
456
+ bindingPolicy: normalizeBindingPolicy(bindingPolicy),
401
457
  rewritePolicy: normalizeRewritePolicy(rewritePolicy),
458
+ moduleSemantics: normalizeModuleSemantics({
459
+ defaultExportBinding: LIVE_DEFAULT_EXPORT_BINDING,
460
+ }),
402
461
  calls: [],
403
462
  operations: [],
404
463
  confirms: [...confirms],
@@ -455,11 +514,6 @@ function applyOperations(state, operations, nodeIndex) {
455
514
  function applyRecord(state, record, invalidCallSeqs) {
456
515
  const { call, code, result } = record
457
516
  if (invalidCallSeqs.has(call.seq)) return
458
- if (code === undefined || result?.journal === undefined) {
459
- state.trusted = false
460
- state.volatileSuffix.push({ seq: call.seq, code, reason: result?.error ?? 'missing dsh-ptc-plus journal result' })
461
- return
462
- }
463
517
  const journal = result.journal
464
518
  if (journal.status === 'noop') return
465
519
  if (journal.status === 'discarded') {
@@ -475,7 +529,7 @@ function applyRecord(state, record, invalidCallSeqs) {
475
529
  applyOperations(state, journal.operations, undefined)
476
530
  return
477
531
  }
478
- if (!state.trusted) {
532
+ if (!state.trusted && !state.surfaceContracted) {
479
533
  state.trusted = true
480
534
  state.volatileSuffix.length = 0
481
535
  }
@@ -487,38 +541,31 @@ function applyRecord(state, record, invalidCallSeqs) {
487
541
  })
488
542
  const index = state.nodes.push(node) - 1
489
543
  state.head = index
490
- applyOperations(state, journal.operations, index)
491
- }
492
-
493
- function forceRecoveryHead(state, boundary) {
494
- state.head = boundary.frontierCallSeq === null
495
- ? undefined
496
- : state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
497
- if (state.head === -1) throw new Error('recovery boundary frontier is not reconstructable')
498
- state.trusted = true
499
- state.volatileSuffix = []
544
+ try {
545
+ applyOperations(state, journal.operations, index)
546
+ } catch (error) {
547
+ state.nodes.pop()
548
+ state.head = node.parent
549
+ throw error
550
+ }
500
551
  }
501
552
 
502
553
  function recordEventSeq(record) {
503
554
  return record.result?.eventSeq ?? record.call.seq
504
555
  }
505
556
 
506
- function foldRecords(records, invalidCallSeqs, boundaries = []) {
557
+ function foldRecords(records, invalidCallSeqs, options = {}) {
507
558
  const state = {
508
559
  nodes: [],
509
560
  head: undefined,
510
561
  checkpoints: new Map(),
511
562
  volatileSuffix: [],
512
563
  trusted: true,
564
+ surfaceContracted: options.surfaceContracted === true,
513
565
  }
514
- let boundaryIndex = 0
515
566
  for (const record of records) {
516
- while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq < recordEventSeq(record)) {
517
- forceRecoveryHead(state, boundaries[boundaryIndex++])
518
- }
519
567
  applyRecord(state, record, invalidCallSeqs)
520
568
  }
521
- while (boundaryIndex < boundaries.length) forceRecoveryHead(state, boundaries[boundaryIndex++])
522
569
  return state
523
570
  }
524
571
 
@@ -529,6 +576,77 @@ function dependsOn(nodes, index, ancestor) {
529
576
  return false
530
577
  }
531
578
 
579
+ /** Return executable call sequences whose provenance remains model-visible. */
580
+ export function visibleExecutableCallSeqs(session) {
581
+ const events = sessionEvents(session)
582
+ let nodes
583
+ try {
584
+ nodes = session?.surface?.nodes
585
+ } catch {
586
+ return undefined
587
+ }
588
+ if (!Array.isArray(events) || !Array.isArray(nodes)) return undefined
589
+ const eventBySeq = new Map()
590
+ for (const event of events) eventBySeq.set(event?.seq, event)
591
+ for (const seq of nodes) {
592
+ if (!Number.isSafeInteger(seq) || seq < 0 || eventBySeq.get(seq) === undefined) return undefined
593
+ }
594
+ const visible = new Set(nodes)
595
+ const resultSources = new Set()
596
+ const visibleAssistantCallSeqs = new Set()
597
+ const pendingAssistantCalls = new Map()
598
+ let assistantCallEvidence = false
599
+ for (const event of events) {
600
+ if (event?.type === 'turn/start' || event?.type === 'turn/end') {
601
+ pendingAssistantCalls.clear()
602
+ continue
603
+ }
604
+ if (event?.type === 'assistant/message') {
605
+ pendingAssistantCalls.clear()
606
+ const content = event.data?.message?.content
607
+ if (Array.isArray(content)) {
608
+ for (const part of content) {
609
+ if (part?.type === 'tool-call' && typeof part.id === 'string') {
610
+ const isVisible = visible.has(event.seq)
611
+ if (isVisible) assistantCallEvidence = true
612
+ let occurrences = pendingAssistantCalls.get(part.id)
613
+ if (occurrences === undefined) {
614
+ occurrences = []
615
+ pendingAssistantCalls.set(part.id, occurrences)
616
+ }
617
+ occurrences.push(isVisible)
618
+ }
619
+ }
620
+ }
621
+ continue
622
+ }
623
+ if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
624
+ const occurrences = pendingAssistantCalls.get(event.data.callId)
625
+ if (occurrences !== undefined && occurrences.length > 0) {
626
+ const isVisible = occurrences.shift()
627
+ if (occurrences.length === 0) pendingAssistantCalls.delete(event.data.callId)
628
+ if (isVisible && ['run_code', 'edit_run_code'].includes(event.data.name)) {
629
+ visibleAssistantCallSeqs.add(event.seq)
630
+ }
631
+ }
632
+ }
633
+ if (visible.has(event?.seq)
634
+ && event?.type === 'tool/result' && Array.isArray(event.sourceEventSeqs)) {
635
+ for (const sourceSeq of event.sourceEventSeqs) {
636
+ if (Number.isSafeInteger(sourceSeq) && sourceSeq >= 0) resultSources.add(sourceSeq)
637
+ }
638
+ }
639
+ }
640
+ const calls = new Set()
641
+ for (const event of events) {
642
+ if (event?.type !== 'tool/call' || !['run_code', 'edit_run_code'].includes(event.data?.name)) continue
643
+ if (assistantCallEvidence
644
+ ? visibleAssistantCallSeqs.has(event.seq)
645
+ : (visible.has(event.seq) || resultSources.has(event.seq))) calls.add(event.seq)
646
+ }
647
+ return calls
648
+ }
649
+
532
650
  function timelineRun(call, result, eventIndex, journal) {
533
651
  let args
534
652
  try {
@@ -588,6 +706,7 @@ export function foldSessionTimeline(events) {
588
706
  const claimedEditTargets = new Set()
589
707
  const editClaims = new Map()
590
708
  const journalResultSeqs = new Set()
709
+ let pruneReplacementWindow
591
710
  let scope = 0
592
711
 
593
712
  const resetTurn = (openTurn) => {
@@ -602,6 +721,11 @@ export function foldSessionTimeline(events) {
602
721
 
603
722
  for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
604
723
  const event = events[eventIndex]
724
+ if (pruneReplacementWindow !== undefined
725
+ && event?.type !== 'compaction/prune'
726
+ && eventIndex !== pruneReplacementWindow.lastEventIndex + 1) {
727
+ pruneReplacementWindow = undefined
728
+ }
605
729
  if (event?.type === 'turn/start') {
606
730
  resetTurn(true)
607
731
  continue
@@ -611,7 +735,23 @@ export function foldSessionTimeline(events) {
611
735
  continue
612
736
  }
613
737
  if (event?.type === RECOVERY_BOUNDARY_EVENT) {
614
- throw new Error('legacy recovery boundary requires migration before DSH session restore')
738
+ state.unavailableResultSeq ??= Number.isSafeInteger(event.seq) ? event.seq : eventIndex
739
+ continue
740
+ }
741
+ if (event?.type === 'compaction/prune' && Array.isArray(event.data?.shadowedSeqs)) {
742
+ const shadowedSeqs = event.data.shadowedSeqs
743
+ const seqs = new Set(shadowedSeqs)
744
+ const shadowedRange = event.data.shadowedRange
745
+ const rangeValid = shadowedRange === undefined
746
+ || (isRecord(shadowedRange)
747
+ && shadowedRange.start === shadowedSeqs[0]
748
+ && shadowedRange.end === shadowedSeqs[shadowedSeqs.length - 1])
749
+ const valid = shadowedSeqs.length > 0
750
+ && seqs.size === shadowedSeqs.length
751
+ && shadowedSeqs.every(seq => Number.isSafeInteger(seq) && seq >= 0)
752
+ && shadowedSeqs.every((seq, index) => index === 0 || seq > shadowedSeqs[index - 1])
753
+ && rangeValid
754
+ pruneReplacementWindow = { seqs: valid ? seqs : new Set(), lastEventIndex: eventIndex }
615
755
  continue
616
756
  }
617
757
  if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
@@ -651,6 +791,31 @@ export function foldSessionTimeline(events) {
651
791
  const sourceSeq = event.sourceEventSeqs?.[0]
652
792
  const callId = event.data?.message?.source?.callId
653
793
  let entry = Number.isSafeInteger(sourceSeq) ? state.executableCalls.get(sourceSeq) : undefined
794
+ let prunedReplacement = false
795
+ if (entry === undefined && Number.isSafeInteger(sourceSeq)
796
+ && pruneReplacementWindow?.seqs.has(sourceSeq)
797
+ && eventIndex === pruneReplacementWindow.lastEventIndex + 1
798
+ && Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length === 1
799
+ && typeof callId === 'string') {
800
+ const candidate = pendingByCallId.get(callId)
801
+ const candidateSeq = candidate?.event?.seq
802
+ const pruneSeq = events[pruneReplacementWindow.lastEventIndex]?.seq
803
+ const resultEventSeq = event.seq
804
+ const surfaceOp = event.surfaceOp
805
+ if (candidate !== undefined && candidate !== null
806
+ && (candidate.event.data.name === 'run_code' || candidate.event.data.name === 'edit_run_code')
807
+ && Number.isSafeInteger(candidateSeq) && candidateSeq >= 0
808
+ && sourceSeq > candidateSeq
809
+ && Number.isSafeInteger(pruneSeq) && pruneSeq >= 0
810
+ && Number.isSafeInteger(resultEventSeq) && resultEventSeq - pruneSeq === 1
811
+ && sourceSeq < pruneSeq
812
+ && (surfaceOp === undefined
813
+ || (surfaceOp?.op === 'replace' && surfaceOp.start === sourceSeq && surfaceOp.end === sourceSeq))) {
814
+ entry = candidate
815
+ prunedReplacement = true
816
+ pruneReplacementWindow.lastEventIndex = eventIndex
817
+ }
818
+ }
654
819
  if (entry === undefined && !Number.isSafeInteger(sourceSeq) && typeof callId === 'string') {
655
820
  entry = pendingByCallId.get(callId)
656
821
  }
@@ -667,23 +832,38 @@ export function foldSessionTimeline(events) {
667
832
  let normalized
668
833
  if (isRecord(meta) && Object.hasOwn(meta, JOURNAL_KEY)) {
669
834
  if (Number.isSafeInteger(sourceSeq) && journalResultSeqs.has(sourceSeq)) {
670
- throw new Error(`session log contains duplicate PTC journal results for call seq ${sourceSeq}`)
835
+ state.unavailableResultSeq ??= sourceSeq
836
+ state.results.set(sourceSeq, {
837
+ eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : sourceSeq,
838
+ eventIndex,
839
+ error: `session log contains duplicate PTC journal results for call seq ${sourceSeq}`,
840
+ })
841
+ state.found = true
842
+ continue
671
843
  }
672
844
  if (Number.isSafeInteger(sourceSeq)) journalResultSeqs.add(sourceSeq)
673
845
  if (entry === undefined) {
674
- if (Number.isSafeInteger(sourceSeq)) state.unavailableResultSeq ??= sourceSeq
846
+ if (Number.isSafeInteger(sourceSeq)) {
847
+ state.unavailableResultSeq ??= sourceSeq
848
+ }
675
849
  continue
676
850
  }
677
- const resultSeq = Number.isSafeInteger(sourceSeq) ? sourceSeq : callSeq
851
+ const resultSeq = prunedReplacement
852
+ ? callSeq
853
+ : Number.isSafeInteger(sourceSeq) ? sourceSeq : callSeq
678
854
  const raw = {
679
855
  meta,
680
856
  eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : resultSeq,
681
857
  eventIndex,
682
858
  }
683
859
  if (Object.hasOwn(meta, RECOVERY_BOUNDARY_KEY)) {
684
- state.boundaries.push(...normalizeRecoveryBoundaries(
685
- meta[RECOVERY_BOUNDARY_KEY], raw.eventSeq,
686
- ))
860
+ try {
861
+ state.boundaries.push(...normalizeRecoveryBoundaries(
862
+ meta[RECOVERY_BOUNDARY_KEY], raw.eventSeq,
863
+ ))
864
+ } catch {
865
+ state.unavailableResultSeq ??= callSeq
866
+ }
687
867
  }
688
868
  try {
689
869
  if (call.data.name === 'edit_run_code') {
@@ -741,64 +921,105 @@ export function foldSessionTimeline(events) {
741
921
 
742
922
  /** Fold the session log into the last exactly replayable frontier. */
743
923
  export function recoverJournal(session, currentCallSeq, options = {}) {
744
- const events = session?.events
924
+ const events = sessionEvents(session)
745
925
  if (!Array.isArray(events)) {
746
926
  return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], available: true }
747
927
  }
748
928
  const timeline = foldSessionTimeline(events)
749
929
  const calls = timeline.calls.filter(call => call.seq !== currentCallSeq)
750
930
  const { executableCalls, results, found } = timeline
931
+ const visibleCalls = options?.visibleCallSeqs
932
+ let unavailableSeq = timeline.unavailableResultSeq
933
+ let unavailableReason = 'unavailable or malformed dsh-ptc-plus journal result'
934
+ for (const [resultSeq, result] of results.entries()) {
935
+ if (result?.error !== undefined && Number.isSafeInteger(resultSeq)) {
936
+ unavailableSeq ??= resultSeq
937
+ unavailableReason = result.error
938
+ }
939
+ }
751
940
  const extraBoundaries = options?.extraBoundaries ?? []
941
+ let normalizedExtraBoundaries = []
942
+ try {
943
+ normalizedExtraBoundaries = normalizeRecoveryBoundaries(extraBoundaries, undefined)
944
+ } catch {
945
+ normalizedExtraBoundaries = []
946
+ }
752
947
  const boundaries = [
753
948
  ...timeline.boundaries,
754
- ...normalizeRecoveryBoundaries(extraBoundaries, undefined).map(boundary => ({
949
+ ...normalizedExtraBoundaries.map(boundary => ({
755
950
  ...boundary,
756
951
  eventSeq: Number.POSITIVE_INFINITY,
757
952
  })),
758
953
  ].sort((left, right) => left.eventSeq - right.eventSeq)
759
- if (timeline.unavailableResultSeq !== undefined) {
760
- throw new Error(`PTC journal result references unavailable run_code call seq ${timeline.unavailableResultSeq}`)
761
- }
954
+ // An unavailable historical result contracts the replay frontier. It must
955
+ // not prevent the current request from executing on the verified prefix.
762
956
 
763
957
  const confirmedNoops = new Set()
764
- for (const { journal, eventIndex } of results.values()) {
958
+ const invalidResultReasons = new Map()
959
+ for (const [resultSeq, { journal, eventIndex }] of results.entries()) {
960
+ const candidateConfirms = []
765
961
  for (const callSeq of journal?.confirms ?? []) {
766
962
  const confirmed = executableCalls.get(callSeq)
767
963
  if (confirmed === undefined || confirmed.eventIndex >= eventIndex || results.has(callSeq)) {
768
- throw new Error(`confirmed no-op does not identify an earlier unjournaled run_code call seq ${callSeq}`)
964
+ const reason = `confirmed no-op does not identify an earlier unjournaled run_code call seq ${callSeq}`
965
+ invalidResultReasons.set(resultSeq, reason)
966
+ unavailableSeq ??= resultSeq
967
+ unavailableReason = reason
968
+ break
769
969
  }
770
- confirmedNoops.add(callSeq)
970
+ candidateConfirms.push(callSeq)
971
+ }
972
+ if (!invalidResultReasons.has(resultSeq)) {
973
+ for (const callSeq of candidateConfirms) confirmedNoops.add(callSeq)
771
974
  }
772
975
  }
773
976
  const records = []
774
977
  const orderedRecords = []
775
978
  const invalidCallSeqs = new Set()
776
- const appliedBoundaries = []
777
979
  let state = foldRecords(records, invalidCallSeqs)
980
+ let blockedUnavailableSeq = unavailableSeq
778
981
  let boundaryIndex = 0
779
982
  const applyBoundariesBefore = (seq) => {
780
983
  while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq <= seq) {
781
984
  const boundary = boundaries[boundaryIndex++]
985
+ const failedCall = executableCalls.get(boundary.failedCallSeq)
986
+ if (failedCall === undefined || failedCall.event.seq >= boundary.eventSeq) continue
782
987
  const failedIndex = state.nodes.findIndex(node => node.callSeq === boundary.failedCallSeq)
783
- if (failedIndex < 0) throw new Error('recovery boundary references an unavailable failed cell')
784
- const expectedFrontier = state.nodes[failedIndex].parent
988
+ const expectedFrontier = failedIndex < 0 ? state.head : state.nodes[failedIndex].parent
785
989
  const frontierIndex = boundary.frontierCallSeq === null
786
990
  ? undefined
787
991
  : state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
788
- if (frontierIndex !== expectedFrontier) {
789
- throw new Error('recovery boundary does not identify the failed cell parent')
790
- }
992
+ if (frontierIndex !== expectedFrontier) continue
993
+ const resolvesUnavailable = blockedUnavailableSeq === boundary.failedCallSeq
994
+ if (failedIndex < 0 && !resolvesUnavailable) continue
791
995
  for (let index = 0; index < state.nodes.length; index += 1) {
792
996
  if (dependsOn(state.nodes, index, failedIndex)) invalidCallSeqs.add(state.nodes[index].callSeq)
793
997
  }
794
- appliedBoundaries.push(boundary)
795
- state = foldRecords(records, invalidCallSeqs, appliedBoundaries)
998
+ if (failedIndex < 0) {
999
+ for (const record of records) {
1000
+ if (record.call.seq >= boundary.failedCallSeq) invalidCallSeqs.add(record.call.seq)
1001
+ }
1002
+ }
1003
+ state = foldRecords(records, invalidCallSeqs, {
1004
+ surfaceContracted: resolvesUnavailable ? false : state.surfaceContracted,
1005
+ })
1006
+ if (resolvesUnavailable) {
1007
+ blockedUnavailableSeq = undefined
1008
+ if (unavailableSeq === boundary.failedCallSeq) unavailableSeq = undefined
1009
+ }
796
1010
  }
797
1011
  }
798
1012
  for (const call of calls) {
799
1013
  const result = results.get(call.seq)
800
1014
  const code = call.data?.name === 'edit_run_code' ? result?.derived?.code : sourceForRunCall(call)
801
- const record = { call, code, result }
1015
+ const record = {
1016
+ call,
1017
+ code,
1018
+ result: invalidResultReasons.has(call.seq)
1019
+ ? { ...result, journal: undefined, error: invalidResultReasons.get(call.seq) }
1020
+ : result,
1021
+ hidden: visibleCalls instanceof Set && !visibleCalls.has(call.seq),
1022
+ }
802
1023
  if (call.data?.name === 'edit_run_code' && record.code === undefined && result === undefined) continue
803
1024
  if (result?.journal === undefined && confirmedNoops.has(call.seq)) continue
804
1025
  orderedRecords.push(record)
@@ -809,15 +1030,62 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
809
1030
  for (const record of orderedRecords) {
810
1031
  applyBoundariesBefore(recordEventSeq(record))
811
1032
  records.push(record)
812
- applyRecord(state, record, invalidCallSeqs)
1033
+ if (record.hidden) {
1034
+ blockedUnavailableSeq ??= record.call.seq
1035
+ unavailableSeq ??= record.call.seq
1036
+ unavailableReason = 'model-visible provenance was shadowed'
1037
+ invalidCallSeqs.add(record.call.seq)
1038
+ state.surfaceContracted = true
1039
+ state.trusted = false
1040
+ state.volatileSuffix.push({ seq: record.call.seq, code: undefined, reason: unavailableReason })
1041
+ continue
1042
+ }
1043
+ if (blockedUnavailableSeq !== undefined && record.call.seq >= blockedUnavailableSeq) {
1044
+ invalidCallSeqs.add(record.call.seq)
1045
+ if (!state.volatileSuffix.some(item => item.seq === record.call.seq)) {
1046
+ state.trusted = false
1047
+ state.volatileSuffix.push({
1048
+ seq: record.call.seq,
1049
+ code: record.code,
1050
+ reason: unavailableReason,
1051
+ })
1052
+ }
1053
+ continue
1054
+ }
1055
+ if (record.result?.journal === undefined) {
1056
+ blockedUnavailableSeq ??= record.call.seq
1057
+ unavailableSeq ??= record.call.seq
1058
+ unavailableReason = record.result?.error ?? 'missing dsh-ptc-plus journal result'
1059
+ invalidCallSeqs.add(record.call.seq)
1060
+ state.trusted = false
1061
+ state.volatileSuffix.push({ seq: record.call.seq, code: record.code, reason: unavailableReason })
1062
+ continue
1063
+ }
1064
+ try {
1065
+ applyRecord(state, record, invalidCallSeqs)
1066
+ } catch (error) {
1067
+ unavailableSeq ??= record.call.seq
1068
+ blockedUnavailableSeq ??= record.call.seq
1069
+ unavailableReason = error.message
1070
+ invalidCallSeqs.add(record.call.seq)
1071
+ }
813
1072
  }
814
1073
  applyBoundariesBefore(Number.POSITIVE_INFINITY)
1074
+ if (unavailableSeq !== undefined
1075
+ && !state.volatileSuffix.some(item => item.seq === unavailableSeq)) {
1076
+ state.trusted = false
1077
+ state.volatileSuffix.push({
1078
+ seq: unavailableSeq,
1079
+ code: undefined,
1080
+ reason: unavailableReason,
1081
+ })
1082
+ }
815
1083
  return {
816
1084
  nodes: state.nodes,
817
1085
  head: state.head,
818
1086
  checkpoints: state.checkpoints,
819
1087
  volatileSuffix: state.volatileSuffix,
820
- available: found,
1088
+ available: found && !state.surfaceContracted && blockedUnavailableSeq === undefined,
821
1089
  }
822
1090
  }
823
1091
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  foldSessionTimeline,
3
3
  } from './session-journal.js'
4
+ import { sessionEvents } from './session-events.js'
4
5
 
5
6
  const SYSTEM_PROMPT_PLUGIN = '@deepseek-ai/dsh-system-prompt'
6
7
 
@@ -43,7 +44,7 @@ function cordisTranscriptFacts(timeline) {
43
44
 
44
45
  /** Project session events into immutable facts consumed by prompt presentation. */
45
46
  export function projectSessionLog(agent, requestedEdit) {
46
- const events = agent?.session?.events
47
+ const events = sessionEvents(agent?.session)
47
48
  if (!Array.isArray(events)) {
48
49
  return Object.freeze({
49
50
  openTurn: false,