dsh-ptc-plus 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.en.md +10 -4
  2. package/README.md +10 -4
  3. package/client.cjs +1623 -104
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  8. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  9. package/docs/adr/0023-global-user-bindings.md +12 -0
  10. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  11. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  12. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  13. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  14. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  15. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  16. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  17. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  18. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  19. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  20. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  21. package/docs/adr/README.md +7 -1
  22. package/docs/architecture.md +3 -3
  23. package/docs/client-ui.md +5 -2
  24. package/docs/durability-design.md +15 -2
  25. package/docs/installation.md +9 -9
  26. package/docs/releases/v0.5.2.md +91 -0
  27. package/docs/releases/v0.5.3.md +77 -0
  28. package/docs/runtime-reference.md +43 -3
  29. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  30. package/docs/user-bindings.en.md +15 -0
  31. package/docs/user-bindings.md +15 -0
  32. package/docs/v0.5.3-goals.md +117 -0
  33. package/docs/verification.md +7 -3
  34. package/index.js +100 -31
  35. package/internal/binding-adoption.js +136 -0
  36. package/internal/bounded-literal.js +85 -0
  37. package/internal/capability-command-receipt.js +18 -4
  38. package/internal/capability-delivery-proof.js +70 -0
  39. package/internal/capability-environment.js +200 -11
  40. package/internal/capability-plan.js +11 -8
  41. package/internal/cell-error.js +13 -0
  42. package/internal/config-spec.js +126 -6
  43. package/internal/cordis-tools-owner.js +2 -1
  44. package/internal/direct-surface-owner.js +39 -21
  45. package/internal/edit-transport-owner.js +81 -15
  46. package/internal/execution-provider-owner.js +6 -0
  47. package/internal/execution-seam-compat.js +25 -0
  48. package/internal/host-tool-sdk.js +308 -0
  49. package/internal/isolated-worker.js +134 -17
  50. package/internal/kernel-worker.js +167 -3
  51. package/internal/legacy-cell-analysis.js +14 -2
  52. package/internal/legacy-repl-convenience.js +16 -3
  53. package/internal/official-repl-priority.js +31 -10
  54. package/internal/program-bindings.js +12 -1
  55. package/internal/projection-retention.js +238 -0
  56. package/internal/prompt-text.js +858 -0
  57. package/internal/recovery-tips.js +50 -11
  58. package/internal/rejected-cell-editor.js +3 -2
  59. package/internal/repl-memory-projection.js +21 -2
  60. package/internal/runtime-bridge-owner.js +114 -9
  61. package/internal/runtime-config.js +2 -0
  62. package/internal/runtime-contexts.js +14 -5
  63. package/internal/runtime-messages.js +48 -15
  64. package/internal/session-cell-executor.js +132 -11
  65. package/internal/session-journal-recovery.js +1204 -116
  66. package/internal/session-journal-schema.js +10 -5
  67. package/internal/session-journal.js +214 -14
  68. package/internal/session-log-view.js +133 -26
  69. package/internal/session-runtime.js +746 -38
  70. package/internal/session-state.js +27 -0
  71. package/internal/stateful-root-compiler.js +11 -0
  72. package/internal/stateful-root-runtime.js +17 -0
  73. package/internal/user-binding-console.js +7 -0
  74. package/internal/user-bindings-owner.js +112 -6
  75. package/internal/user-bindings.js +2 -2
  76. package/internal/validated-parse-repair.js +6 -1
  77. package/internal/worker-client.js +27 -4
  78. package/package.json +16 -6
  79. package/src/client-activity.js +24 -4
  80. package/src/client-capabilities-view.js +504 -27
  81. package/src/client-copy.js +187 -2
  82. package/src/client-indicator-view.js +2 -1
  83. package/src/client-repl-view.js +35 -11
  84. package/src/client-styles.js +64 -2
  85. package/src/client.js +3 -1
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { isAbsolute } from 'node:path'
2
3
  import { isDeepStrictEqual } from 'node:util'
3
4
  import { diagnostic, renderDiagnostic } from './diagnostic.js'
@@ -12,10 +13,13 @@ import {
12
13
  import {
13
14
  foldSessionTimeline,
14
15
  pathToHead,
16
+ bindingNamesOfOperations,
15
17
  recoveryBoundaryForHistory,
16
18
  recoverJournal,
17
19
  visibleExecutableCallSeqs,
18
20
  } from './session-journal-recovery.js'
21
+ import { sessionEvents } from './session-events.js'
22
+ import { REPL_TOOL_NAMES } from './session-journal-schema.js'
19
23
  import { valueLimitsFromConfig } from './value-wire-schema.js'
20
24
  import { encodeValueAtBoundary, valueWiresEqual } from './value-wire.js'
21
25
  import { normalizeBindingDescriptors } from './binding-descriptors.js'
@@ -24,6 +28,7 @@ import { WorkerClient } from './worker-client.js'
24
28
  import { compilerWorkerCache } from './compiler-service.js'
25
29
  import { BindingCatalog, durabilityState, transitionDurability } from './session-state.js'
26
30
  import { SessionCellExecutor } from './session-cell-executor.js'
31
+ import { CellAdmissionBlocked } from './cell-error.js'
27
32
  import { executionPolicies } from './binding-update-policy.js'
28
33
  import {
29
34
  createReplMemorySnapshot,
@@ -35,6 +40,22 @@ import {
35
40
  const WORKER_URL = new URL('./kernel-worker.js', import.meta.url)
36
41
  const UNAVAILABLE_SURFACE_GENERATION = Symbol('unavailableSurfaceGeneration')
37
42
 
43
+ /**
44
+ * Tell "the surface proves this call is gone" apart from "the surface cannot be
45
+ * read right now".
46
+ *
47
+ * A queued message, a mid-flight surface replacement or any transient host
48
+ * state can leave `session.surface.nodes` unreadable for one request. Reading
49
+ * that as an empty visible set made every live binding look contracted, so the
50
+ * worker was reset and the journal replayed; a replay that cannot reproduce an
51
+ * old completion then contracted the whole suffix and the user lost the REPL.
52
+ * Only a readable surface may decide that a live binding lost its provenance.
53
+ */
54
+ function surfaceReadable(session) {
55
+ if (!Array.isArray(session?.surface?.nodes)) return false
56
+ return Array.isArray(session?.events)
57
+ }
58
+
38
59
  function surfaceGeneration(session, provenanceRequired = true) {
39
60
  try {
40
61
  const surface = session?.surface
@@ -48,12 +69,186 @@ function surfaceGeneration(session, provenanceRequired = true) {
48
69
  }
49
70
  }
50
71
 
51
- function recoveryDiagnostic(count) {
72
+ /**
73
+ * Per-call source digest of the visible surface.
74
+ *
75
+ * A surface republish appends or re-renders; a history change removes a call or
76
+ * rewrites its source. Distinguishing the two needs the call set plus each
77
+ * call's own bytes, not the whole message tree: digests stay cheap, and an
78
+ * unchanged call keeps its digest across a re-render.
79
+ */
80
+ /**
81
+ * Reduce a visible call set to the seqs both sides of the recovery comparison can
82
+ * name: `run_code` and `edit_run_code` call records.
83
+ *
84
+ * `visibleExecutableCallSeqs` also carries the record a derived execution starts
85
+ * from, which can be a `tool/result` or a nested `tool/ptc-dispatch` rather than a
86
+ * `tool/call`. The kernel never tracks those as live calls, so keeping them in the
87
+ * comparison made a vanished non-call look like a rewritten history and reset the
88
+ * worker for nothing.
89
+ *
90
+ * @param {readonly {seq: number, type: string, data?: object}[]} events
91
+ * @param {Set<number>} visibleSeqs
92
+ * @returns {{seqs: Set<number>, sources: Map<number, string>}}
93
+ */
94
+ export function comparableVisibleCalls(events, visibleSeqs) {
95
+ const calls = new Set()
96
+ const sources = new Map()
97
+ for (const event of events ?? []) {
98
+ if (!visibleSeqs.has(event?.seq) || event?.type !== 'tool/call') continue
99
+ if (typeof event.data?.name === 'string' && !REPL_TOOL_NAMES.has(event.data.name)) continue
100
+ calls.add(event.seq)
101
+ const source = typeof event.data?.arguments === 'string' ? event.data.arguments : ''
102
+ sources.set(event.seq, createHash('sha256').update(source).digest('hex'))
103
+ }
104
+ return { seqs: calls, sources }
105
+ }
106
+
107
+
108
+
109
+ /**
110
+ * Wording of the contraction notice.
111
+ *
112
+ * A count of cells tells the model nothing it can act on; the binding names are
113
+ * what it must stop referencing. When no journal names them, the notice
114
+ * describes the contraction without pretending to know which bindings went away.
115
+ */
116
+ export function recoveryDiagnosticMessage(bindings, count) {
117
+ const names = [...new Set(bindings ?? [])].filter(name => typeof name === 'string' && name !== '')
118
+ if (names.length === 0) {
119
+ return `Restored the durable head and skipped ${count ?? 1} unreconstructable historical cell(s); their source remains in the session log.`
120
+ }
121
+ return `Restored the durable head and dropped ${names.length} binding(s) (${names.join(', ')}) that no recorded value proves; their source remains in the session log.`
122
+ }
123
+
124
+ /**
125
+ * Notice for a replay whose completions diverged.
126
+ *
127
+ * The bindings exist because the replay produced them; what cannot be certified
128
+ * is the recorded completion payload. Saying so keeps the model from trusting a
129
+ * recorded result while leaving the live bindings in place.
130
+ */
131
+ /**
132
+ * Notice for cells restored from their recorded values.
133
+ *
134
+ * A volatile cell cannot be replayed: running it again would redispatch the
135
+ * ambient source that made it volatile. Recovery installs the values it
136
+ * recorded instead, so the bindings are live while the recorded completions
137
+ * were never re-derived.
138
+ */
139
+ export function restoredFromRecordDiagnostic(count, live, failed, continuation = false) {
140
+ // The cell count and the binding names describe different things: one cell can
141
+ // install several bindings, and an install can fail. Saying "restored N cells
142
+ // (a, b)" invites the model to read the names as the count and to trust a name
143
+ // that never made it into the worker, so the two facts stay separate and a
144
+ // failed name is never called live.
145
+ const livePart = live.length === 0
146
+ ? 'no binding was live'
147
+ : `${live.length} binding(s) live: ${live.join(', ')}`
148
+ const failedPart = failed.length === 0
149
+ ? ''
150
+ : `; ${failed.length} could not be installed and are NOT live: ${failed.join(', ')}`
151
+ return diagnostic({
152
+ code: 'PTC-R004',
153
+ severity: 'warning',
154
+ phase: 'recover',
155
+ message: `Restored ${continuation ? 'another ' : ''}${count} cell(s) from recorded values; ${livePart}${failedPart}. The recorded completions were not replayed.`,
156
+ stateEffect: 'unchanged',
157
+ help: [
158
+ 'continue from the bindings listed as live',
159
+ 'do not assume the recorded cells ran again; external effects they performed were not repeated',
160
+ ],
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Notice for a binding that was announced as live and then stopped existing.
166
+ *
167
+ * Reporting it when it happens keeps the model from reading a later
168
+ * `x is not defined` as its own mistake: the binding went away outside the
169
+ * cell that names it.
170
+ */
171
+ export function vanishedBindingDiagnostic(names) {
172
+ const list = names.join(', ')
173
+ return diagnostic({
174
+ code: 'PTC-R006',
175
+ severity: 'warning',
176
+ phase: 'recover',
177
+ message: `${names.length} binding(s) reported live earlier are no longer available: ${list}. The REPL state lost them without a cell asking for it.`,
178
+ stateEffect: 'unchanged',
179
+ help: [`redeclare ${list} before using them again`],
180
+ })
181
+ }
182
+
183
+ /**
184
+ * Notice for bindings a recorded revision superseded.
185
+ *
186
+ * A revision that rewrites its target puts the target off the recorded path, so
187
+ * the target's declarations stop building the environment. They are not missing:
188
+ * the log says which revision replaced them, and reporting that relation keeps
189
+ * the model from reading a later `x is not defined` as its own mistake.
190
+ */
191
+ export function supersededDiagnostic(bindings, supersessions = []) {
192
+ const names = bindings.length === 0 ? '' : ` (${bindings.join(', ')})`
193
+ const relations = supersessions.map(item =>
194
+ `cell ${item.supersededCallSeq} by cell ${item.supersedingCallSeq}`).join('; ')
195
+ return diagnostic({
196
+ code: 'PTC-R007',
197
+ severity: 'warning',
198
+ phase: 'recover',
199
+ message: (bindings.length === 0
200
+ ? 'A recorded revision replaced the cell it edited'
201
+ : `Superseded ${bindings.length} binding(s)${names} because a recorded revision replaced the cell that declared them`)
202
+ + `${relations === '' ? '' : ` (${relations})`}. The replaced cell stays in the session log but no longer builds this environment.`,
203
+ stateEffect: 'rolled-back',
204
+ help: [
205
+ 'this is not a recovery failure: an accepted edit rewrote the cell that declared these bindings, and the revision does not declare them',
206
+ 'redeclare a superseded name in a new cell before using it again',
207
+ ],
208
+ })
209
+ }
210
+
211
+ function unverifiedCompletionDiagnostic(count) {
212
+ return diagnostic({
213
+ code: 'PTC-R003',
214
+ severity: 'warning',
215
+ phase: 'recover',
216
+ message: `Replayed ${count} historical cell(s) whose completion differs from the record; their bindings are live and their recorded results stay unverified.`,
217
+ stateEffect: 'unchanged',
218
+ help: [
219
+ 'continue from the restored bindings',
220
+ 'do not rely on the recorded result values of the cells named here',
221
+ ],
222
+ })
223
+ }
224
+
225
+ /**
226
+ * Notice for bindings a recorded rewind ended.
227
+ *
228
+ * They are not missing: the record says they are gone, because the cell that
229
+ * restored an earlier durable state abandoned them. Reporting them apart from a
230
+ * genuine loss keeps the two costs distinguishable.
231
+ */
232
+ export function rewindEndedDiagnostic(bindings) {
233
+ const names = bindings.length === 0 ? '' : ` (${bindings.join(', ')})`
234
+ return diagnostic({
235
+ code: 'PTC-R005',
236
+ severity: 'warning',
237
+ phase: 'recover',
238
+ message: `Dropped ${bindings.length} binding(s)${names} because a recorded rewind ended them.`,
239
+ stateEffect: 'rolled-back',
240
+ help: [
241
+ 'this is not a recovery failure: the cell that restored an earlier durable state ended these bindings on purpose',
242
+ ],
243
+ })
244
+ }
245
+
246
+ function recoveryDiagnostic(bindings, count) {
52
247
  return diagnostic({
53
248
  code: 'PTC-R002',
54
249
  severity: 'warning',
55
250
  phase: 'recover',
56
- message: `Restored the durable head and skipped ${count} unreconstructable historical cell(s); their source remains in the session log.`,
251
+ message: recoveryDiagnosticMessage(bindings, count),
57
252
  stateEffect: 'rolled-back',
58
253
  help: [
59
254
  'continue from the restored bindings',
@@ -62,6 +257,25 @@ function recoveryDiagnostic(count) {
62
257
  })
63
258
  }
64
259
 
260
+ /** Every binding name the folded history no longer proves. */
261
+ function droppedBindingNames(history) {
262
+ const names = []
263
+ for (const item of history?.volatileSuffix ?? []) {
264
+ for (const name of item.bindings ?? []) names.push(name)
265
+ }
266
+ return names
267
+ }
268
+
269
+ /**
270
+ * A replayed cell whose completion no longer matches its record.
271
+ *
272
+ * The bindings the replay produced are real; only the recorded completion
273
+ * payload cannot be certified. Contracting here used to drop every later
274
+ * binding the log still proves, so a divergence is recorded and the replay
275
+ * continues instead.
276
+ */
277
+ class ReplayDivergence extends Error {}
278
+
65
279
  function resolvedRuntimeConfig(config) {
66
280
  return Object.freeze(resolveConfig(config))
67
281
  }
@@ -70,6 +284,21 @@ function emptyHistory() {
70
284
  return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], available: true }
71
285
  }
72
286
 
287
+ /**
288
+ * How many historical REPL cells a disposed identity leaves behind.
289
+ *
290
+ * A disposed owner's environment is not resumed, so the count only sizes the
291
+ * one notice that tells the model how much history is gone. Prefer the proven
292
+ * executable calls and fall back to one so the notice is never silent.
293
+ */
294
+ function disposedHistoryCells(sessionLog, callSeq) {
295
+ const visible = visibleExecutableCallSeqs(sessionLog)
296
+ if (!(visible instanceof Set)) return 1
297
+ // The submitted cell already appears in the log; it is not history.
298
+ const historical = [...visible].filter(seq => seq !== callSeq)
299
+ return historical.length > 0 ? historical.length : 1
300
+ }
301
+
73
302
  class ReplayFailure extends Error {
74
303
  constructor(node, cause) {
75
304
  super(messageOf(cause))
@@ -77,6 +306,18 @@ class ReplayFailure extends Error {
77
306
  }
78
307
  }
79
308
 
309
+ /**
310
+ * Fallback for `unmeasuredCellLimit`: consecutive cells that may end before
311
+ * they reach the runtime while the live worker is kept.
312
+ *
313
+ * One such cell is not evidence about the worker: the host may simply have been
314
+ * unable to observe it. The configured limit (default two) ends the sequence, so
315
+ * a worker that never becomes dispatchable cannot stay in charge of every later
316
+ * cell; reaching it drops the live worker and lets the next cell replay the
317
+ * proved frontier.
318
+ */
319
+ const UNMEASURED_CELL_LIMIT = 2
320
+
80
321
  class ReplayCancelled extends Error {
81
322
  constructor(result) {
82
323
  super(result.error?.message ?? 'session replay cancelled')
@@ -84,8 +325,21 @@ class ReplayCancelled extends Error {
84
325
  }
85
326
  }
86
327
 
328
+ /**
329
+ * A recorded cell whose execution could not be measured.
330
+ *
331
+ * The cell never ran, so the recorded history remains exactly as proved and the
332
+ * replay may be attempted again through a fresh worker.
333
+ */
334
+ class ReplayUnmeasured extends Error {
335
+ constructor(result) {
336
+ super(result.error?.message ?? 'session replay could not be measured')
337
+ this.result = result
338
+ }
339
+ }
340
+
87
341
  class SessionKernel {
88
- constructor({ config, history, cwd, session, surfaceProvenanceRequired, userBindingsCwd, withInitiator, observeValues }) {
342
+ constructor({ config, history, cwd, session, surfaceProvenanceRequired, userBindingsCwd, withInitiator, observeValues, disposedCells = 0 }) {
89
343
  this.config = config
90
344
  this.history = history
91
345
  this.initialRecoveryBoundary = recoveryBoundaryForHistory(history)
@@ -99,11 +353,27 @@ class SessionKernel {
99
353
  this.durability = durabilityState()
100
354
  this.bindingCatalog = new BindingCatalog()
101
355
  this.replayed = false
356
+ this.unmeasuredCells = 0
102
357
  this.liveCallSeqs = new Set()
103
- this.recoveryNotice = history.volatileSuffix.length === 0
104
- ? undefined
105
- : recoveryDiagnostic(history.volatileSuffix.length)
106
- this.recoveryNoticeShown = false
358
+ this.unverifiedCompletions = []
359
+ this.restoredFromRecord = []
360
+ this.recoveryNotices = disposedCells > 0
361
+ // A kernel whose owning agent or session was disposed must not resurrect
362
+ // the disposed environment: the history is gone by contract, and a later
363
+ // cell starts from an empty REPL with the loss reported once.
364
+ ? [recoveryDiagnostic([], disposedCells)]
365
+ : history.volatileSuffix.length === 0
366
+ ? []
367
+ : [recoveryDiagnostic(droppedBindingNames(history), history.volatileSuffix.length)]
368
+ // Bindings a recovery told the model were live, and the last help pair that
369
+ // explained a recovery. Both exist to keep one event from being reported
370
+ // twice and to notice a binding that stops existing afterwards.
371
+ this.announcedLive = new Set()
372
+ // A recorded supersession is a durable fact; announcing it once per runtime
373
+ // keeps later folds from repeating a relation the model already read.
374
+ this.announcedSupersessions = new Set()
375
+ this.recoveryHelpSignature = undefined
376
+ this.recoveryEventCount = 0
107
377
  this.active = undefined
108
378
  this.sequence = 0
109
379
  this.tail = Promise.resolve()
@@ -167,10 +437,29 @@ class SessionKernel {
167
437
  return attempt
168
438
  }
169
439
 
440
+ /**
441
+ * Start the session worker with the bounds the kernel holds now.
442
+ *
443
+ * The worker serves later cells as well as the one that starts it, so its
444
+ * close rule and startup bound follow the kernel's current configuration even
445
+ * when the starting cell was admitted before a settings change. Calibrating
446
+ * the transport after ensure covers a worker that already existed.
447
+ */
448
+ async ensureWorker(maxOldGenerationSizeMb) {
449
+ // A live worker already took its bounds at creation and reconfigure keeps it
450
+ // current. The client reads this projection only when it actually starts a
451
+ // worker, after its scratch wait, so a settings change in that window still
452
+ // reaches the startup and close bounds.
453
+ return this.client.ensure(maxOldGenerationSizeMb, () => ({
454
+ reclaimBoundMs: this.config.workerReclaimMs,
455
+ startupProbeMs: this.config.startupProbeMs,
456
+ }))
457
+ }
458
+
170
459
  async prepareWorker() {
171
460
  await Promise.all([...this.reclamationAttempts])
172
461
  const failures = [...this.reclamationRecords.values()].filter(record => record.failed).map(record => record.reason)
173
- if (failures.length) throw new AggregateError(failures, 'previous computation helper reclamation failed; dispose the session before continuing')
462
+ if (failures.length) throw new CellAdmissionBlocked(failures)
174
463
  }
175
464
 
176
465
  valueLimits(config = this.config) {
@@ -189,6 +478,13 @@ class SessionKernel {
189
478
  reconfigure(config) {
190
479
  this.assertReconfigurationAllowed(config)
191
480
  this.config = config
481
+ // The close path reads the bound the transport carries, and a live worker can
482
+ // be dropped outside resetWorker (idle disposal, owner auto-close), so the
483
+ // current configuration has to reach the transport as soon as it is applied.
484
+ const worker = this.client.worker
485
+ if (worker !== undefined && Number.isSafeInteger(config.workerReclaimMs)) {
486
+ worker.reclaimBoundMs = config.workerReclaimMs
487
+ }
192
488
  }
193
489
 
194
490
  reserveWorkerConfiguration(config) {
@@ -273,54 +569,141 @@ class SessionKernel {
273
569
  if (this.surfaceProvenanceRequired
274
570
  && (currentGeneration !== this.surfaceGeneration || hasLiveState)) {
275
571
  this.surfaceGeneration = currentGeneration
276
- const visibleCallSeqs = visibleExecutableCallSeqs(observedSession)
277
- const contractsLiveState = [...this.liveCallSeqs].some(callSeq => (
278
- !(visibleCallSeqs instanceof Set) || !visibleCallSeqs.has(callSeq)
279
- ))
280
- if ((config.durableReplay && !this.replayed) || contractsLiveState) {
572
+ // An unreadable surface is undecidable, not proof of contraction: keep the
573
+ // worker and its bindings instead of dropping them on a transient read.
574
+ const surfaceIsReadable = surfaceReadable(observedSession)
575
+ const visibleCallSeqs = surfaceIsReadable ? visibleExecutableCallSeqs(observedSession) : undefined
576
+ // The surface is a view, not the authority over a live worker. A re-render,
577
+ // a windowed republish, a working-directory change or an edit the runtime
578
+ // appended never costs the user bindings the worker still holds: this block
579
+ // rebuilds only when the worker is gone, the kernel was disposed, an explicit
580
+ // rewind or head cut was applied, or the first replay after a restart runs.
581
+ const workerAlive = this.client.worker !== undefined
582
+ const initialReplay = config.durableReplay && !this.replayed
583
+ if (!workerAlive || initialReplay) {
584
+
281
585
  const worker = this.client.worker
282
- if (worker !== undefined) await this.client.reset(worker)
586
+ // A refused stop stays recorded here, so the later admission check in
587
+ // prepareWorker() blocks computation beside an unreclaimed helper.
588
+ if (worker !== undefined) await this.resetWorker(worker)
283
589
  this.rollbackToDurable()
284
590
  if (config.durableReplay) {
285
- this.recoveryNotice = undefined
591
+ this.recoveryNotices = []
286
592
  try {
287
593
  this.history = recoverJournal(observedSession, request.callSeq, {
288
594
  visibleCallSeqs,
595
+ editRewritesHistory: config.editRewritesHistory === true,
289
596
  })
290
597
  } catch (error) {
598
+ /* c8 ignore start -- the fold reports damaged history as data, so no supported session reaches this catch */
291
599
  const boundary = recoveryBoundaryForHistory(this.history, undefined, { reset: true })
292
600
  if (boundary !== undefined) recoveryBoundaries.push(boundary)
293
601
  this.history = emptyHistory()
294
- this.recoveryNotice = recoveryDiagnostic(1)
602
+ this.recoveryNotices = [recoveryDiagnostic([], 1)]
295
603
  this.initialRecoveryBoundary = undefined
604
+ /* c8 ignore stop */
296
605
  }
606
+ /* c8 ignore start -- volatile suffix on the first fold; no case settles a volatile suffix before the first replay */
297
607
  if (this.history.volatileSuffix.length > 0) {
298
- this.recoveryNotice = recoveryDiagnostic(this.history.volatileSuffix.length)
608
+ this.recoveryNotices = [recoveryDiagnostic(
609
+ droppedBindingNames(this.history), this.history.volatileSuffix.length)]
299
610
  }
611
+ /* c8 ignore stop */
300
612
  this.initialRecoveryBoundary = recoveryBoundaryForHistory(this.history)
613
+ /* c8 ignore start -- first-fold recovery boundary; no case makes the first replay record one */
301
614
  if (this.initialRecoveryBoundary !== undefined) {
302
615
  recoveryBoundaries.push(this.initialRecoveryBoundary)
303
616
  this.initialRecoveryBoundary = undefined
617
+ /* c8 ignore stop */
304
618
  }
305
619
  } else {
306
620
  this.history = emptyHistory()
307
621
  this.replayed = true
308
- this.recoveryNotice = recoveryDiagnostic(1)
622
+ this.recoveryNotices = [recoveryDiagnostic([], 1)]
309
623
  }
310
624
  }
311
625
  }
312
626
  if (!this.replayed && config.durableReplay) {
313
627
  let skipped = 0
628
+ let recoveryAttempted = false
629
+ // A replay pass can end before the loop settles either because it was
630
+ // cancelled or because a recorded cell could not be measured; a front it
631
+ // already contracted still has to reach the model in those cases.
632
+ const leavingReplay = result => {
633
+ if (skipped === 0) return result
634
+ const notice = recoveryDiagnostic([], skipped)
635
+ if (request.journal !== undefined) request.journal.diagnostics.push(notice)
636
+ return { ...result, logs: [...result.logs, renderDiagnostic(notice, request.program)] }
637
+ }
314
638
  while (!this.replayed) {
315
639
  try {
316
640
  await this.replayHistory(request, config)
317
641
  this.replayed = true
318
642
  } catch (error) {
643
+ // Reclamation goes through the kernel's owner so a refused stop stays
644
+ // recorded: prepareWorker() then blocks later computation until that
645
+ // failure is handled, instead of admitting it beside a live helper.
646
+ const retainedReasons = () => [...this.reclamationRecords.values()]
647
+ .filter(record => record.failed)
648
+ .map(record => record.reason)
319
649
  const worker = this.client.worker
320
- if (worker !== undefined) await this.client.reset(worker)
650
+ // A refusal may predate this catch: the abandoned cell that ended the
651
+ // previous attempt already reset its worker, so no worker is reachable
652
+ // here while the failure stays recorded. A retained refusal keeps the
653
+ // helper until disposal, so a second attempt would record the same
654
+ // refusal again; the guidance below follows the retained outcome, which
655
+ // is also what the admission gate enforces.
656
+ if (worker !== undefined && retainedReasons().length === 0) await this.resetWorker(worker)
657
+ const reasons = retainedReasons()
658
+ const unreclaimed = reasons.length === 0
659
+ ? ''
660
+ : `; helper reclamation failed: ${String(reasons[0]?.message ?? reasons[0])}`
661
+ if (error instanceof CellAdmissionBlocked) {
662
+ // The replay could not start because an earlier helper was not
663
+ // reclaimed. That says nothing about the recorded cells, so the
664
+ // frontier keeps what this pass proved, including a contraction it
665
+ // already performed, whose notice therefore still has to be reported.
666
+ const blocked = { logs: [], error: { kind: 'worker-exit', message: error.message } }
667
+ this.completeJournal(request.journal, 'discarded', blocked)
668
+ return finishResult(leavingReplay(blocked))
669
+ }
321
670
  if (error instanceof ReplayCancelled) {
322
671
  this.completeJournal(request.journal, 'noop', error.result)
323
- return finishResult(error.result)
672
+ return finishResult(leavingReplay(error.result))
673
+ }
674
+ // A historical cell that could not be measured never ran, so it proves
675
+ // nothing against the recorded frontier. The reset above left an empty
676
+ // worker, so the replay is retried once; a second unmeasured replay
677
+ // through a fresh worker still says nothing about the recorded history,
678
+ // which therefore stays intact for the next cell to replay again.
679
+ if (error instanceof ReplayUnmeasured) {
680
+ if (!recoveryAttempted && unreclaimed === '') {
681
+ recoveryAttempted = true
682
+ this.durability = durabilityState()
683
+ continue
684
+ }
685
+ this.rollbackToDurable()
686
+ const blocked = unreclaimed !== ''
687
+ // The sampled cell's own message tells the model to retry that cell and
688
+ // may already carry the refusal. Neither holds here: the admission gate
689
+ // will refuse the retry, and one refusal must not be reported twice.
690
+ const facts = blocked ? messageOf(error).replace('; retry the cell', '') : messageOf(error)
691
+ const repeated = blocked && facts.includes(unreclaimed)
692
+ const unmeasured = {
693
+ logs: error.result.logs,
694
+ error: {
695
+ kind: 'budget-unavailable',
696
+ // A refusal leaves later computation blocked, so the result must
697
+ // not promise a replay that the admission gate will refuse.
698
+ message: `${facts}; ${skipped === 0
699
+ ? 'the recorded frontier is unchanged'
700
+ : 'the recorded frontier contracted as the recovery notice reports'}, ${blocked
701
+ ? 'later computation stays blocked until the session is disposed'
702
+ : 'the kernel worker was reset, and the next cell replays the remaining frontier'}${repeated ? '' : unreclaimed}`,
703
+ },
704
+ }
705
+ this.completeJournal(request.journal, 'noop', unmeasured)
706
+ return finishResult(leavingReplay(unmeasured))
324
707
  }
325
708
  try {
326
709
  if (!(error instanceof ReplayFailure)) throw error
@@ -329,6 +712,7 @@ class SessionKernel {
329
712
  const recovered = recoverJournal(observedSession, request.callSeq, {
330
713
  extraBoundaries: [boundary],
331
714
  visibleCallSeqs: visibleExecutableCallSeqs(observedSession),
715
+ editRewritesHistory: config.editRewritesHistory === true,
332
716
  })
333
717
  const nextPathLength = pathToHead(recovered).length
334
718
  if (nextPathLength >= previousPathLength) throw new Error('recovery did not contract the historical frontier')
@@ -347,23 +731,145 @@ class SessionKernel {
347
731
  this.durability = durabilityState()
348
732
  }
349
733
  }
350
- if (skipped > 0) this.recoveryNotice = recoveryDiagnostic(skipped)
734
+ // One recovery event produces one report. The facts below describe the same
735
+ // event, so each one that holds is collected instead of overwriting the ones
736
+ // already found: an earlier overwrite dropped the restored-name list whenever
737
+ // a later fact arrived, and a relation dropped that way was then cleared
738
+ // without ever reaching the model. The facts never name the same binding, so
739
+ // collecting them states the event once per fact rather than twice per name.
740
+ const restoredCount = this.restoredFromRecord.length
741
+ const allLive = [...new Set(this.restoredFromRecord.flatMap(item => item.bindings))]
742
+ // Report only what this recovery added. A later recovery that installs
743
+ // nothing new (same bindings, same count) would otherwise repeat a sentence
744
+ // the model already acted on.
745
+ const liveNames = allLive.filter(name => !this.announcedLive.has(name))
746
+ // The executor reports `{ name, reason }` for a refused install; a notice that
747
+ // printed the entry itself used to render `[object Object]` where the binding
748
+ // name belongs.
749
+ const failedNames = [...new Set(this.restoredFromRecord
750
+ .flatMap(item => item.failed.map(entry => typeof entry === 'string' ? entry : entry?.name))
751
+ .filter(name => typeof name === 'string'))]
752
+ const droppedNames = this.missingBindings === undefined ? [] : [...new Set(this.missingBindings)]
753
+ const endedNames = this.rewindEndedBindings === undefined ? [] : [...new Set(this.rewindEndedBindings)]
754
+ const supersededNames = this.supersededEndedBindings === undefined
755
+ ? [] : [...new Set(this.supersededEndedBindings)]
756
+ const alreadyReported = this.recoveryEventCount > 0
757
+ // A complete restore is invisible to the model. Every recorded binding was
758
+ // installed, nothing was dropped or ended, and the next cell behaves exactly
759
+ // as it would have without the recovery, so a notice would only report plugin
760
+ // mechanics: in the audited session one such line cost two reasoning blocks of
761
+ // source-code investigation and changed no decision. The names stay tracked so
762
+ // that a later disappearance can still be named; only the announcement goes.
763
+ const completeRestore = failedNames.length === 0
764
+ && droppedNames.length === 0
765
+ && endedNames.length === 0
766
+ && supersededNames.length === 0
767
+ if (restoredCount > 0 && completeRestore) {
768
+ this.restoredFromRecord = []
769
+ /* c8 ignore start -- needs a rewind that ended an announced name after a reported recovery; no case does */
770
+ } else if (restoredCount > 0 && liveNames.length === 0 && failedNames.length === 0 && alreadyReported) {
771
+ this.restoredFromRecord = []
772
+ /* c8 ignore stop */
773
+ } else if (restoredCount > 0) {
774
+ this.recoveryEventCount += 1
775
+ this.recoveryNotices.push(restoredFromRecordDiagnostic(
776
+ restoredCount,
777
+ liveNames,
778
+ failedNames,
779
+ alreadyReported,
780
+ ))
781
+ }
782
+ /* c8 ignore start -- needs a proven name the replay never visits; no case records one */
783
+ if (droppedNames.length > 0) {
784
+ this.recoveryNotices.push(recoveryDiagnostic(droppedNames, droppedNames.length))
785
+ /* c8 ignore stop */
786
+ } else if (restoredCount === 0 && skipped > 0) {
787
+ this.recoveryNotices.push(recoveryDiagnostic([], skipped))
788
+ }
789
+ // A rewind that ended a binding is recorded, not a loss: report it only when
790
+ // the replay installed nothing under that name anywhere (path, off-path or
791
+ // catalog), so a correct value never carries a "dropped" notice. A revision
792
+ // that superseded a cell is the other recorded ending: the environment it
793
+ // built differs from the cells the log still lists, so the relation is
794
+ // reported once per runtime instead of on every later fold.
795
+ const freshSupersessions = (this.supersessions ?? [])
796
+ .filter(item => !this.announcedSupersessions.has(item.supersedingCallSeq))
797
+ // A restored cell and a superseded one are two facts about the same recovery,
798
+ // not alternatives: the names this relation withdrew are excluded from the
799
+ // missing and rewind lists, so a report that chose the restore notice over it
800
+ // left a withdrawn name reported nowhere.
801
+ if (freshSupersessions.length > 0 || supersededNames.length > 0) {
802
+ this.recoveryNotices.push(supersededDiagnostic(supersededNames, freshSupersessions))
803
+ for (const item of freshSupersessions) this.announcedSupersessions.add(item.supersedingCallSeq)
804
+ }
805
+ if (endedNames.length > 0) {
806
+ this.recoveryNotices.push(rewindEndedDiagnostic(endedNames))
807
+ }
808
+ // A completion the replay could not certify is not a lost binding: the
809
+ // replay established them, and only the recorded payload stays unproved.
810
+ if (this.unverifiedCompletions.length > 0) {
811
+ this.recoveryNotices.push(unverifiedCompletionDiagnostic(this.unverifiedCompletions.length))
812
+ }
813
+ if (this.recoveryNotices.length > 0) {
814
+ // Remember what was promised as live, so a binding that disappears later
815
+ // is reported by name instead of surfacing as an unexplained ReferenceError.
816
+ for (const name of liveNames) this.announcedLive.add(name)
817
+ // The help pair explains the recovery once. Repeating it on every later
818
+ // recovery in the same session is noise the model cannot act on twice.
819
+ const signature = this.recoveryNotices.map(item => item.message).join('\n')
820
+ if (this.recoveryHelpSignature === signature) {
821
+ this.recoveryNotices = this.recoveryNotices.map(item => ({ ...item, help: [] }))
822
+ } else {
823
+ this.recoveryHelpSignature = signature
824
+ }
825
+ }
826
+ this.restoredFromRecord = []
827
+ this.missingBindings = undefined
828
+ this.rewindEndedBindings = undefined
829
+ this.supersededEndedBindings = undefined
830
+ this.supersessions = undefined
351
831
  }
352
- const leadingDiagnostics = this.recoveryNotice === undefined || this.recoveryNoticeShown
353
- ? [] : [this.recoveryNotice]
354
- this.recoveryNoticeShown = this.recoveryNotice !== undefined
355
- this.recoveryNotice = undefined
832
+ // Every recovery reports the facts it established. Suppressing a later recovery's
833
+ // notice hid a relation the runtime had already marked as announced, so a name the
834
+ // revision withdrew was reported nowhere; what is deduplicated is the explanation
835
+ // (below) and each name or relation this runtime already stated.
836
+ const leadingDiagnostics = this.recoveryNotices
837
+ this.recoveryNotices = []
356
838
  if (request.journal !== undefined) request.journal.diagnostics.push(...leadingDiagnostics)
357
- const result = await this.cellExecutor.executeCell(request, undefined, config)
839
+ let result
840
+ try {
841
+ result = await this.cellExecutor.executeCell(request, undefined, config)
842
+ } catch (error) {
843
+ // The kernel refused to admit this cell because an earlier helper was not
844
+ // reclaimed. Nothing ran and nothing about the recorded history changed, so
845
+ // the cell settles on its own terms instead of blaming the source; the
846
+ // recovery notice prepared above is still attached below.
847
+ if (!(error instanceof CellAdmissionBlocked)) throw error
848
+ result = { logs: [], error: { kind: 'worker-exit', message: error.message } }
849
+ this.completeJournal(request.journal, 'discarded', result)
850
+ }
358
851
  if (result.error === undefined) {
359
852
  this.failures.reset()
360
853
  } else {
361
854
  const primary = request.journal?.diagnostics.find(item => item.severity === 'error')
362
- const hint = this.failures.hint(result.error, primary?.stateEffect)
855
+ // A no-op journal is this cell's own record that nothing it could have done
856
+ // is in the REPL state, so a repeated-failure hint must not describe that
857
+ // state as unknown.
858
+ const stateEffect = primary?.stateEffect
859
+ ?? (request.journal?.status === 'noop' ? 'unchanged' : undefined)
860
+ const hint = this.failures.hint(result.error, stateEffect)
363
861
  if (hint !== undefined) {
364
862
  result.logs = [...result.logs, renderDiagnostic(hint, request.program)]
365
863
  if (request.journal !== undefined) request.journal.diagnostics.push(hint)
366
864
  }
865
+ // A binding announced as live by a recovery, now reported missing by the
866
+ // engine itself, went away outside this cell. Naming it here keeps the
867
+ // model from reading its own correct reference as a mistake it made.
868
+ const vanished = this.vanishedAnnouncedBindings(result.error, request.program)
869
+ if (vanished.length > 0) {
870
+ result.logs = [...result.logs, renderDiagnostic(vanished[0], request.program)]
871
+ if (request.journal !== undefined) request.journal.diagnostics.push(...vanished)
872
+ }
367
873
  }
368
874
  if (leadingDiagnostics.length > 0) {
369
875
  const rendered = leadingDiagnostics.map(item => renderDiagnostic(item, request.program))
@@ -373,15 +879,68 @@ class SessionKernel {
373
879
  return finishResult(result)
374
880
  }
375
881
 
882
+ /**
883
+ * Bindings a recovery promised as live that the engine now reports missing.
884
+ *
885
+ * Each name is reported once: the set is drained when it is named, so a later
886
+ * cell repeating the same reference does not repeat the notice.
887
+ */
888
+ vanishedAnnouncedBindings(error, program) {
889
+ if (this.announcedLive.size === 0) return []
890
+ const message = String(error?.message ?? '')
891
+ const match = /(?:ReferenceError:\s*)?([A-Za-z_$][\w$]*) is not defined/.exec(message)
892
+ if (match === null) return []
893
+ const name = match[1]
894
+ if (!this.announcedLive.has(name)) return []
895
+ this.announcedLive.delete(name)
896
+ void program
897
+ return [vanishedBindingDiagnostic([name])]
898
+ }
899
+
376
900
  async replayHistory(request, config) {
377
901
  this.bindingCatalog = new BindingCatalog()
378
902
  const limits = this.valueLimits(config)
379
903
  const emptyArgsWire = encodeValueAtBoundary({}, limits, 'parameter')
380
904
  const path = pathToHead(this.history)
905
+ // Every binding the session log proves has to come back or be named. A node
906
+ // that is not on the path to the head never replays, so its bindings would
907
+ // otherwise disappear without a diagnostic.
908
+ const replayedNames = new Set()
909
+ for (const node of path) {
910
+ for (const value of node.journal.values ?? []) replayedNames.add(value.name)
911
+ for (const name of bindingNamesOfOperations(node.journal.operations)) replayedNames.add(name)
912
+ }
913
+ // A recorded rewind ended these bindings on purpose: they are gone by record,
914
+ // not by omission, so they are reported apart from the ones recovery failed to
915
+ // install. Nothing else may report them as missing.
916
+ const endedBindings = new Set(this.history.deletedBindings ?? [])
381
917
  for (const node of path) {
382
918
  const bindings = replayBindingNamespaces(request.bindings, node.journal, limits, emptyArgsWire)
383
919
  const bindingDescriptors = bindings === request.bindings
384
920
  ? request.bindingDescriptors : normalizeBindingDescriptors(bindings)
921
+ // A cell the journal recorded as volatile keeps the values it produced.
922
+ // Installing them is the only replay that does not redispatch the ambient
923
+ // source that made the cell volatile, so its completion stays unverified
924
+ // while its bindings stay live.
925
+ if (node.restoredFromRecord === true) {
926
+ const restored = await this.cellExecutor.executeCell(
927
+ {
928
+ ...request,
929
+ bindings: bindingDescriptors.namespaces,
930
+ bindingDescriptors,
931
+ program: node.code,
932
+ journal: undefined,
933
+ restoreValues: node.journal.values,
934
+ ...(node.userBindings === undefined ? { userBindings: undefined } : { userBindings: node.userBindings }),
935
+ },
936
+ node.journal,
937
+ config,
938
+ )
939
+ if (restored.error !== undefined) throw new ReplayFailure(node, new Error(restored.error.message))
940
+ this.restoredFromRecord.push({ callSeq: node.callSeq,
941
+ bindings: restored.restored ?? [], failed: restored.failed ?? [] })
942
+ continue
943
+ }
385
944
  try {
386
945
  const result = await this.cellExecutor.executeCell(
387
946
  {
@@ -398,26 +957,58 @@ class SessionKernel {
398
957
  const completion = node.journal.completion
399
958
  if (result.error !== undefined && !['exception', 'invalid-output'].includes(result.error.kind)) {
400
959
  if (result.error.kind === 'abort') throw new ReplayCancelled(result)
960
+ // An unmeasured cell never ran: it disproves nothing about the recorded
961
+ // frontier, and the replay can be attempted again through a fresh worker.
962
+ if (result.error.kind === 'budget-unavailable') throw new ReplayUnmeasured(result)
401
963
  throw new Error(`cell replay infrastructure failed (${result.error.kind}): ${result.error.message}`)
402
964
  }
403
965
  if (completion.kind === 'return' && result.error !== undefined) {
404
- throw new Error(`cell replay failed: ${result.error.message}`)
966
+ throw new ReplayDivergence(`recorded cell returned a value and the replay failed: ${result.error.message}`)
405
967
  }
406
968
  if (completion.kind === 'throw') {
407
969
  if (result.error === undefined) throw new Error('cell replay succeeded where the recorded cell failed')
408
970
  if (result.error.kind !== completion.error.kind || result.error.message !== completion.error.message) {
409
- throw new Error('cell replay produced a different semantic failure')
971
+ throw new ReplayDivergence('cell replay produced a different semantic failure')
410
972
  }
411
973
  }
412
974
  } catch (error) {
413
- if (error instanceof ReplayCancelled) throw error
975
+ // A blocked admission is not a fact about this recorded cell, so it is
976
+ // rethrown unchanged and never wrapped as a replay failure.
977
+ if (error instanceof ReplayCancelled || error instanceof ReplayUnmeasured
978
+ || error instanceof CellAdmissionBlocked) throw error
979
+ // The replay produced real bindings and a different completion. Contracting
980
+ // here dropped every later binding the log still proves, which is how a
981
+ // diverging completion cost the user the whole REPL; certify the divergence
982
+ // and keep replaying from the bindings the replay established.
983
+ if (error instanceof ReplayDivergence) {
984
+ this.unverifiedCompletions.push({ callSeq: node.callSeq, reason: error.message })
985
+ continue
986
+ }
414
987
  throw new ReplayFailure(node, error)
415
988
  }
416
989
  }
417
990
  for (const node of path) this.liveCallSeqs.add(node.callSeq)
991
+ // Names the replay installed include the ones the binding catalog supplies: a
992
+ // binding can come back through the catalog without any cell executing, so the
993
+ // arithmetic below counts that evidence too. Without it a rewound branch whose
994
+ // binding returned through the catalog was reported as ended while its value was
995
+ // correct.
996
+ const installed = new Set(replayedNames)
997
+ for (const name of this.bindingCatalog.inputs().knownBindings) installed.add(name)
998
+ // A superseded name is ended by the record that replaced its cell, so it is
999
+ // reported apart from a rewind and never as a binding recovery failed to
1000
+ // install: the log says which revision replaced it.
1001
+ const supersededEnded = new Set((this.history.supersededBindings ?? [])
1002
+ .filter(name => !installed.has(name)))
1003
+ this.rewindEndedBindings = [...endedBindings]
1004
+ .filter(name => !installed.has(name) && !supersededEnded.has(name))
1005
+ this.supersededEndedBindings = [...supersededEnded]
1006
+ this.supersessions = this.history.supersessions ?? []
1007
+ this.missingBindings = (this.history.provenBindings ?? [])
1008
+ .filter(name => !installed.has(name) && !endedBindings.has(name) && !supersededEnded.has(name))
418
1009
  }
419
1010
 
420
- completeJournal(journal, status, result, volatileReason, diagnostics = [], completion = undefined) {
1011
+ completeJournal(journal, status, result, volatileReason, diagnostics = [], completion = undefined, values = undefined) {
421
1012
  if (journal === undefined) return
422
1013
  journal.status = status
423
1014
  journal.completion = result.error === undefined
@@ -428,6 +1019,13 @@ class SessionKernel {
428
1019
  }
429
1020
  : { kind: 'throw', error: { kind: result.error.kind, message: result.error.message } }
430
1021
  if (volatileReason !== undefined) journal.volatileReason = volatileReason
1022
+ // A volatile cell keeps the values it produced so recovery can install them
1023
+ // without running the cell again; a value the codec could not transfer is
1024
+ // named instead of being omitted silently.
1025
+ if (values !== undefined && (values.values.length > 0 || values.unrecorded.length > 0)) {
1026
+ journal.values = values.values
1027
+ if (values.unrecorded.length > 0) journal.unrecordedBindings = values.unrecorded
1028
+ }
431
1029
  if (diagnostics.length > 0) journal.diagnostics.push(...diagnostics)
432
1030
  if (status === 'volatile') {
433
1031
  journal.operations = journal.operations.filter(operation => operation.action !== 'save')
@@ -443,14 +1041,72 @@ class SessionKernel {
443
1041
  this.workerObservation = undefined
444
1042
  this.durability = durabilityState()
445
1043
  this.replayed = false
1044
+ // Every path that drops the live worker replaces it, so the next cell starts
1045
+ // its own sequence of unmeasured cells on a worker that has not failed yet.
1046
+ this.unmeasuredCells = 0
446
1047
  this.bindingCatalog = new BindingCatalog()
447
1048
  this.liveCallSeqs.clear()
448
1049
  }
449
1050
 
1051
+ /**
1052
+ * Settle a cell that never reached the runtime.
1053
+ *
1054
+ * A cell that was never dispatched has nothing to discard, no binding to roll
1055
+ * back and no reason to rebuild the worker, so its journal records a no-op
1056
+ * outcome and the live worker keeps the state earlier cells proved. Consecutive
1057
+ * such cells are the evidence that this worker does not become dispatchable:
1058
+ * the live worker is then dropped while the proved frontier stays, and the next
1059
+ * cell replays it. Budgets and the cancellation listener end with the cell
1060
+ * exactly as they do on any other settlement.
1061
+ */
1062
+ abandonCell(active, result) {
1063
+ if (this.active !== active) return
1064
+ active.settled = true
1065
+ clearInterval(active.computeTimer)
1066
+ clearTimeout(active.wallTimer)
1067
+ active.request.signal?.removeEventListener('abort', active.onAbort)
1068
+ this.active = undefined
1069
+ this.completeJournal(active.journal, 'noop', result)
1070
+ this.unmeasuredCells += 1
1071
+ // The cell was submitted under its own configuration snapshot, and that
1072
+ // snapshot decides its settlement even if the setting changed while it ran.
1073
+ // `admission.config` is the runtime configuration object itself (`beginSubmission`
1074
+ // stores `this.config` by reference), so a second lookup on `this.config` could only
1075
+ // repeat the first answer. The shipped constant is the real fallback.
1076
+ const limit = Number.isSafeInteger(active.config?.unmeasuredCellLimit)
1077
+ ? active.config.unmeasuredCellLimit
1078
+ : UNMEASURED_CELL_LIMIT
1079
+ if (this.unmeasuredCells < limit) {
1080
+ active.finish(result)
1081
+ return
1082
+ }
1083
+ this.rollbackToDurable()
1084
+ // The model has to learn that the state this worker held is gone; only the
1085
+ // proved frontier survives for the next cell to replay.
1086
+ result = { ...result, error: { ...result.error,
1087
+ message: `${result.error?.message ?? 'cell could not be measured'}; this worker was dropped after consecutive unmeasured cells, so bindings outside the proved recovery frontier are no longer available` } }
1088
+ this.completeJournal(active.journal, 'noop', result)
1089
+ // A refused reclamation stays recorded on this kernel instead of becoming an
1090
+ // unhandled rejection; the kernel keeps the worker until dispose.
1091
+ void this.resetWorker(active.worker).then(record => {
1092
+ if (record.failed) {
1093
+ result = { ...result, error: { ...result.error,
1094
+ message: `${result.error?.message ?? 'cell could not be measured'}; helper reclamation failed: ${String(record.reason?.message ?? record.reason)}` } }
1095
+ if (active.journal !== undefined && active.replay === undefined) {
1096
+ this.completeJournal(active.journal, 'noop', result)
1097
+ }
1098
+ }
1099
+ active.finish(result)
1100
+ })
1101
+ }
1102
+
450
1103
  settleCell(active, result, terminate = false) {
451
1104
  /* c8 ignore next */
452
1105
  if (this.active !== active) return
453
1106
  active.settled = true
1107
+ // Any cell that settles on a normal path disproves the sequence of cells that
1108
+ // never reached the runtime, whichever way it ended.
1109
+ this.unmeasuredCells = 0
454
1110
  const { request, journal, replay, worker } = active
455
1111
  clearInterval(active.computeTimer)
456
1112
  clearTimeout(active.wallTimer)
@@ -477,6 +1133,7 @@ class SessionKernel {
477
1133
  const status = active.durability.status
478
1134
  this.completeJournal(
479
1135
  journal, status, result, active.durability.reason, active.diagnostics, active.completion,
1136
+ active.bindingValues,
480
1137
  )
481
1138
  this.tentatives.set(journal, {
482
1139
  callSeq: request.callSeq,
@@ -495,7 +1152,8 @@ class SessionKernel {
495
1152
  // A live cell establishes this worker's timeline even when historical
496
1153
  // replay was disabled. Only a reset may reopen its recovery boundary.
497
1154
  this.replayed = true
498
- this.liveCallSeqs.add(request.callSeq ?? request.sourceCallSeq)
1155
+ const ownSeq = request.callSeq ?? request.sourceCallSeq
1156
+ this.liveCallSeqs.add(ownSeq)
499
1157
  }
500
1158
  this.active = undefined
501
1159
  if (terminate) {
@@ -671,7 +1329,7 @@ function sessionOf(sessionContext) {
671
1329
  * and contract the durable frontier. Zero-argument calls keep their recorded
672
1330
  * empty-object form so the args wire still matches the journal.
673
1331
  */
674
- function replayBindingNamespaces(namespaces, journal, limits, emptyArgsWire) {
1332
+ export function replayBindingNamespaces(namespaces, journal, limits, emptyArgsWire) {
675
1333
  const calls = journal?.calls ?? []
676
1334
  let changed = false
677
1335
  const next = namespaces.map(namespace => {
@@ -702,6 +1360,15 @@ export class SessionRuntime {
702
1360
  constructor(config = {}, options = {}) {
703
1361
  this.config = resolvedRuntimeConfig(config)
704
1362
  this.kernels = new Map()
1363
+ // Identities whose owning session was disposed. Their environment is gone by
1364
+ // contract, so a later cell in the same identity must start empty instead of
1365
+ // replaying bindings the disposed owner once held.
1366
+ this.disposedSessions = new Set()
1367
+ // Identities whose Agent was disposed. An Agent owns the live environment,
1368
+ // not the session's durable journal: releasing it ends the worker and every
1369
+ // live-only (volatile) value it observed, while the durable computation the
1370
+ // log proves still replays for the identity that continues the session.
1371
+ this.releasedAgents = new Set()
705
1372
  this.pendingNoops = new Map()
706
1373
  this.settlements = new WeakSet()
707
1374
  this.preparingCells = new Set()
@@ -770,7 +1437,8 @@ export class SessionRuntime {
770
1437
  kernel.surfaceGeneration = surfaceGeneration(session)
771
1438
  } else {
772
1439
  const timeline = foldSessionTimeline(observed.events)
773
- const history = recoverJournal(observed, undefined, { visibleCallSeqs: visible })
1440
+ const history = recoverJournal(observed, undefined, { visibleCallSeqs: visible,
1441
+ editRewritesHistory: this.config.editRewritesHistory === true })
774
1442
  const head = history.nodes[history.head]
775
1443
  if (head === undefined) return unavailableReplMemorySnapshot()
776
1444
  // A cold inventory can describe a proved recorded frontier without
@@ -850,21 +1518,40 @@ export class SessionRuntime {
850
1518
  }
851
1519
  let kernel = this.kernels.get(sessionId)
852
1520
  if (kernel === undefined) {
1521
+ // A disposed session loses its environment by contract: the next cell in
1522
+ // that identity starts empty and is told how many historical cells the
1523
+ // disposed environment held. Replaying them would hand the disposed
1524
+ // owner's bindings to whoever runs next in the same identity.
1525
+ const disposedCells = this.disposedSessions.has(sessionId)
1526
+ ? disposedHistoryCells(sessionLog, callSeq)
1527
+ : 0
1528
+ // A released Agent owns only the live environment, so the durable journal
1529
+ // still replays; the live-only values that environment observed do not.
1530
+ const liveOnlyLost = this.releasedAgents.has(sessionId)
853
1531
  let history
854
1532
  try {
855
- history = cellConfig.durableReplay
856
- ? recoverJournal(sessionLog, callSeq, {
1533
+ history = disposedCells > 0 || !cellConfig.durableReplay
1534
+ ? emptyHistory()
1535
+ : recoverJournal(sessionLog, callSeq, {
857
1536
  visibleCallSeqs: visibleExecutableCallSeqs(sessionLog),
1537
+ editRewritesHistory: cellConfig.editRewritesHistory === true,
1538
+ ...(liveOnlyLost ? { liveOnlyLost: true } : {}),
858
1539
  })
859
- : emptyHistory()
860
1540
  } catch (error) {
861
1541
  // Malformed PTC metadata is contracted by the ordered recovery owner.
862
1542
  // Failure to read DSH's session source cannot prove a reset boundary.
863
1543
  return completed({ logs: [], error: { kind: 'recovery', message: `cannot reconstruct REPL from session log: ${messageOf(error)}` } })
864
1544
  }
1545
+ // One release is one fact about the environment that held it: the
1546
+ // environment built below owns its own live values again.
1547
+ if (liveOnlyLost) this.releasedAgents.delete(sessionId)
865
1548
  kernel = new SessionKernel({
866
- config: cellConfig,
1549
+ // The kernel owns the worker and the sessions it serves, so it takes the
1550
+ // configuration current when the session starts; this cell's own budgets
1551
+ // still come from the submitted snapshot passed to run().
1552
+ config: this.config,
867
1553
  history,
1554
+ disposedCells,
868
1555
  cwd,
869
1556
  session,
870
1557
  surfaceProvenanceRequired,
@@ -938,6 +1625,27 @@ export class SessionRuntime {
938
1625
 
939
1626
  async disposeSession(sessionId) {
940
1627
  const id = String(sessionId)
1628
+ // Recorded before the early return: an identity whose owning session was
1629
+ // disposed has no environment to resume, whether or not a kernel still
1630
+ // existed here.
1631
+ this.disposedSessions.add(id)
1632
+ return this.#releaseKernel(id)
1633
+ }
1634
+
1635
+ /**
1636
+ * End the live environment of one Agent while keeping the session's journal.
1637
+ *
1638
+ * The Agent, not the session, owns the worker: a replaced Agent in the same
1639
+ * session continues the computation the log proves, and loses only the
1640
+ * live-only values its predecessor observed.
1641
+ */
1642
+ async disposeAgent(sessionId) {
1643
+ const id = String(sessionId)
1644
+ this.releasedAgents.add(id)
1645
+ return this.#releaseKernel(id)
1646
+ }
1647
+
1648
+ async #releaseKernel(id) {
941
1649
  for (const admission of this.preparingCells) {
942
1650
  if (admission.sessionId === id) {
943
1651
  admission.cancelled = true