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.
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/client.cjs +1467 -84
- package/compiler-core.cjs +132 -132
- package/compiler-core.cjs.map +3 -3
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
- package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
- package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
- package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
- package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
- package/docs/architecture.md +2 -2
- package/docs/client-ui.md +5 -2
- package/docs/durability-design.md +7 -1
- package/docs/releases/v0.5.2.md +79 -0
- package/docs/runtime-reference.md +36 -2
- package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
- package/docs/verification.md +6 -2
- package/index.js +36 -15
- package/internal/bounded-literal.js +85 -0
- package/internal/capability-environment.js +162 -10
- package/internal/capability-plan.js +11 -8
- package/internal/cell-error.js +13 -0
- package/internal/config-spec.js +113 -2
- package/internal/direct-surface-owner.js +14 -10
- package/internal/edit-transport-owner.js +79 -14
- package/internal/isolated-worker.js +134 -17
- package/internal/kernel-worker.js +167 -3
- package/internal/legacy-cell-analysis.js +14 -2
- package/internal/legacy-repl-convenience.js +16 -3
- package/internal/projection-retention.js +238 -0
- package/internal/prompt-text.js +858 -0
- package/internal/recovery-tips.js +50 -11
- package/internal/rejected-cell-editor.js +3 -2
- package/internal/runtime-bridge-owner.js +1 -1
- package/internal/runtime-config.js +2 -0
- package/internal/runtime-contexts.js +14 -5
- package/internal/runtime-messages.js +43 -15
- package/internal/session-cell-executor.js +132 -11
- package/internal/session-journal-recovery.js +1009 -99
- package/internal/session-journal-schema.js +7 -5
- package/internal/session-journal.js +177 -14
- package/internal/session-log-view.js +111 -24
- package/internal/session-runtime.js +674 -33
- package/internal/session-state.js +27 -0
- package/internal/stateful-root-compiler.js +11 -0
- package/internal/stateful-root-runtime.js +17 -0
- package/internal/user-binding-console.js +7 -0
- package/internal/user-bindings-owner.js +3 -0
- package/internal/user-bindings.js +2 -2
- package/internal/validated-parse-repair.js +6 -1
- package/internal/worker-client.js +27 -4
- package/package.json +3 -1
- package/src/client-activity.js +24 -4
- package/src/client-capabilities-view.js +459 -24
- package/src/client-copy.js +163 -2
- package/src/client-repl-view.js +6 -1
- 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
|
-
|
|
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:
|
|
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.
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
914
|
+
throw new ReplayDivergence('cell replay produced a different semantic failure')
|
|
410
915
|
}
|
|
411
916
|
}
|
|
412
917
|
} catch (error) {
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|