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