dsh-ptc-plus 0.5.6 → 0.5.8

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.
@@ -7,6 +7,8 @@ import {
7
7
  JOURNAL_KEY,
8
8
  RECOVERY_BOUNDARY_KEY,
9
9
  RECOVERY_BOUNDARY_EVENT,
10
+ ENVIRONMENT_RESET_VERSION,
11
+ isEnvironmentResetEvent,
10
12
  DIAGNOSTIC_WORDING_KEY,
11
13
  DIAGNOSTIC_WORDING_FIELDS,
12
14
  SETTLEMENT_PROOF_KEY,
@@ -40,7 +42,7 @@ import {
40
42
 
41
43
  const RECOVERY_BOUNDARY_EVIDENCE = Symbol('recoveryBoundaryEvidence')
42
44
  const RUN_SELECTION_CLEAR_REASONS = new Set([
43
- 'turn/start', 'turn/end', 'duplicate-call-sequence', 'invalid-source-relation',
45
+ 'turn/start', 'turn/end', 'environment-reset', 'duplicate-call-sequence', 'invalid-source-relation',
44
46
  'ambiguous-call-id', 'ambiguous-call-sequence', 'duplicate-result', 'result-identity-mismatch',
45
47
  ])
46
48
  const MODEL_VISIBLE_SURFACE_EVENTS = new Set([
@@ -80,8 +82,12 @@ function sourceForRunCall(call, executableCalls) {
80
82
  /** Select the boundary of a failed replay node or an unavailable recovered suffix. */
81
83
  export function recoveryBoundaryForHistory(history, failedNode, { reset = false } = {}) {
82
84
  if (reset) failedNode = history.nodes[0]
85
+ const unavailable = history.available === false ? history.volatileSuffix[0] : undefined
86
+ if (failedNode === undefined && unavailable?.kind === 'environment-reset') {
87
+ return { failedResetSeq: unavailable.seq, frontierCallSeq: null }
88
+ }
83
89
  const failedCallSeq = failedNode === undefined
84
- ? history.available === false ? history.volatileSuffix[0]?.seq : undefined
90
+ ? unavailable?.seq
85
91
  : failedNode.callSeq
86
92
  if (failedCallSeq === undefined) return undefined
87
93
  const frontier = reset ? undefined : failedNode === undefined ? history.head : failedNode.parent
@@ -735,6 +741,7 @@ function correlationEvent(event) {
735
741
  ...(typeof event.data?.message?.source?.callId === 'string'
736
742
  ? { callId: event.data.message.source.callId } : {}),
737
743
  } } } } : {}),
744
+ ...(isEnvironmentResetEvent(event) ? { data: { source: event.data.source } } : {}),
738
745
  ...(event.type === 'compaction/prune' ? { data: {
739
746
  ...(event.data?.shadowedSeqs === undefined ? {} : { shadowedSeqs: event.data.shadowedSeqs }),
740
747
  ...(event.data?.shadowedRange === undefined ? {} : { shadowedRange: event.data.shadowedRange }),
@@ -1158,7 +1165,7 @@ export function validateSessionTimelineState(value, logFacts, options = {}) {
1158
1165
  'ordinaryResultSeqs', 'settledResultsByEventSeq', 'scope', 'latestRun', 'editableRun', 'lastSuccessfulRunIndex',
1159
1166
  'unavailableResultSeq', 'unavailableResultReason', 'pruneReplacementWindow', 'runSelectionFrontier',
1160
1167
  'runSelectionHistory', 'lastEventIndex', 'eventFacts', 'retainedBytes', 'retentionSummary', 'compacted',
1161
- 'hotCellDropped']) && typeof value.openTurn === 'boolean'
1168
+ 'hotCellDropped', 'environmentReset']) && typeof value.openTurn === 'boolean'
1162
1169
  && typeof value.found === 'boolean' && isCanonicalSequence(value.scope)
1163
1170
  && typeof value.retainedBytes === 'number' && value.retainedBytes >= 0
1164
1171
  && (value.retentionSummary === undefined || value.retentionSummary === null || isRecord(value.retentionSummary))
@@ -1328,9 +1335,10 @@ export function validateSessionTimelineState(value, logFacts, options = {}) {
1328
1335
  && isCanonicalSequence(observation.eventIndex) && observation.eventIndex > correlationIndex
1329
1336
  && value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
1330
1337
  && isRecord(observation.event) && isCanonicalSequence(observation.event.seq)
1331
- && ['turn/start', 'turn/end', RECOVERY_BOUNDARY_EVENT, 'compaction/prune', 'tool/call', 'tool/result',
1338
+ && ['turn/start', 'turn/end', 'user/message', RECOVERY_BOUNDARY_EVENT, 'compaction/prune', 'tool/call', 'tool/result',
1332
1339
  'tool/ptc-dispatch-start', 'tool/ptc-dispatch']
1333
1340
  .includes(observation.event.type)
1341
+ && (observation.event.type !== 'user/message' || isEnvironmentResetEvent(observation.event))
1334
1342
  && deepEqualJson(correlationEvent(observation.event), observation.event)
1335
1343
  && (observation.event.type === 'tool/ptc-dispatch-start'
1336
1344
  && observation.event.data?.name === 'edit_run_code'
@@ -1363,6 +1371,9 @@ export function validateSessionTimelineState(value, logFacts, options = {}) {
1363
1371
  }
1364
1372
  reconstructed = replaySessionTimelineFact(reconstructed, observation, executable)
1365
1373
  }
1374
+ const resetFact = value.eventFacts.findLast(fact => isEnvironmentResetEvent(fact.event))
1375
+ requireFact(deepEqualJson(value.environmentReset ?? null, resetFact === undefined ? null
1376
+ : { seq: resetFact.event.seq, index: resetFact.eventIndex }))
1366
1377
  const summary = retentionSummaryOf(value)
1367
1378
  // A compacted state that lost its summary must be rejected: dropping the summary
1368
1379
  // would erase the only trace of the clusters the window removed.
@@ -1428,7 +1439,8 @@ export function validateSessionTimelineState(value, logFacts, options = {}) {
1428
1439
  if (item.frontier !== undefined) {
1429
1440
  dispatchFrontier = item.frontier
1430
1441
  dispatchRun = undefined
1431
- if (item.frontier.reason === 'turn/start' || item.frontier.reason === 'turn/end') {
1442
+ if (item.frontier.reason === 'turn/start' || item.frontier.reason === 'turn/end'
1443
+ || item.frontier.reason === 'environment-reset') {
1432
1444
  dispatchScope = item.frontier.scope
1433
1445
  dispatchClaims.clear()
1434
1446
  dispatchEdits.clear()
@@ -1669,13 +1681,14 @@ function requireRecordedSettlementProof(meta, journal, isError = undefined) {
1669
1681
  }
1670
1682
 
1671
1683
  function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, retainedCalls) {
1684
+ if (inputEvent?.type === 'user/message' && !isEnvironmentResetEvent(inputEvent)) return previous
1672
1685
  const nested = inputEvent?.type === 'tool/ptc-dispatch-start'
1673
1686
  || inputEvent?.type === 'tool/ptc-dispatch'
1674
1687
  if (nested && inputEvent.data?.name !== 'edit_run_code'
1675
1688
  && inputEvent.data?.name !== 'run_code'
1676
1689
  && !previous.eventFacts.some(fact => fact.event.type === 'tool/ptc-dispatch-start'
1677
1690
  && fact.event.data?.subCallId === inputEvent.data?.subCallId)) return previous
1678
- if (!['turn/start', 'turn/end', RECOVERY_BOUNDARY_EVENT, 'compaction/prune', 'tool/call', 'tool/result',
1691
+ if (!['turn/start', 'turn/end', 'user/message', RECOVERY_BOUNDARY_EVENT, 'compaction/prune', 'tool/call', 'tool/result',
1679
1692
  'tool/ptc-dispatch-start', 'tool/ptc-dispatch'].includes(inputEvent?.type)) {
1680
1693
  return previous
1681
1694
  }
@@ -1746,6 +1759,21 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, reta
1746
1759
  resetTurn(false)
1747
1760
  continue
1748
1761
  }
1762
+ if (isEnvironmentResetEvent(event)) {
1763
+ state.environmentReset = { seq: event.seq, index: eventIndex }
1764
+ pendingByCallId.clear()
1765
+ seenCallIds.clear()
1766
+ claimedEditTargets.clear()
1767
+ editClaims.clear()
1768
+ delete state.unavailableResultSeq
1769
+ delete state.unavailableResultReason
1770
+ clearRunSelection('environment-reset')
1771
+ if (event.data.source.version !== ENVIRONMENT_RESET_VERSION) {
1772
+ state.unavailableResultSeq = isCanonicalSequence(event.seq) ? event.seq : eventIndex
1773
+ state.unavailableResultReason = { seq: state.unavailableResultSeq, reason: 'invalid PTC environment reset boundary' }
1774
+ }
1775
+ continue
1776
+ }
1749
1777
  if (event?.type === RECOVERY_BOUNDARY_EVENT) {
1750
1778
  state.unavailableResultSeq ??= isCanonicalSequence(event.seq) ? event.seq : eventIndex
1751
1779
  continue
@@ -1824,6 +1852,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, reta
1824
1852
  settledResultsByEventSeq.set(event.seq, republishedSettlement)
1825
1853
  continue
1826
1854
  }
1855
+ if (entry?.event?.seq <= (state.environmentReset?.seq ?? -1)) continue
1827
1856
  if (hasSourceRelation && !canonicalSourceRelation) {
1828
1857
  const callSeq = invalidSourceCallSeq(event, state.executableCalls, pendingByCallId)
1829
1858
  if (callSeq !== undefined) {
@@ -2387,29 +2416,33 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2387
2416
  const needsPayload = [...folded.executableCalls.values()]
2388
2417
  .some(entry => typeof entry?.event?.data?.arguments !== 'string')
2389
2418
  const timeline = needsPayload ? withFullRetention(() => foldSessionTimeline(events)) : folded
2390
- const calls = timelineCalls(timeline).filter(call => call.seq !== currentCallSeq)
2391
- const { executableCalls, results, found } = timeline
2419
+ const resetSeq = timeline.environmentReset?.seq ?? -1
2420
+ const calls = timelineCalls(timeline).filter(call => call.seq !== currentCallSeq && call.seq > resetSeq)
2421
+ const executableCalls = new Map([...timeline.executableCalls].filter(([seq]) => seq > resetSeq))
2422
+ const results = new Map([...timeline.results].filter(([seq]) => seq > resetSeq))
2423
+ const found = timeline.found || resetSeq >= 0
2392
2424
  const visibleCalls = options?.visibleCallSeqs
2393
2425
  const appliedBoundaries = options?.[RECOVERY_BOUNDARY_EVIDENCE]
2394
2426
  const invalidCallSeqs = new Set()
2395
2427
  let unavailableBoundary
2396
- const registerUnavailable = ({ seq, reason, code }, recoveryState) => {
2428
+ const registerUnavailable = ({ seq, reason, code, kind }, recoveryState) => {
2397
2429
  if (unavailableBoundary === undefined || seq <= unavailableBoundary.seq) {
2398
- unavailableBoundary = { seq, reason }
2430
+ unavailableBoundary = { seq, reason, ...(kind === undefined ? {} : { kind }) }
2399
2431
  }
2400
2432
  if (recoveryState === undefined) return
2401
2433
  invalidCallSeqs.add(seq)
2402
2434
  recoveryState.trusted = false
2403
2435
  if (!recoveryState.volatileSuffix.some(item => item.seq === seq)) {
2404
- const item = { seq, code, reason }
2436
+ const item = { seq, code, reason, ...(kind === undefined ? {} : { kind }) }
2405
2437
  const index = recoveryState.volatileSuffix.findIndex(existing => existing.seq > seq)
2406
2438
  if (index < 0) recoveryState.volatileSuffix.push(item)
2407
2439
  else recoveryState.volatileSuffix.splice(index, 0, item)
2408
2440
  }
2409
2441
  }
2410
- if (timeline.unavailableResultSeq !== undefined) {
2442
+ if (timeline.unavailableResultSeq !== undefined && timeline.unavailableResultSeq >= resetSeq) {
2411
2443
  registerUnavailable({
2412
2444
  seq: timeline.unavailableResultSeq,
2445
+ ...(timeline.unavailableResultSeq === resetSeq ? { kind: 'environment-reset' } : {}),
2413
2446
  reason: timeline.unavailableResultReason?.seq === timeline.unavailableResultSeq
2414
2447
  ? timeline.unavailableResultReason.reason
2415
2448
  : 'unavailable or malformed dsh-ptc-plus journal result',
@@ -2428,7 +2461,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2428
2461
  normalizedExtraBoundaries = []
2429
2462
  }
2430
2463
  const boundaries = [
2431
- ...timeline.boundaries,
2464
+ ...recoveryBoundariesAfterReset(timeline),
2432
2465
  ...normalizedExtraBoundaries.map(boundary => ({
2433
2466
  ...boundary,
2434
2467
  eventSeq: Number.POSITIVE_INFINITY,
@@ -2442,7 +2475,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2442
2475
  for (const [resultSeq, { journal, eventIndex }] of results.entries()) {
2443
2476
  const candidateConfirms = []
2444
2477
  for (const callSeq of journal?.confirms ?? []) {
2445
- if (!isConfirmableNoop(timeline, callSeq, eventIndex)) {
2478
+ if (callSeq <= resetSeq || !isConfirmableNoop(timeline, callSeq, eventIndex)) {
2446
2479
  const reason = `confirmed no-op does not identify an earlier unjournaled run_code call seq ${callSeq}`
2447
2480
  invalidResultReasons.set(resultSeq, reason)
2448
2481
  registerUnavailable({ seq: resultSeq, reason })
@@ -2470,9 +2503,10 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2470
2503
  ))
2471
2504
  const rejectBoundary = (boundary, reason) => {
2472
2505
  const carrierCallSeq = carrierCallSeqForBoundary(boundary)
2506
+ const failedSeq = boundary.failedResetSeq ?? boundary.failedCallSeq
2473
2507
  const contractionSeq = carrierCallSeq === undefined
2474
- ? boundary.failedCallSeq
2475
- : Math.min(boundary.failedCallSeq, carrierCallSeq)
2508
+ ? failedSeq
2509
+ : Math.min(failedSeq, carrierCallSeq)
2476
2510
  // A rejected boundary still establishes a conservative contraction point:
2477
2511
  // the same window an accepted boundary would contract stays unproved, so
2478
2512
  // settlements folded after this rejection cannot re-enter the frontier.
@@ -2486,12 +2520,13 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2486
2520
  ...foldOptions,
2487
2521
  surfaceContracted: state.surfaceContracted,
2488
2522
  })
2489
- registerUnavailable({ seq: carrierCallSeq ?? boundary.failedCallSeq, reason }, state)
2523
+ registerUnavailable({ seq: carrierCallSeq ?? failedSeq, reason }, state)
2490
2524
  }
2491
2525
  const applyBoundariesBefore = (seq) => {
2492
2526
  while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq <= seq) {
2493
2527
  const boundary = boundaries[boundaryIndex++]
2494
2528
  const carrierCallSeq = carrierCallSeqForBoundary(boundary)
2529
+ const failedSeq = boundary.failedResetSeq ?? boundary.failedCallSeq
2495
2530
  const failedCall = executableCalls.get(boundary.failedCallSeq)
2496
2531
  // Derived records exist only after the shared timeline has proved both
2497
2532
  // dispatch pairs and the final root publication. A raw nested start does
@@ -2503,14 +2538,17 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2503
2538
  )) : []
2504
2539
  const failedExecution = failedCall === undefined && derivedRecords.length === 1
2505
2540
  ? derivedRecords[0] : undefined
2541
+ const failedReset = boundary.failedResetSeq === resetSeq
2542
+ && unavailableBoundary?.seq === resetSeq && unavailableBoundary.kind === 'environment-reset'
2543
+ && resetSeq < boundary.eventSeq
2506
2544
  const isEarlierExecution = failedCall === undefined
2507
2545
  ? failedExecution !== undefined && recordEventSeq(failedExecution) < boundary.eventSeq
2508
2546
  : failedCall.event.seq < boundary.eventSeq
2509
- if (!isEarlierExecution) {
2547
+ if (boundary.failedResetSeq !== undefined ? !failedReset : !isEarlierExecution) {
2510
2548
  rejectBoundary(boundary, 'recovery boundary does not identify an earlier executable call')
2511
2549
  continue
2512
2550
  }
2513
- const failedIndex = state.nodes.findIndex(node => node.callSeq === boundary.failedCallSeq)
2551
+ const failedIndex = state.nodes.findIndex(node => node.callSeq === failedSeq)
2514
2552
  const expectedFrontier = failedIndex < 0 ? state.head : state.nodes[failedIndex].parent
2515
2553
  const frontierIndex = boundary.frontierCallSeq === null
2516
2554
  ? undefined
@@ -2519,25 +2557,25 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2519
2557
  rejectBoundary(boundary, 'recovery boundary frontier does not match the failed call parent')
2520
2558
  continue
2521
2559
  }
2522
- const resolvesUnavailable = unavailableBoundary?.seq === boundary.failedCallSeq
2560
+ const resolvesUnavailable = unavailableBoundary?.seq === failedSeq
2523
2561
  const resolvesConfirmedNoop = confirmedNoops.has(boundary.failedCallSeq)
2524
2562
  if (failedIndex < 0 && !resolvesUnavailable && !resolvesConfirmedNoop) {
2525
2563
  rejectBoundary(boundary, 'recovery boundary failed call is outside the verified frontier')
2526
2564
  continue
2527
2565
  }
2528
- if (carrierCallSeq !== undefined && carrierCallSeq <= boundary.failedCallSeq) {
2566
+ if (carrierCallSeq !== undefined && carrierCallSeq <= failedSeq) {
2529
2567
  rejectBoundary(boundary, 'recovery boundary carrier does not follow the failed call')
2530
2568
  continue
2531
2569
  }
2532
2570
  if (Array.isArray(appliedBoundaries)) appliedBoundaries.push(boundary)
2533
2571
  if (carrierCallSeq !== undefined) {
2534
- contractedCallRanges.push({ start: boundary.failedCallSeq, end: carrierCallSeq })
2572
+ contractedCallRanges.push({ start: failedSeq, end: carrierCallSeq })
2535
2573
  }
2536
2574
  for (let index = 0; index < state.nodes.length; index += 1) {
2537
2575
  if (dependsOn(state.nodes, index, failedIndex)) invalidCallSeqs.add(state.nodes[index].callSeq)
2538
2576
  }
2539
2577
  for (const record of records) {
2540
- if ((failedIndex < 0 && record.call.seq >= boundary.failedCallSeq)
2578
+ if ((failedIndex < 0 && record.call.seq >= failedSeq)
2541
2579
  || isContractedCallSeq(record.call.seq)) {
2542
2580
  invalidCallSeqs.add(record.call.seq)
2543
2581
  }
@@ -2663,7 +2701,12 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
2663
2701
  return history
2664
2702
  }
2665
2703
 
2666
- /** Prove that recovery consumes every persisted boundary at its declared frontier. */
2704
+ function recoveryBoundariesAfterReset(timeline) {
2705
+ const resetSeq = timeline.environmentReset?.seq ?? -1
2706
+ return timeline.boundaries.filter(boundary => boundary.carrierCallSeq > resetSeq)
2707
+ }
2708
+
2709
+ /** Prove every persisted boundary eligible after the latest reset at its declared frontier. */
2667
2710
  export function validateRecoveryBoundaryApplication(events) {
2668
2711
  const applied = []
2669
2712
  const recovered = recoverJournal(
@@ -2671,7 +2714,7 @@ export function validateRecoveryBoundaryApplication(events) {
2671
2714
  undefined,
2672
2715
  { [RECOVERY_BOUNDARY_EVIDENCE]: applied },
2673
2716
  )
2674
- const declared = foldSessionTimeline(events).boundaries.length
2717
+ const declared = recoveryBoundariesAfterReset(foldSessionTimeline(events)).length
2675
2718
  if (!recovered.available || applied.length !== declared) {
2676
2719
  throw new Error('migrated PTC recovery boundary does not prove its declared frontier')
2677
2720
  }
@@ -1,3 +1,4 @@
1
+ import { isPtcMessageSource } from './message-sources.js'
1
2
  import {
2
3
  LEGACY_DEFAULT_EXPORT_BINDING,
3
4
  LIVE_DEFAULT_EXPORT_BINDING,
@@ -29,6 +30,14 @@ export const PREVIOUS_JOURNAL_VERSION = 3
29
30
  export const USER_BINDING_RELATIONLESS_JOURNAL_VERSION = 4
30
31
  export const FINGERPRINT_REUSE_JOURNAL_VERSION = 5
31
32
  export const VERSIONED_BINDING_REUSE_JOURNAL_VERSION = 6
33
+ export const ENVIRONMENT_RESET_VERSION = 1
34
+
35
+ /** Structured producer identity proves a reset independently of notice wording. */
36
+ export function isEnvironmentResetEvent(event) {
37
+ const source = event?.data?.source
38
+ return event?.type === 'user/message' && isPtcMessageSource(source)
39
+ && source.form === 'environment-reset'
40
+ }
32
41
  export const RECOVERY_BOUNDARY_EVENT = 'ptc-plus/recovery-boundary'
33
42
 
34
43
  export const STATUSES = new Set(['durable', 'volatile', 'discarded', 'noop'])
@@ -121,6 +130,7 @@ export function normalizeJournalUserBindingNames(journal) {
121
130
  return normalizeUserBindingNames(journal.userBindingNames)
122
131
  }
123
132
  export const RECOVERY_BOUNDARY_FIELDS = new Set(['failedCallSeq', 'frontierCallSeq'])
133
+ export const RESET_RECOVERY_BOUNDARY_FIELDS = new Set(['failedResetSeq', 'frontierCallSeq'])
124
134
  export const REPL_TOOL_NAMES = new Set(['run_code', 'edit_run_code'])
125
135
  export const REWRITE_FIELDS = new Set(['kind', 'description', 'source'])
126
136
  export const REWRITE_KINDS = new Set(['import', 'redeclaration', 'export'])
@@ -63,6 +63,8 @@ import {
63
63
  JOURNAL_VERSIONS,
64
64
  USER_BINDINGS_REUSE_POLICIES,
65
65
  RECOVERY_BOUNDARY_FIELDS,
66
+ RESET_RECOVERY_BOUNDARY_FIELDS,
67
+ isEnvironmentResetEvent,
66
68
  DIAGNOSTIC_WORDING_KEY,
67
69
  SETTLEMENT_PROOF_KEY,
68
70
  SETTLEMENT_PROOF_FIELDS,
@@ -722,18 +724,19 @@ export function recoveryBoundariesEqual(left, right) {
722
724
  }
723
725
 
724
726
  function normalizeRecoveryBoundaryValue(value, eventSeq = undefined) {
727
+ const reset = isRecord(value) && Object.hasOwn(value, 'failedResetSeq')
725
728
  if (!isRecord(value)
726
- || !isCanonicalSequence(value.failedCallSeq)
727
- || (value.frontierCallSeq !== null
728
- && !isCanonicalSequence(value.frontierCallSeq))) {
729
+ || !isCanonicalSequence(reset ? value.failedResetSeq : value.failedCallSeq)
730
+ || (reset ? value.frontierCallSeq !== null
731
+ : value.frontierCallSeq !== null && !isCanonicalSequence(value.frontierCallSeq))) {
729
732
  throw new Error('invalid dsh-ptc-plus recovery boundary')
730
733
  }
731
- assertOwnFields(value, RECOVERY_BOUNDARY_FIELDS, 'recovery boundary')
734
+ assertOwnFields(value, reset ? RESET_RECOVERY_BOUNDARY_FIELDS : RECOVERY_BOUNDARY_FIELDS, 'recovery boundary')
732
735
  if (eventSeq !== undefined && !isCanonicalSequence(eventSeq)) {
733
736
  throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
734
737
  }
735
738
  return {
736
- failedCallSeq: value.failedCallSeq,
739
+ ...(reset ? { failedResetSeq: value.failedResetSeq } : { failedCallSeq: value.failedCallSeq }),
737
740
  frontierCallSeq: value.frontierCallSeq,
738
741
  ...(eventSeq === undefined ? {} : { eventSeq }),
739
742
  }
@@ -1111,15 +1114,24 @@ export function migrateRecoveryBoundaryEvents(events) {
1111
1114
  }
1112
1115
  return target.retainedSeq
1113
1116
  }
1114
- const mapBoundary = (boundary, resultSeq) => ({
1115
- failedCallSeq: mapCall(boundary.failedCallSeq, resultSeq, 'recovery-boundary failure'),
1116
- frontierCallSeq: boundary.frontierCallSeq === null ? null : (() => {
1117
- if (boundary.frontierCallSeq >= boundary.failedCallSeq) {
1118
- throw new Error('recovery-boundary frontier must precede its failed call')
1117
+ const mapBoundary = (boundary, resultSeq) => {
1118
+ if (boundary.failedResetSeq !== undefined) {
1119
+ const target = sequenceMap.get(boundary.failedResetSeq)
1120
+ if (!isEnvironmentResetEvent(target?.event) || boundary.failedResetSeq >= resultSeq) {
1121
+ throw new Error('unproved recovery-boundary reset event reference')
1119
1122
  }
1120
- return mapCall(boundary.frontierCallSeq, resultSeq, 'recovery-boundary frontier')
1121
- })(),
1122
- })
1123
+ return { failedResetSeq: mapEvent(boundary.failedResetSeq, 'recovery-boundary reset'), frontierCallSeq: null }
1124
+ }
1125
+ return {
1126
+ failedCallSeq: mapCall(boundary.failedCallSeq, resultSeq, 'recovery-boundary failure'),
1127
+ frontierCallSeq: boundary.frontierCallSeq === null ? null : (() => {
1128
+ if (boundary.frontierCallSeq >= boundary.failedCallSeq) {
1129
+ throw new Error('recovery-boundary frontier must precede its failed call')
1130
+ }
1131
+ return mapCall(boundary.frontierCallSeq, resultSeq, 'recovery-boundary frontier')
1132
+ })(),
1133
+ }
1134
+ }
1123
1135
  const migrated = []
1124
1136
  const pending = []
1125
1137
  const boundaryOrigins = new Map()
@@ -1,3 +1,4 @@
1
+ import { isEnvironmentResetEvent } from './session-journal-schema.js'
1
2
  import {
2
3
  advanceSessionTimeline, createSessionTimelineState, factDigest, replaySessionTimelineFact, withFullRetention,
3
4
  retentionSummaryOf, validateRetentionSummary, validateSessionTimelineState, admissionReplayBudget,
@@ -101,7 +102,7 @@ export function runtimeMessageFacts(records, initial = {}) {
101
102
 
102
103
  function presentationEvent(event) {
103
104
  const recognized = event?.type === 'user/message'
104
- && (systemPromptSnapshotSections(event.data) !== undefined || readRuntimeMessage(event.data) !== undefined
105
+ && (isEnvironmentResetEvent(event) || systemPromptSnapshotSections(event.data) !== undefined || readRuntimeMessage(event.data) !== undefined
105
106
  || isBindingAdoptionStatement(event.data))
106
107
  const data = recognized ? { source: event.data.source, content: event.data.content }
107
108
  : event?.type === 'user/message' && typeof event.data?.source?.kind === 'string'
@@ -171,11 +172,9 @@ export function createSessionLogProjection(options = {}) {
171
172
  const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
172
173
  return Object.freeze({
173
174
  key: SESSION_LOG_PROJECTION_KEY,
174
- // 14 records the binding-adoption statement this build cannot resolve: the fact it
175
- // keeps now carries the form its own replay needs, and `ptcMessages` gained a record
176
- // for it. A checkpoint written by an earlier fold answers a different question, so
177
- // the version moves and stored checkpoints are refolded rather than admitted.
178
- stateVersion: 14,
175
+ // 16 classifies damaged reset recovery targets separately from failed calls.
176
+ // Refold older caches to retain their reset corruption evidence.
177
+ stateVersion: 16,
179
178
  stateSchema: Object.freeze({ parse(value) {
180
179
  if (!isJsonValue(value) || !isRecord(value) || !isRecord(value.timeline)
181
180
  || typeof value.timeline.openTurn !== 'boolean' || typeof value.timeline.found !== 'boolean'
@@ -201,6 +200,7 @@ export function createSessionLogProjection(options = {}) {
201
200
  // ordered log facts whose indices agree with the timeline facts, each
202
201
  // timeline fact carrying a digest that matches the log fact it points at,
203
202
  // and a frontier that names the processed cut.
203
+ const timelineFacts = new Map(value.timeline.eventFacts.map(fact => [fact.eventIndex, fact]))
204
204
  let lastIndex = -1
205
205
  for (const fact of value.logFacts) {
206
206
  if (!isRecord(fact) || Object.keys(fact).some(key => !['index', 'event'].includes(key))
@@ -209,6 +209,12 @@ export function createSessionLogProjection(options = {}) {
209
209
  || !deepEqualJson(presentationEvent(fact.event), fact.event)) {
210
210
  throw new TypeError('invalid PTC session-log projection checkpoint facts')
211
211
  }
212
+ // Reset facts are never dropped with retained cell clusters. Their log
213
+ // identity must survive even when compaction prevents full replay below.
214
+ if (isEnvironmentResetEvent(fact.event)
215
+ && !isEnvironmentResetEvent(timelineFacts.get(fact.index)?.event)) {
216
+ throw new TypeError('inconsistent PTC session-log projection checkpoint missing reset fact')
217
+ }
212
218
  lastIndex = fact.index
213
219
  }
214
220
  // Strictly increasing indices are what makes this index one-to-one, so a
@@ -221,7 +227,9 @@ export function createSessionLogProjection(options = {}) {
221
227
  if (logFact === undefined) {
222
228
  throw new TypeError('inconsistent PTC session-log projection checkpoint event identity')
223
229
  }
224
- if (logFact.event.seq !== timelineFact.event.seq || logFact.event.type !== timelineFact.event.type) {
230
+ if (logFact.event.seq !== timelineFact.event.seq || logFact.event.type !== timelineFact.event.type
231
+ || (isEnvironmentResetEvent(timelineFact.event)
232
+ && !deepEqualJson(logFact.event.data?.source, timelineFact.event.data?.source))) {
225
233
  throw new TypeError('inconsistent PTC session-log projection checkpoint event identity')
226
234
  }
227
235
  }
@@ -241,7 +249,6 @@ export function createSessionLogProjection(options = {}) {
241
249
  // longer reach the stored state. Admission proves the summary instead (below)
242
250
  // and skips the replay entirely: it is both impossible to satisfy and the
243
251
  // most expensive thing this file does.
244
- const timelineFacts = new Map(value.timeline.eventFacts.map(fact => [fact.eventIndex, fact]))
245
252
  let replayBytes = 0
246
253
  let replayComplete = true
247
254
  let reconstructed = emptyLogState()
@@ -1,3 +1,4 @@
1
+ import { environmentResetMessage } from './runtime-messages.js'
1
2
  import { createHash } from 'node:crypto'
2
3
  import { isAbsolute } from 'node:path'
3
4
  import { isDeepStrictEqual } from 'node:util'
@@ -1623,6 +1624,18 @@ export class SessionRuntime {
1623
1624
  if (noops.size === 0) this.pendingNoops.delete(sessionId)
1624
1625
  }
1625
1626
 
1627
+ /** Explicit Host reset ends live state and every earlier replay/checkpoint branch. */
1628
+ async resetEnvironment(session) {
1629
+ if (typeof session?.append !== 'function') {
1630
+ throw new TypeError('ptc-plus: environment reset requires the public session append API')
1631
+ }
1632
+ const id = String(session.id)
1633
+ await this.#releaseKernel(id)
1634
+ await session.append('user/message', environmentResetMessage(), { surfaceOp: 'append' })
1635
+ this.releasedAgents.delete(id)
1636
+ this.disposedSessions.delete(id)
1637
+ }
1638
+
1626
1639
  async disposeSession(sessionId) {
1627
1640
  const id = String(sessionId)
1628
1641
  // Recorded before the early return: an identity whose owning session was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ptc-plus",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "icon": "assets/ptc-plus-icon.svg",
5
5
  "description": "A session-bound agent-native REPL for DeepSeek Harness PTC mode.",
6
6
  "type": "module",
@@ -29,6 +29,7 @@ import {
29
29
  EDIT_TARGET_FIELDS,
30
30
  DERIVED_RUN_FIELDS,
31
31
  RECOVERY_BOUNDARY_FIELDS,
32
+ RESET_RECOVERY_BOUNDARY_FIELDS,
32
33
  REWRITE_FIELDS,
33
34
  REWRITE_KINDS,
34
35
  USER_BINDINGS_REUSE_POLICIES,
@@ -419,7 +420,9 @@ function isValidDerivedRun(value) {
419
420
  function isValidRecoveryBoundaries(value) {
420
421
  try {
421
422
  return Array.isArray(value) && value.every(boundary => (
422
- hasExactFields(boundary, RECOVERY_BOUNDARY_FIELDS)
423
+ hasExactFields(boundary, RESET_RECOVERY_BOUNDARY_FIELDS)
424
+ && isSafeSequence(boundary.failedResetSeq) && boundary.frontierCallSeq === null
425
+ || hasExactFields(boundary, RECOVERY_BOUNDARY_FIELDS)
423
426
  && isSafeSequence(boundary.failedCallSeq)
424
427
  && (boundary.frontierCallSeq === null || isSafeSequence(boundary.frontierCallSeq))
425
428
  ))
@@ -116,13 +116,6 @@ export function createPtcSettingsView(React, deps) {
116
116
  onChange: event => onChange(field, event.target.checked),
117
117
  })
118
118
  }
119
- if (field.type === 'enum') {
120
- return h('input', {
121
- type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', checked: value !== 'protected',
122
- disabled, 'aria-label': label,
123
- onChange: event => onChange(field, event.target.checked ? 'stateful' : 'protected'),
124
- })
125
- }
126
119
  return h('input', {
127
120
  type: 'number', className: 'ptcPlusInput',
128
121
  value: Number.isSafeInteger(value) ? String(value) : '',
@@ -402,9 +395,9 @@ export function createPtcSettingsView(React, deps) {
402
395
  h('div', { className: 'ptcPlusLabel' }, t(`${field.key}.label`)),
403
396
  field.description === '' ? null : h('div', { className: 'ptcPlusDetail' },
404
397
  t(migratingPolicy ? 'bindingPolicy.migrationDescription' : `${field.key}.description`))),
405
- migratingPolicy
406
- ? h(Select, { value: 'legacy', disabled: fieldDisabled(field), label: t(`${field.key}.label`),
407
- options: [{ value: 'legacy', label: t('bindingPolicy.legacy'), disabled: true },
398
+ field.type === 'enum'
399
+ ? h(Select, { value: migratingPolicy ? 'legacy' : value[field.key], disabled: fieldDisabled(field), label: t(`${field.key}.label`),
400
+ options: [...(migratingPolicy ? [{ value: 'legacy', label: t('bindingPolicy.legacy'), disabled: true }] : []),
408
401
  ...field.options.map(policy => ({ value: policy, label: t(`bindingPolicy.${policy}`) }))],
409
402
  onChange: next => persist(field, next) })
410
403
  : fieldInput(field, value[field.key], fieldDisabled(field), persist, t(`${field.key}.label`))),