dsh-ptc-plus 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/client.cjs +1467 -84
  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/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  8. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  9. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  10. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  11. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  12. package/docs/architecture.md +2 -2
  13. package/docs/client-ui.md +5 -2
  14. package/docs/durability-design.md +7 -1
  15. package/docs/releases/v0.5.2.md +79 -0
  16. package/docs/runtime-reference.md +36 -2
  17. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  18. package/docs/verification.md +6 -2
  19. package/index.js +36 -15
  20. package/internal/bounded-literal.js +85 -0
  21. package/internal/capability-environment.js +162 -10
  22. package/internal/capability-plan.js +11 -8
  23. package/internal/cell-error.js +13 -0
  24. package/internal/config-spec.js +113 -2
  25. package/internal/direct-surface-owner.js +14 -10
  26. package/internal/edit-transport-owner.js +79 -14
  27. package/internal/isolated-worker.js +134 -17
  28. package/internal/kernel-worker.js +167 -3
  29. package/internal/legacy-cell-analysis.js +14 -2
  30. package/internal/legacy-repl-convenience.js +16 -3
  31. package/internal/projection-retention.js +238 -0
  32. package/internal/prompt-text.js +858 -0
  33. package/internal/recovery-tips.js +50 -11
  34. package/internal/rejected-cell-editor.js +3 -2
  35. package/internal/runtime-bridge-owner.js +1 -1
  36. package/internal/runtime-config.js +2 -0
  37. package/internal/runtime-contexts.js +14 -5
  38. package/internal/runtime-messages.js +43 -15
  39. package/internal/session-cell-executor.js +132 -11
  40. package/internal/session-journal-recovery.js +1009 -99
  41. package/internal/session-journal-schema.js +7 -5
  42. package/internal/session-journal.js +177 -14
  43. package/internal/session-log-view.js +111 -24
  44. package/internal/session-runtime.js +674 -33
  45. package/internal/session-state.js +27 -0
  46. package/internal/stateful-root-compiler.js +11 -0
  47. package/internal/stateful-root-runtime.js +17 -0
  48. package/internal/user-binding-console.js +7 -0
  49. package/internal/user-bindings-owner.js +3 -0
  50. package/internal/user-bindings.js +2 -2
  51. package/internal/validated-parse-repair.js +6 -1
  52. package/internal/worker-client.js +27 -4
  53. package/package.json +3 -1
  54. package/src/client-activity.js +24 -4
  55. package/src/client-capabilities-view.js +459 -24
  56. package/src/client-copy.js +163 -2
  57. package/src/client-repl-view.js +6 -1
  58. package/src/client-styles.js +64 -2
@@ -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,158 @@ 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
+ function unverifiedCompletionDiagnostic(count) {
184
+ return diagnostic({
185
+ code: 'PTC-R003',
186
+ severity: 'warning',
187
+ phase: 'recover',
188
+ message: `Replayed ${count} historical cell(s) whose completion differs from the record; their bindings are live and their recorded results stay unverified.`,
189
+ stateEffect: 'unchanged',
190
+ help: [
191
+ 'continue from the restored bindings',
192
+ 'do not rely on the recorded result values of the cells named here',
193
+ ],
194
+ })
195
+ }
196
+
197
+ /**
198
+ * Notice for bindings a recorded rewind ended.
199
+ *
200
+ * They are not missing: the record says they are gone, because the cell that
201
+ * restored an earlier durable state abandoned them. Reporting them apart from a
202
+ * genuine loss keeps the two costs distinguishable.
203
+ */
204
+ export function rewindEndedDiagnostic(bindings) {
205
+ const names = bindings.length === 0 ? '' : ` (${bindings.join(', ')})`
206
+ return diagnostic({
207
+ code: 'PTC-R005',
208
+ severity: 'warning',
209
+ phase: 'recover',
210
+ message: `Dropped ${bindings.length} binding(s)${names} because a recorded rewind ended them.`,
211
+ stateEffect: 'rolled-back',
212
+ help: [
213
+ 'this is not a recovery failure: the cell that restored an earlier durable state ended these bindings on purpose',
214
+ ],
215
+ })
216
+ }
217
+
218
+ function recoveryDiagnostic(bindings, count) {
52
219
  return diagnostic({
53
220
  code: 'PTC-R002',
54
221
  severity: 'warning',
55
222
  phase: 'recover',
56
- message: `Restored the durable head and skipped ${count} unreconstructable historical cell(s); their source remains in the session log.`,
223
+ message: recoveryDiagnosticMessage(bindings, count),
57
224
  stateEffect: 'rolled-back',
58
225
  help: [
59
226
  'continue from the restored bindings',
@@ -62,6 +229,25 @@ function recoveryDiagnostic(count) {
62
229
  })
63
230
  }
64
231
 
232
+ /** Every binding name the folded history no longer proves. */
233
+ function droppedBindingNames(history) {
234
+ const names = []
235
+ for (const item of history?.volatileSuffix ?? []) {
236
+ for (const name of item.bindings ?? []) names.push(name)
237
+ }
238
+ return names
239
+ }
240
+
241
+ /**
242
+ * A replayed cell whose completion no longer matches its record.
243
+ *
244
+ * The bindings the replay produced are real; only the recorded completion
245
+ * payload cannot be certified. Contracting here used to drop every later
246
+ * binding the log still proves, so a divergence is recorded and the replay
247
+ * continues instead.
248
+ */
249
+ class ReplayDivergence extends Error {}
250
+
65
251
  function resolvedRuntimeConfig(config) {
66
252
  return Object.freeze(resolveConfig(config))
67
253
  }
@@ -70,6 +256,21 @@ function emptyHistory() {
70
256
  return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], available: true }
71
257
  }
72
258
 
259
+ /**
260
+ * How many historical REPL cells a disposed identity leaves behind.
261
+ *
262
+ * A disposed owner's environment is not resumed, so the count only sizes the
263
+ * one notice that tells the model how much history is gone. Prefer the proven
264
+ * executable calls and fall back to one so the notice is never silent.
265
+ */
266
+ function disposedHistoryCells(sessionLog, callSeq) {
267
+ const visible = visibleExecutableCallSeqs(sessionLog)
268
+ if (!(visible instanceof Set)) return 1
269
+ // The submitted cell already appears in the log; it is not history.
270
+ const historical = [...visible].filter(seq => seq !== callSeq)
271
+ return historical.length > 0 ? historical.length : 1
272
+ }
273
+
73
274
  class ReplayFailure extends Error {
74
275
  constructor(node, cause) {
75
276
  super(messageOf(cause))
@@ -77,6 +278,18 @@ class ReplayFailure extends Error {
77
278
  }
78
279
  }
79
280
 
281
+ /**
282
+ * Fallback for `unmeasuredCellLimit`: consecutive cells that may end before
283
+ * they reach the runtime while the live worker is kept.
284
+ *
285
+ * One such cell is not evidence about the worker: the host may simply have been
286
+ * unable to observe it. The configured limit (default two) ends the sequence, so
287
+ * a worker that never becomes dispatchable cannot stay in charge of every later
288
+ * cell; reaching it drops the live worker and lets the next cell replay the
289
+ * proved frontier.
290
+ */
291
+ const UNMEASURED_CELL_LIMIT = 2
292
+
80
293
  class ReplayCancelled extends Error {
81
294
  constructor(result) {
82
295
  super(result.error?.message ?? 'session replay cancelled')
@@ -84,8 +297,21 @@ class ReplayCancelled extends Error {
84
297
  }
85
298
  }
86
299
 
300
+ /**
301
+ * A recorded cell whose execution could not be measured.
302
+ *
303
+ * The cell never ran, so the recorded history remains exactly as proved and the
304
+ * replay may be attempted again through a fresh worker.
305
+ */
306
+ class ReplayUnmeasured extends Error {
307
+ constructor(result) {
308
+ super(result.error?.message ?? 'session replay could not be measured')
309
+ this.result = result
310
+ }
311
+ }
312
+
87
313
  class SessionKernel {
88
- constructor({ config, history, cwd, session, surfaceProvenanceRequired, userBindingsCwd, withInitiator, observeValues }) {
314
+ constructor({ config, history, cwd, session, surfaceProvenanceRequired, userBindingsCwd, withInitiator, observeValues, disposedCells = 0 }) {
89
315
  this.config = config
90
316
  this.history = history
91
317
  this.initialRecoveryBoundary = recoveryBoundaryForHistory(history)
@@ -99,11 +325,25 @@ class SessionKernel {
99
325
  this.durability = durabilityState()
100
326
  this.bindingCatalog = new BindingCatalog()
101
327
  this.replayed = false
328
+ this.unmeasuredCells = 0
102
329
  this.liveCallSeqs = new Set()
103
- this.recoveryNotice = history.volatileSuffix.length === 0
104
- ? undefined
105
- : recoveryDiagnostic(history.volatileSuffix.length)
330
+ this.unverifiedCompletions = []
331
+ this.restoredFromRecord = []
332
+ this.recoveryNotice = disposedCells > 0
333
+ // A kernel whose owning agent or session was disposed must not resurrect
334
+ // the disposed environment: the history is gone by contract, and a later
335
+ // cell starts from an empty REPL with the loss reported once.
336
+ ? recoveryDiagnostic([], disposedCells)
337
+ : history.volatileSuffix.length === 0
338
+ ? undefined
339
+ : recoveryDiagnostic(droppedBindingNames(history), history.volatileSuffix.length)
106
340
  this.recoveryNoticeShown = false
341
+ // Bindings a recovery told the model were live, and the last help pair that
342
+ // explained a recovery. Both exist to keep one event from being reported
343
+ // twice and to notice a binding that stops existing afterwards.
344
+ this.announcedLive = new Set()
345
+ this.recoveryHelpSignature = undefined
346
+ this.recoveryEventCount = 0
107
347
  this.active = undefined
108
348
  this.sequence = 0
109
349
  this.tail = Promise.resolve()
@@ -167,10 +407,29 @@ class SessionKernel {
167
407
  return attempt
168
408
  }
169
409
 
410
+ /**
411
+ * Start the session worker with the bounds the kernel holds now.
412
+ *
413
+ * The worker serves later cells as well as the one that starts it, so its
414
+ * close rule and startup bound follow the kernel's current configuration even
415
+ * when the starting cell was admitted before a settings change. Calibrating
416
+ * the transport after ensure covers a worker that already existed.
417
+ */
418
+ async ensureWorker(maxOldGenerationSizeMb) {
419
+ // A live worker already took its bounds at creation and reconfigure keeps it
420
+ // current. The client reads this projection only when it actually starts a
421
+ // worker, after its scratch wait, so a settings change in that window still
422
+ // reaches the startup and close bounds.
423
+ return this.client.ensure(maxOldGenerationSizeMb, () => ({
424
+ reclaimBoundMs: this.config.workerReclaimMs,
425
+ startupProbeMs: this.config.startupProbeMs,
426
+ }))
427
+ }
428
+
170
429
  async prepareWorker() {
171
430
  await Promise.all([...this.reclamationAttempts])
172
431
  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')
432
+ if (failures.length) throw new CellAdmissionBlocked(failures)
174
433
  }
175
434
 
176
435
  valueLimits(config = this.config) {
@@ -189,6 +448,13 @@ class SessionKernel {
189
448
  reconfigure(config) {
190
449
  this.assertReconfigurationAllowed(config)
191
450
  this.config = config
451
+ // The close path reads the bound the transport carries, and a live worker can
452
+ // be dropped outside resetWorker (idle disposal, owner auto-close), so the
453
+ // current configuration has to reach the transport as soon as it is applied.
454
+ const worker = this.client.worker
455
+ if (worker !== undefined && Number.isSafeInteger(config.workerReclaimMs)) {
456
+ worker.reclaimBoundMs = config.workerReclaimMs
457
+ }
192
458
  }
193
459
 
194
460
  reserveWorkerConfiguration(config) {
@@ -273,13 +539,23 @@ class SessionKernel {
273
539
  if (this.surfaceProvenanceRequired
274
540
  && (currentGeneration !== this.surfaceGeneration || hasLiveState)) {
275
541
  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) {
542
+ // An unreadable surface is undecidable, not proof of contraction: keep the
543
+ // worker and its bindings instead of dropping them on a transient read.
544
+ const surfaceIsReadable = surfaceReadable(observedSession)
545
+ const visibleCallSeqs = surfaceIsReadable ? visibleExecutableCallSeqs(observedSession) : undefined
546
+ // The surface is a view, not the authority over a live worker. A re-render,
547
+ // a windowed republish, a working-directory change or an edit the runtime
548
+ // appended never costs the user bindings the worker still holds: this block
549
+ // rebuilds only when the worker is gone, the kernel was disposed, an explicit
550
+ // rewind or head cut was applied, or the first replay after a restart runs.
551
+ const workerAlive = this.client.worker !== undefined
552
+ const initialReplay = config.durableReplay && !this.replayed
553
+ if (!workerAlive || initialReplay) {
554
+
281
555
  const worker = this.client.worker
282
- if (worker !== undefined) await this.client.reset(worker)
556
+ // A refused stop stays recorded here, so the later admission check in
557
+ // prepareWorker() blocks computation beside an unreclaimed helper.
558
+ if (worker !== undefined) await this.resetWorker(worker)
283
559
  this.rollbackToDurable()
284
560
  if (config.durableReplay) {
285
561
  this.recoveryNotice = undefined
@@ -288,39 +564,114 @@ class SessionKernel {
288
564
  visibleCallSeqs,
289
565
  })
290
566
  } catch (error) {
567
+ /* c8 ignore start -- a fold that throws is not reachable from any supported session; cover or prove it in 0.5.3 */
291
568
  const boundary = recoveryBoundaryForHistory(this.history, undefined, { reset: true })
292
569
  if (boundary !== undefined) recoveryBoundaries.push(boundary)
293
570
  this.history = emptyHistory()
294
- this.recoveryNotice = recoveryDiagnostic(1)
571
+ this.recoveryNotice = recoveryDiagnostic([], 1)
295
572
  this.initialRecoveryBoundary = undefined
573
+ /* c8 ignore stop */
296
574
  }
575
+ /* c8 ignore start -- volatile suffix on the first fold; cover in 0.5.3 */
297
576
  if (this.history.volatileSuffix.length > 0) {
298
- this.recoveryNotice = recoveryDiagnostic(this.history.volatileSuffix.length)
577
+ this.recoveryNotice = recoveryDiagnostic(droppedBindingNames(this.history), this.history.volatileSuffix.length)
299
578
  }
579
+ /* c8 ignore stop */
300
580
  this.initialRecoveryBoundary = recoveryBoundaryForHistory(this.history)
581
+ /* c8 ignore start -- first-fold recovery boundary; cover in 0.5.3 */
301
582
  if (this.initialRecoveryBoundary !== undefined) {
302
583
  recoveryBoundaries.push(this.initialRecoveryBoundary)
303
584
  this.initialRecoveryBoundary = undefined
585
+ /* c8 ignore stop */
304
586
  }
305
587
  } else {
306
588
  this.history = emptyHistory()
307
589
  this.replayed = true
308
- this.recoveryNotice = recoveryDiagnostic(1)
590
+ this.recoveryNotice = recoveryDiagnostic([], 1)
309
591
  }
310
592
  }
311
593
  }
312
594
  if (!this.replayed && config.durableReplay) {
313
595
  let skipped = 0
596
+ let recoveryAttempted = false
597
+ // A replay pass can end before the loop settles either because it was
598
+ // cancelled or because a recorded cell could not be measured; a front it
599
+ // already contracted still has to reach the model in those cases.
600
+ const leavingReplay = result => {
601
+ if (skipped === 0) return result
602
+ const notice = recoveryDiagnostic([], skipped)
603
+ if (request.journal !== undefined) request.journal.diagnostics.push(notice)
604
+ return { ...result, logs: [...result.logs, renderDiagnostic(notice, request.program)] }
605
+ }
314
606
  while (!this.replayed) {
315
607
  try {
316
608
  await this.replayHistory(request, config)
317
609
  this.replayed = true
318
610
  } catch (error) {
611
+ // Reclamation goes through the kernel's owner so a refused stop stays
612
+ // recorded: prepareWorker() then blocks later computation until that
613
+ // failure is handled, instead of admitting it beside a live helper.
614
+ const retainedReasons = () => [...this.reclamationRecords.values()]
615
+ .filter(record => record.failed)
616
+ .map(record => record.reason)
319
617
  const worker = this.client.worker
320
- if (worker !== undefined) await this.client.reset(worker)
618
+ // A refusal may predate this catch: the abandoned cell that ended the
619
+ // previous attempt already reset its worker, so no worker is reachable
620
+ // here while the failure stays recorded. A retained refusal keeps the
621
+ // helper until disposal, so a second attempt would record the same
622
+ // refusal again; the guidance below follows the retained outcome, which
623
+ // is also what the admission gate enforces.
624
+ if (worker !== undefined && retainedReasons().length === 0) await this.resetWorker(worker)
625
+ const reasons = retainedReasons()
626
+ const unreclaimed = reasons.length === 0
627
+ ? ''
628
+ : `; helper reclamation failed: ${String(reasons[0]?.message ?? reasons[0])}`
629
+ if (error instanceof CellAdmissionBlocked) {
630
+ // The replay could not start because an earlier helper was not
631
+ // reclaimed. That says nothing about the recorded cells, so the
632
+ // frontier keeps what this pass proved, including a contraction it
633
+ // already performed, whose notice therefore still has to be reported.
634
+ const blocked = { logs: [], error: { kind: 'worker-exit', message: error.message } }
635
+ this.completeJournal(request.journal, 'discarded', blocked)
636
+ return finishResult(leavingReplay(blocked))
637
+ }
321
638
  if (error instanceof ReplayCancelled) {
322
639
  this.completeJournal(request.journal, 'noop', error.result)
323
- return finishResult(error.result)
640
+ return finishResult(leavingReplay(error.result))
641
+ }
642
+ // A historical cell that could not be measured never ran, so it proves
643
+ // nothing against the recorded frontier. The reset above left an empty
644
+ // worker, so the replay is retried once; a second unmeasured replay
645
+ // through a fresh worker still says nothing about the recorded history,
646
+ // which therefore stays intact for the next cell to replay again.
647
+ if (error instanceof ReplayUnmeasured) {
648
+ if (!recoveryAttempted && unreclaimed === '') {
649
+ recoveryAttempted = true
650
+ this.durability = durabilityState()
651
+ continue
652
+ }
653
+ this.rollbackToDurable()
654
+ const blocked = unreclaimed !== ''
655
+ // The sampled cell's own message tells the model to retry that cell and
656
+ // may already carry the refusal. Neither holds here: the admission gate
657
+ // will refuse the retry, and one refusal must not be reported twice.
658
+ const facts = blocked ? messageOf(error).replace('; retry the cell', '') : messageOf(error)
659
+ const repeated = blocked && facts.includes(unreclaimed)
660
+ const unmeasured = {
661
+ logs: error.result.logs,
662
+ error: {
663
+ kind: 'budget-unavailable',
664
+ // A refusal leaves later computation blocked, so the result must
665
+ // not promise a replay that the admission gate will refuse.
666
+ message: `${facts}; ${skipped === 0
667
+ ? 'the recorded frontier is unchanged'
668
+ : 'the recorded frontier contracted as the recovery notice reports'}, ${blocked
669
+ ? 'later computation stays blocked until the session is disposed'
670
+ : 'the kernel worker was reset, and the next cell replays the remaining frontier'}${repeated ? '' : unreclaimed}`,
671
+ },
672
+ }
673
+ this.completeJournal(request.journal, 'noop', unmeasured)
674
+ return finishResult(leavingReplay(unmeasured))
324
675
  }
325
676
  try {
326
677
  if (!(error instanceof ReplayFailure)) throw error
@@ -347,23 +698,121 @@ class SessionKernel {
347
698
  this.durability = durabilityState()
348
699
  }
349
700
  }
350
- if (skipped > 0) this.recoveryNotice = recoveryDiagnostic(skipped)
701
+ // One recovery event produces one notice. The four facts below describe the
702
+ // same event, so they are composed once instead of overwriting each other:
703
+ // an earlier overwrite dropped the restored-name list whenever a later fact
704
+ // arrived, and a repeat entry re-emitted the same sentence.
705
+ const restoredCount = this.restoredFromRecord.length
706
+ const allLive = [...new Set(this.restoredFromRecord.flatMap(item => item.bindings))]
707
+ // Report only what this recovery added. A later recovery that installs
708
+ // nothing new (same bindings, same count) would otherwise repeat a sentence
709
+ // the model already acted on.
710
+ const liveNames = allLive.filter(name => !this.announcedLive.has(name))
711
+ // The executor reports `{ name, reason }` for a refused install; a notice that
712
+ // printed the entry itself used to render `[object Object]` where the binding
713
+ // name belongs.
714
+ const failedNames = [...new Set(this.restoredFromRecord
715
+ .flatMap(item => item.failed.map(entry => typeof entry === 'string' ? entry : entry?.name))
716
+ .filter(name => typeof name === 'string'))]
717
+ const droppedNames = this.missingBindings === undefined ? [] : [...new Set(this.missingBindings)]
718
+ const endedNames = this.rewindEndedBindings === undefined ? [] : [...new Set(this.rewindEndedBindings)]
719
+ const alreadyReported = this.recoveryEventCount > 0
720
+ // A complete restore is invisible to the model. Every recorded binding was
721
+ // installed, nothing was dropped or ended, and the next cell behaves exactly
722
+ // as it would have without the recovery, so a notice would only report plugin
723
+ // mechanics: in the audited session one such line cost two reasoning blocks of
724
+ // source-code investigation and changed no decision. The names stay tracked so
725
+ // that a later disappearance can still be named; only the announcement goes.
726
+ const completeRestore = failedNames.length === 0
727
+ && droppedNames.length === 0
728
+ && endedNames.length === 0
729
+ if (restoredCount > 0 && completeRestore) {
730
+ this.restoredFromRecord = []
731
+ /* c8 ignore start -- needs a rewind that ended an announced name; cover in 0.5.3 */
732
+ } else if (restoredCount > 0 && liveNames.length === 0 && failedNames.length === 0 && alreadyReported) {
733
+ this.restoredFromRecord = []
734
+ /* c8 ignore stop */
735
+ } else if (restoredCount > 0) {
736
+ this.recoveryEventCount += 1
737
+ this.recoveryNotice = restoredFromRecordDiagnostic(
738
+ restoredCount,
739
+ liveNames,
740
+ failedNames,
741
+ alreadyReported,
742
+ )
743
+ }
744
+ /* c8 ignore start -- needs a proven name the replay never visits; cover in 0.5.3 */
745
+ if (droppedNames.length > 0) {
746
+ this.recoveryNotice = recoveryDiagnostic(droppedNames, droppedNames.length)
747
+ /* c8 ignore stop */
748
+ } else if (restoredCount === 0 && skipped > 0) {
749
+ this.recoveryNotice = recoveryDiagnostic([], skipped)
750
+ }
751
+ // A rewind that ended a binding is recorded, not a loss: report it only when
752
+ // the replay installed nothing under that name anywhere (path, off-path or
753
+ // catalog), so a correct value never carries a "dropped" notice.
754
+ if (this.recoveryNotice === undefined && endedNames.length > 0) {
755
+ this.recoveryNotice = rewindEndedDiagnostic(endedNames)
756
+ }
757
+ if (this.recoveryNotice === undefined && this.unverifiedCompletions.length > 0) {
758
+ this.recoveryNotice = unverifiedCompletionDiagnostic(this.unverifiedCompletions.length)
759
+ }
760
+ // A completion the replay could not certify is not a lost binding: the
761
+ // replay established them, and only the recorded payload stays unproved.
762
+ if (this.recoveryNotice !== undefined) {
763
+ // Remember what was promised as live, so a binding that disappears later
764
+ // is reported by name instead of surfacing as an unexplained ReferenceError.
765
+ for (const name of liveNames) this.announcedLive.add(name)
766
+ // The help pair explains the recovery once. Repeating it on every later
767
+ // recovery in the same session is noise the model cannot act on twice.
768
+ const signature = this.recoveryNotice.message
769
+ if (this.recoveryHelpSignature === signature) this.recoveryNotice = { ...this.recoveryNotice, help: [] }
770
+ else this.recoveryHelpSignature = signature
771
+ }
772
+ this.restoredFromRecord = []
773
+ this.missingBindings = undefined
774
+ this.rewindEndedBindings = undefined
351
775
  }
352
- const leadingDiagnostics = this.recoveryNotice === undefined || this.recoveryNoticeShown
353
- ? [] : [this.recoveryNotice]
776
+ const leadingDiagnostics = [
777
+ ...(this.recoveryNotice === undefined || this.recoveryNoticeShown ? [] : [this.recoveryNotice]),
778
+ ]
354
779
  this.recoveryNoticeShown = this.recoveryNotice !== undefined
355
780
  this.recoveryNotice = undefined
356
781
  if (request.journal !== undefined) request.journal.diagnostics.push(...leadingDiagnostics)
357
- const result = await this.cellExecutor.executeCell(request, undefined, config)
782
+ let result
783
+ try {
784
+ result = await this.cellExecutor.executeCell(request, undefined, config)
785
+ } catch (error) {
786
+ // The kernel refused to admit this cell because an earlier helper was not
787
+ // reclaimed. Nothing ran and nothing about the recorded history changed, so
788
+ // the cell settles on its own terms instead of blaming the source; the
789
+ // recovery notice prepared above is still attached below.
790
+ if (!(error instanceof CellAdmissionBlocked)) throw error
791
+ result = { logs: [], error: { kind: 'worker-exit', message: error.message } }
792
+ this.completeJournal(request.journal, 'discarded', result)
793
+ }
358
794
  if (result.error === undefined) {
359
795
  this.failures.reset()
360
796
  } else {
361
797
  const primary = request.journal?.diagnostics.find(item => item.severity === 'error')
362
- const hint = this.failures.hint(result.error, primary?.stateEffect)
798
+ // A no-op journal is this cell's own record that nothing it could have done
799
+ // is in the REPL state, so a repeated-failure hint must not describe that
800
+ // state as unknown.
801
+ const stateEffect = primary?.stateEffect
802
+ ?? (request.journal?.status === 'noop' ? 'unchanged' : undefined)
803
+ const hint = this.failures.hint(result.error, stateEffect)
363
804
  if (hint !== undefined) {
364
805
  result.logs = [...result.logs, renderDiagnostic(hint, request.program)]
365
806
  if (request.journal !== undefined) request.journal.diagnostics.push(hint)
366
807
  }
808
+ // A binding announced as live by a recovery, now reported missing by the
809
+ // engine itself, went away outside this cell. Naming it here keeps the
810
+ // model from reading its own correct reference as a mistake it made.
811
+ const vanished = this.vanishedAnnouncedBindings(result.error, request.program)
812
+ if (vanished.length > 0) {
813
+ result.logs = [...result.logs, renderDiagnostic(vanished[0], request.program)]
814
+ if (request.journal !== undefined) request.journal.diagnostics.push(...vanished)
815
+ }
367
816
  }
368
817
  if (leadingDiagnostics.length > 0) {
369
818
  const rendered = leadingDiagnostics.map(item => renderDiagnostic(item, request.program))
@@ -373,15 +822,68 @@ class SessionKernel {
373
822
  return finishResult(result)
374
823
  }
375
824
 
825
+ /**
826
+ * Bindings a recovery promised as live that the engine now reports missing.
827
+ *
828
+ * Each name is reported once: the set is drained when it is named, so a later
829
+ * cell repeating the same reference does not repeat the notice.
830
+ */
831
+ vanishedAnnouncedBindings(error, program) {
832
+ if (this.announcedLive.size === 0) return []
833
+ const message = String(error?.message ?? '')
834
+ const match = /(?:ReferenceError:\s*)?([A-Za-z_$][\w$]*) is not defined/.exec(message)
835
+ if (match === null) return []
836
+ const name = match[1]
837
+ if (!this.announcedLive.has(name)) return []
838
+ this.announcedLive.delete(name)
839
+ void program
840
+ return [vanishedBindingDiagnostic([name])]
841
+ }
842
+
376
843
  async replayHistory(request, config) {
377
844
  this.bindingCatalog = new BindingCatalog()
378
845
  const limits = this.valueLimits(config)
379
846
  const emptyArgsWire = encodeValueAtBoundary({}, limits, 'parameter')
380
847
  const path = pathToHead(this.history)
848
+ // Every binding the session log proves has to come back or be named. A node
849
+ // that is not on the path to the head never replays, so its bindings would
850
+ // otherwise disappear without a diagnostic.
851
+ const replayedNames = new Set()
852
+ for (const node of path) {
853
+ for (const value of node.journal.values ?? []) replayedNames.add(value.name)
854
+ for (const name of bindingNamesOfOperations(node.journal.operations)) replayedNames.add(name)
855
+ }
856
+ // A recorded rewind ended these bindings on purpose: they are gone by record,
857
+ // not by omission, so they are reported apart from the ones recovery failed to
858
+ // install. Nothing else may report them as missing.
859
+ const endedBindings = new Set(this.history.deletedBindings ?? [])
381
860
  for (const node of path) {
382
861
  const bindings = replayBindingNamespaces(request.bindings, node.journal, limits, emptyArgsWire)
383
862
  const bindingDescriptors = bindings === request.bindings
384
863
  ? request.bindingDescriptors : normalizeBindingDescriptors(bindings)
864
+ // A cell the journal recorded as volatile keeps the values it produced.
865
+ // Installing them is the only replay that does not redispatch the ambient
866
+ // source that made the cell volatile, so its completion stays unverified
867
+ // while its bindings stay live.
868
+ if (node.restoredFromRecord === true) {
869
+ const restored = await this.cellExecutor.executeCell(
870
+ {
871
+ ...request,
872
+ bindings: bindingDescriptors.namespaces,
873
+ bindingDescriptors,
874
+ program: node.code,
875
+ journal: undefined,
876
+ restoreValues: node.journal.values,
877
+ ...(node.userBindings === undefined ? { userBindings: undefined } : { userBindings: node.userBindings }),
878
+ },
879
+ node.journal,
880
+ config,
881
+ )
882
+ if (restored.error !== undefined) throw new ReplayFailure(node, new Error(restored.error.message))
883
+ this.restoredFromRecord.push({ callSeq: node.callSeq,
884
+ bindings: restored.restored ?? [], failed: restored.failed ?? [] })
885
+ continue
886
+ }
385
887
  try {
386
888
  const result = await this.cellExecutor.executeCell(
387
889
  {
@@ -398,26 +900,50 @@ class SessionKernel {
398
900
  const completion = node.journal.completion
399
901
  if (result.error !== undefined && !['exception', 'invalid-output'].includes(result.error.kind)) {
400
902
  if (result.error.kind === 'abort') throw new ReplayCancelled(result)
903
+ // An unmeasured cell never ran: it disproves nothing about the recorded
904
+ // frontier, and the replay can be attempted again through a fresh worker.
905
+ if (result.error.kind === 'budget-unavailable') throw new ReplayUnmeasured(result)
401
906
  throw new Error(`cell replay infrastructure failed (${result.error.kind}): ${result.error.message}`)
402
907
  }
403
908
  if (completion.kind === 'return' && result.error !== undefined) {
404
- throw new Error(`cell replay failed: ${result.error.message}`)
909
+ throw new ReplayDivergence(`recorded cell returned a value and the replay failed: ${result.error.message}`)
405
910
  }
406
911
  if (completion.kind === 'throw') {
407
912
  if (result.error === undefined) throw new Error('cell replay succeeded where the recorded cell failed')
408
913
  if (result.error.kind !== completion.error.kind || result.error.message !== completion.error.message) {
409
- throw new Error('cell replay produced a different semantic failure')
914
+ throw new ReplayDivergence('cell replay produced a different semantic failure')
410
915
  }
411
916
  }
412
917
  } catch (error) {
413
- if (error instanceof ReplayCancelled) throw error
918
+ // A blocked admission is not a fact about this recorded cell, so it is
919
+ // rethrown unchanged and never wrapped as a replay failure.
920
+ if (error instanceof ReplayCancelled || error instanceof ReplayUnmeasured
921
+ || error instanceof CellAdmissionBlocked) throw error
922
+ // The replay produced real bindings and a different completion. Contracting
923
+ // here dropped every later binding the log still proves, which is how a
924
+ // diverging completion cost the user the whole REPL; certify the divergence
925
+ // and keep replaying from the bindings the replay established.
926
+ if (error instanceof ReplayDivergence) {
927
+ this.unverifiedCompletions.push({ callSeq: node.callSeq, reason: error.message })
928
+ continue
929
+ }
414
930
  throw new ReplayFailure(node, error)
415
931
  }
416
932
  }
417
933
  for (const node of path) this.liveCallSeqs.add(node.callSeq)
934
+ // Names the replay installed include the ones the binding catalog supplies: a
935
+ // binding can come back through the catalog without any cell executing, so the
936
+ // arithmetic below counts that evidence too. Without it a rewound branch whose
937
+ // binding returned through the catalog was reported as ended while its value was
938
+ // correct.
939
+ const installed = new Set(replayedNames)
940
+ for (const name of this.bindingCatalog.inputs().knownBindings) installed.add(name)
941
+ this.rewindEndedBindings = [...endedBindings].filter(name => !installed.has(name))
942
+ this.missingBindings = (this.history.provenBindings ?? [])
943
+ .filter(name => !installed.has(name) && !endedBindings.has(name))
418
944
  }
419
945
 
420
- completeJournal(journal, status, result, volatileReason, diagnostics = [], completion = undefined) {
946
+ completeJournal(journal, status, result, volatileReason, diagnostics = [], completion = undefined, values = undefined) {
421
947
  if (journal === undefined) return
422
948
  journal.status = status
423
949
  journal.completion = result.error === undefined
@@ -428,6 +954,13 @@ class SessionKernel {
428
954
  }
429
955
  : { kind: 'throw', error: { kind: result.error.kind, message: result.error.message } }
430
956
  if (volatileReason !== undefined) journal.volatileReason = volatileReason
957
+ // A volatile cell keeps the values it produced so recovery can install them
958
+ // without running the cell again; a value the codec could not transfer is
959
+ // named instead of being omitted silently.
960
+ if (values !== undefined && (values.values.length > 0 || values.unrecorded.length > 0)) {
961
+ journal.values = values.values
962
+ if (values.unrecorded.length > 0) journal.unrecordedBindings = values.unrecorded
963
+ }
431
964
  if (diagnostics.length > 0) journal.diagnostics.push(...diagnostics)
432
965
  if (status === 'volatile') {
433
966
  journal.operations = journal.operations.filter(operation => operation.action !== 'save')
@@ -443,14 +976,72 @@ class SessionKernel {
443
976
  this.workerObservation = undefined
444
977
  this.durability = durabilityState()
445
978
  this.replayed = false
979
+ // Every path that drops the live worker replaces it, so the next cell starts
980
+ // its own sequence of unmeasured cells on a worker that has not failed yet.
981
+ this.unmeasuredCells = 0
446
982
  this.bindingCatalog = new BindingCatalog()
447
983
  this.liveCallSeqs.clear()
448
984
  }
449
985
 
986
+ /**
987
+ * Settle a cell that never reached the runtime.
988
+ *
989
+ * A cell that was never dispatched has nothing to discard, no binding to roll
990
+ * back and no reason to rebuild the worker, so its journal records a no-op
991
+ * outcome and the live worker keeps the state earlier cells proved. Consecutive
992
+ * such cells are the evidence that this worker does not become dispatchable:
993
+ * the live worker is then dropped while the proved frontier stays, and the next
994
+ * cell replays it. Budgets and the cancellation listener end with the cell
995
+ * exactly as they do on any other settlement.
996
+ */
997
+ abandonCell(active, result) {
998
+ if (this.active !== active) return
999
+ active.settled = true
1000
+ clearInterval(active.computeTimer)
1001
+ clearTimeout(active.wallTimer)
1002
+ active.request.signal?.removeEventListener('abort', active.onAbort)
1003
+ this.active = undefined
1004
+ this.completeJournal(active.journal, 'noop', result)
1005
+ this.unmeasuredCells += 1
1006
+ // The cell was submitted under its own configuration snapshot, and that
1007
+ // snapshot decides its settlement even if the setting changed while it ran.
1008
+ // `admission.config` is the runtime configuration object itself (`beginSubmission`
1009
+ // stores `this.config` by reference), so a second lookup on `this.config` could only
1010
+ // repeat the first answer. The shipped constant is the real fallback.
1011
+ const limit = Number.isSafeInteger(active.config?.unmeasuredCellLimit)
1012
+ ? active.config.unmeasuredCellLimit
1013
+ : UNMEASURED_CELL_LIMIT
1014
+ if (this.unmeasuredCells < limit) {
1015
+ active.finish(result)
1016
+ return
1017
+ }
1018
+ this.rollbackToDurable()
1019
+ // The model has to learn that the state this worker held is gone; only the
1020
+ // proved frontier survives for the next cell to replay.
1021
+ result = { ...result, error: { ...result.error,
1022
+ 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` } }
1023
+ this.completeJournal(active.journal, 'noop', result)
1024
+ // A refused reclamation stays recorded on this kernel instead of becoming an
1025
+ // unhandled rejection; the kernel keeps the worker until dispose.
1026
+ void this.resetWorker(active.worker).then(record => {
1027
+ if (record.failed) {
1028
+ result = { ...result, error: { ...result.error,
1029
+ message: `${result.error?.message ?? 'cell could not be measured'}; helper reclamation failed: ${String(record.reason?.message ?? record.reason)}` } }
1030
+ if (active.journal !== undefined && active.replay === undefined) {
1031
+ this.completeJournal(active.journal, 'noop', result)
1032
+ }
1033
+ }
1034
+ active.finish(result)
1035
+ })
1036
+ }
1037
+
450
1038
  settleCell(active, result, terminate = false) {
451
1039
  /* c8 ignore next */
452
1040
  if (this.active !== active) return
453
1041
  active.settled = true
1042
+ // Any cell that settles on a normal path disproves the sequence of cells that
1043
+ // never reached the runtime, whichever way it ended.
1044
+ this.unmeasuredCells = 0
454
1045
  const { request, journal, replay, worker } = active
455
1046
  clearInterval(active.computeTimer)
456
1047
  clearTimeout(active.wallTimer)
@@ -477,6 +1068,7 @@ class SessionKernel {
477
1068
  const status = active.durability.status
478
1069
  this.completeJournal(
479
1070
  journal, status, result, active.durability.reason, active.diagnostics, active.completion,
1071
+ active.bindingValues,
480
1072
  )
481
1073
  this.tentatives.set(journal, {
482
1074
  callSeq: request.callSeq,
@@ -495,7 +1087,8 @@ class SessionKernel {
495
1087
  // A live cell establishes this worker's timeline even when historical
496
1088
  // replay was disabled. Only a reset may reopen its recovery boundary.
497
1089
  this.replayed = true
498
- this.liveCallSeqs.add(request.callSeq ?? request.sourceCallSeq)
1090
+ const ownSeq = request.callSeq ?? request.sourceCallSeq
1091
+ this.liveCallSeqs.add(ownSeq)
499
1092
  }
500
1093
  this.active = undefined
501
1094
  if (terminate) {
@@ -671,7 +1264,7 @@ function sessionOf(sessionContext) {
671
1264
  * and contract the durable frontier. Zero-argument calls keep their recorded
672
1265
  * empty-object form so the args wire still matches the journal.
673
1266
  */
674
- function replayBindingNamespaces(namespaces, journal, limits, emptyArgsWire) {
1267
+ export function replayBindingNamespaces(namespaces, journal, limits, emptyArgsWire) {
675
1268
  const calls = journal?.calls ?? []
676
1269
  let changed = false
677
1270
  const next = namespaces.map(namespace => {
@@ -702,6 +1295,15 @@ export class SessionRuntime {
702
1295
  constructor(config = {}, options = {}) {
703
1296
  this.config = resolvedRuntimeConfig(config)
704
1297
  this.kernels = new Map()
1298
+ // Identities whose owning session was disposed. Their environment is gone by
1299
+ // contract, so a later cell in the same identity must start empty instead of
1300
+ // replaying bindings the disposed owner once held.
1301
+ this.disposedSessions = new Set()
1302
+ // Identities whose Agent was disposed. An Agent owns the live environment,
1303
+ // not the session's durable journal: releasing it ends the worker and every
1304
+ // live-only (volatile) value it observed, while the durable computation the
1305
+ // log proves still replays for the identity that continues the session.
1306
+ this.releasedAgents = new Set()
705
1307
  this.pendingNoops = new Map()
706
1308
  this.settlements = new WeakSet()
707
1309
  this.preparingCells = new Set()
@@ -850,21 +1452,39 @@ export class SessionRuntime {
850
1452
  }
851
1453
  let kernel = this.kernels.get(sessionId)
852
1454
  if (kernel === undefined) {
1455
+ // A disposed session loses its environment by contract: the next cell in
1456
+ // that identity starts empty and is told how many historical cells the
1457
+ // disposed environment held. Replaying them would hand the disposed
1458
+ // owner's bindings to whoever runs next in the same identity.
1459
+ const disposedCells = this.disposedSessions.has(sessionId)
1460
+ ? disposedHistoryCells(sessionLog, callSeq)
1461
+ : 0
1462
+ // A released Agent owns only the live environment, so the durable journal
1463
+ // still replays; the live-only values that environment observed do not.
1464
+ const liveOnlyLost = this.releasedAgents.has(sessionId)
853
1465
  let history
854
1466
  try {
855
- history = cellConfig.durableReplay
856
- ? recoverJournal(sessionLog, callSeq, {
1467
+ history = disposedCells > 0 || !cellConfig.durableReplay
1468
+ ? emptyHistory()
1469
+ : recoverJournal(sessionLog, callSeq, {
857
1470
  visibleCallSeqs: visibleExecutableCallSeqs(sessionLog),
1471
+ ...(liveOnlyLost ? { liveOnlyLost: true } : {}),
858
1472
  })
859
- : emptyHistory()
860
1473
  } catch (error) {
861
1474
  // Malformed PTC metadata is contracted by the ordered recovery owner.
862
1475
  // Failure to read DSH's session source cannot prove a reset boundary.
863
1476
  return completed({ logs: [], error: { kind: 'recovery', message: `cannot reconstruct REPL from session log: ${messageOf(error)}` } })
864
1477
  }
1478
+ // One release is one fact about the environment that held it: the
1479
+ // environment built below owns its own live values again.
1480
+ if (liveOnlyLost) this.releasedAgents.delete(sessionId)
865
1481
  kernel = new SessionKernel({
866
- config: cellConfig,
1482
+ // The kernel owns the worker and the sessions it serves, so it takes the
1483
+ // configuration current when the session starts; this cell's own budgets
1484
+ // still come from the submitted snapshot passed to run().
1485
+ config: this.config,
867
1486
  history,
1487
+ disposedCells,
868
1488
  cwd,
869
1489
  session,
870
1490
  surfaceProvenanceRequired,
@@ -938,6 +1558,27 @@ export class SessionRuntime {
938
1558
 
939
1559
  async disposeSession(sessionId) {
940
1560
  const id = String(sessionId)
1561
+ // Recorded before the early return: an identity whose owning session was
1562
+ // disposed has no environment to resume, whether or not a kernel still
1563
+ // existed here.
1564
+ this.disposedSessions.add(id)
1565
+ return this.#releaseKernel(id)
1566
+ }
1567
+
1568
+ /**
1569
+ * End the live environment of one Agent while keeping the session's journal.
1570
+ *
1571
+ * The Agent, not the session, owns the worker: a replaced Agent in the same
1572
+ * session continues the computation the log proves, and loses only the
1573
+ * live-only values its predecessor observed.
1574
+ */
1575
+ async disposeAgent(sessionId) {
1576
+ const id = String(sessionId)
1577
+ this.releasedAgents.add(id)
1578
+ return this.#releaseKernel(id)
1579
+ }
1580
+
1581
+ async #releaseKernel(id) {
941
1582
  for (const admission of this.preparingCells) {
942
1583
  if (admission.sessionId === id) {
943
1584
  admission.cancelled = true