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.
- package/README.en.md +10 -2
- package/README.md +10 -2
- package/client.cjs +13 -23
- package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +18 -12
- package/docs/adr/0038-freeze-session-capability-plans.md +1 -1
- package/docs/adr/0042-persist-provider-availability-and-handback.md +8 -4
- package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +2 -0
- package/docs/adr/0051-persist-explicit-host-environment-resets.md +29 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +2 -2
- package/docs/capability-planning-components.md +1 -1
- package/docs/durability-design.md +10 -0
- package/docs/installation.md +2 -2
- package/docs/releases/v0.5.6.md +7 -3
- package/docs/releases/v0.5.7.md +51 -0
- package/docs/runtime-reference.md +4 -4
- package/internal/capability-environment.js +7 -3
- package/internal/config-spec.js +2 -2
- package/internal/cordis-tools-owner.js +32 -28
- package/internal/execution-seam-compat.js +13 -15
- package/internal/runtime-bridge-owner.js +7 -1
- package/internal/runtime-contexts.js +5 -1
- package/internal/runtime-messages.js +8 -0
- package/internal/session-journal-recovery.js +68 -25
- package/internal/session-journal-schema.js +10 -0
- package/internal/session-journal.js +25 -13
- package/internal/session-log-view.js +15 -8
- package/internal/session-runtime.js +13 -0
- package/package.json +1 -1
- package/src/client-activity.js +4 -1
- package/src/client-settings-view.js +3 -10
|
@@ -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
|
-
?
|
|
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
|
|
2391
|
-
const
|
|
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
|
|
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
|
-
?
|
|
2475
|
-
: Math.min(
|
|
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 ??
|
|
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 ===
|
|
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 ===
|
|
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 <=
|
|
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:
|
|
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 >=
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
if (boundary.
|
|
1118
|
-
throw new Error('recovery-boundary
|
|
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
|
|
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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
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
package/src/client-activity.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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`))),
|