dsh-ptc-plus 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.en.md +15 -3
  2. package/README.md +15 -3
  3. package/client.cjs +1983 -234
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +2 -2
  8. package/docs/adr/0023-global-user-bindings.md +1 -1
  9. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  10. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  11. package/docs/adr/0038-freeze-session-capability-plans.md +1 -1
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +35 -0
  13. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  14. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  15. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  16. package/docs/adr/README.md +2 -0
  17. package/docs/architecture.md +2 -2
  18. package/docs/capability-planning-components.md +2 -2
  19. package/docs/client-ui.md +13 -10
  20. package/docs/client-visual-contract.md +1 -1
  21. package/docs/durability-design.md +7 -1
  22. package/docs/installation.md +5 -3
  23. package/docs/publishing.md +2 -2
  24. package/docs/releases/v0.5.2.md +79 -0
  25. package/docs/runtime-reference.md +42 -3
  26. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  27. package/docs/verification.md +6 -2
  28. package/index.js +63 -19
  29. package/internal/bounded-literal.js +85 -0
  30. package/internal/capability-command-receipt.js +3 -1
  31. package/internal/capability-environment.js +171 -13
  32. package/internal/capability-plan.js +11 -8
  33. package/internal/capability-statistics.js +6 -2
  34. package/internal/cell-error.js +13 -0
  35. package/internal/config-spec.js +122 -2
  36. package/internal/direct-surface-owner.js +14 -10
  37. package/internal/edit-transport-owner.js +96 -17
  38. package/internal/execution-provider-owner.js +25 -0
  39. package/internal/execution-seam-compat.js +18 -0
  40. package/internal/isolated-worker.js +134 -17
  41. package/internal/kernel-worker.js +167 -3
  42. package/internal/legacy-cell-analysis.js +14 -2
  43. package/internal/legacy-repl-convenience.js +16 -3
  44. package/internal/official-repl-priority.js +51 -0
  45. package/internal/projection-retention.js +238 -0
  46. package/internal/prompt-text.js +858 -0
  47. package/internal/recovery-tips.js +50 -11
  48. package/internal/rejected-cell-editor.js +3 -2
  49. package/internal/runtime-bridge-owner.js +11 -8
  50. package/internal/runtime-config.js +2 -0
  51. package/internal/runtime-contexts.js +14 -5
  52. package/internal/runtime-messages.js +43 -15
  53. package/internal/sdk-projection.js +2 -2
  54. package/internal/session-cell-executor.js +132 -11
  55. package/internal/session-journal-recovery.js +1009 -99
  56. package/internal/session-journal-schema.js +7 -5
  57. package/internal/session-journal.js +177 -14
  58. package/internal/session-log-view.js +111 -24
  59. package/internal/session-runtime.js +674 -33
  60. package/internal/session-state.js +27 -0
  61. package/internal/stateful-root-compiler.js +11 -0
  62. package/internal/stateful-root-runtime.js +17 -0
  63. package/internal/user-binding-console.js +7 -0
  64. package/internal/user-bindings-owner.js +8 -27
  65. package/internal/user-bindings.js +2 -2
  66. package/internal/validated-parse-repair.js +6 -1
  67. package/internal/worker-client.js +27 -4
  68. package/package.json +5 -2
  69. package/src/client-activity.js +27 -6
  70. package/src/client-capabilities-view.js +774 -125
  71. package/src/client-copy.js +231 -18
  72. package/src/client-repl-view.js +6 -1
  73. package/src/client-styles.js +68 -2
  74. package/src/client-tool-view.js +9 -2
@@ -5,14 +5,46 @@ import { PassThrough } from 'node:stream'
5
5
  import { pathToFileURL } from 'node:url'
6
6
  import { helperProcessEnvironment, isElectronHost } from './worker-environment.js'
7
7
 
8
+ /**
9
+ * Bound on the wait for one on-demand utilization answer.
10
+ *
11
+ * The helper answers by reading the inner worker's counters synchronously, so
12
+ * this bound covers delivery rather than helper work. It is deliberately not
13
+ * the shutdown release grace below: how long a stopping helper may keep running
14
+ * must not decide how long a cell waits for its measurement.
15
+ */
16
+ export const ISOLATED_SAMPLE_MS = 1000
17
+ /**
18
+ * Sampling failure code for a sample whose answer this transport could not
19
+ * observe. The transport reports only that fact: whether the worker stays usable
20
+ * belongs to the caller, which alone knows how many consecutive measurements it
21
+ * may lose before the worker has to be replaced.
22
+ */
23
+ export const UNANSWERED_SAMPLE_CODE = 'ptc-sample-unanswered'
24
+ /**
25
+ * Helper messages one sample may read out before it gives up on the answer.
26
+ *
27
+ * Reading stops at the first event-loop turn that delivers nothing, so this
28
+ * bound only ends a backlog larger than the bound itself, where the answer
29
+ * cannot be reached by reading until the channel empties.
30
+ */
31
+ const SAMPLE_DRAIN_TURNS = 4096
8
32
  /**
9
33
  * Fallback bound for a helper that answers the stop request and then keeps the
10
34
  * process alive with unrelated work of its own.
11
35
  */
12
36
  const ISOLATED_RELEASE_MS = 1000
13
- /** Absolute bound after which an unresponsive helper is killed. */
37
+ /**
38
+ * Default bound for each close stage of one helper and the transport default.
39
+ *
40
+ * Exit confirmation and the output close that follows are each bounded by it;
41
+ * the fixed release grace is capped so the bound still leaves a confirmation
42
+ * window for the kill. A transport instance carries a configurable
43
+ * `reclaimBoundMs`, and the close path reads that instance bound instead of
44
+ * this default.
45
+ */
14
46
  const ISOLATED_STOP_MS = 5000
15
- /** Bound on the helper's output pipes really ending after its process exited. */
47
+ /** Default bound on the helper's output pipes really ending after its process exited. */
16
48
  const ISOLATED_OUTPUT_MS = 5000
17
49
 
18
50
  export function isolatedWorkerEntry(entry) {
@@ -36,7 +68,7 @@ export function isolatedWorkerEntry(entry) {
36
68
  * so a kernel that never answers is reclaimed without ending the host.
37
69
  */
38
70
  export class IsolatedWorker extends EventEmitter {
39
- constructor({ helper, entry, workerData, resourceLimits, env, protocol = 'kernel' }) {
71
+ constructor({ helper, entry, workerData, resourceLimits, env, protocol = 'kernel', reclaimBoundMs = ISOLATED_STOP_MS }) {
40
72
  super()
41
73
  // The entry follows the worker constructor's contract: a URL or a string
42
74
  // filename. Anything else fails here, before a helper process exists.
@@ -58,6 +90,29 @@ export class IsolatedWorker extends EventEmitter {
58
90
  this.utilization = { idle: 0, active: 0 }
59
91
  this.utilizationSequence = 0
60
92
  this.utilizationRequests = new Map()
93
+ /**
94
+ * Count of messages this helper has produced.
95
+ *
96
+ * Reading a sample uses it to tell one event-loop turn that delivered
97
+ * something from one that delivered nothing; the count says nothing about
98
+ * when the helper wrote a message.
99
+ */
100
+ this.helperMessages = 0
101
+ /**
102
+ * Bound this instance waits for one utilization answer.
103
+ *
104
+ * It is read per request, so a transport user may shorten or extend the wait
105
+ * for its own environment without changing the shutdown release grace.
106
+ */
107
+ this.sampleBoundMs = ISOLATED_SAMPLE_MS
108
+ /**
109
+ * Bound this instance's close path waits for the killed helper to be
110
+ * confirmed gone and for its output pipes to end.
111
+ *
112
+ * It is read when the close starts, so a transport user may widen the wait
113
+ * for its own platform without changing the release grace.
114
+ */
115
+ this.reclaimBoundMs = reclaimBoundMs
61
116
  this.pendingErrors = []
62
117
  this.performance = {
63
118
  /** Utilization reported by the helper, in the shape callers already use. */
@@ -124,6 +179,7 @@ export class IsolatedWorker extends EventEmitter {
124
179
  }
125
180
  })
126
181
  this.child.on('message', message => {
182
+ this.helperMessages += 1
127
183
  if (message?.type === 'child-error') {
128
184
  const error = new Error(message.message)
129
185
  if (message.name !== undefined) error.name = message.name
@@ -139,11 +195,14 @@ export class IsolatedWorker extends EventEmitter {
139
195
  }
140
196
  if (message?.type === 'inner-worker-exit') { this.emit('inner-worker-exit', message.code); return }
141
197
  if (message?.type === 'utilization-sample') {
198
+ // An answer for a request this transport already concluded is still the
199
+ // helper doing its work: keep the freshest value it reported.
200
+ this.utilization = message.utilization
142
201
  const pending = this.utilizationRequests.get(message.id)
143
202
  if (pending !== undefined) {
144
203
  this.utilizationRequests.delete(message.id)
145
204
  clearTimeout(pending.timer)
146
- this.utilization = message.utilization
205
+ if (pending.decision !== undefined) clearImmediate(pending.decision)
147
206
  pending.resolve(message.utilization)
148
207
  }
149
208
  return
@@ -225,25 +284,81 @@ export class IsolatedWorker extends EventEmitter {
225
284
  }
226
285
  const id = ++this.utilizationSequence
227
286
  return new Promise((resolve, reject) => {
228
- const timer = setTimeout(() => {
229
- this.utilizationRequests.delete(id)
230
- reject(new Error('kernel helper did not answer utilization sampling before its deadline'))
231
- }, ISOLATED_RELEASE_MS)
232
- this.utilizationRequests.set(id, { resolve, reject, timer })
287
+ const pending = { resolve, reject, timer: undefined, decision: undefined }
288
+ pending.timer = setTimeout(() => {
289
+ // A blocked host runs its expired timers before it reads its IPC pipe, so
290
+ // the answer may be waiting unread here. Read the channel out first and
291
+ // only then conclude that no answer exists.
292
+ pending.decision = setImmediate(() => this.#readOutSample(id, pending))
293
+ }, this.sampleBoundMs)
294
+ this.utilizationRequests.set(id, pending)
233
295
  try {
234
296
  this.child.send({ type: 'sample-utilization', id })
235
297
  } catch (error) {
236
- clearTimeout(timer)
298
+ clearTimeout(pending.timer)
237
299
  this.utilizationRequests.delete(id)
238
300
  reject(error)
239
301
  }
240
302
  })
241
303
  }
242
304
 
305
+ /**
306
+ * Read the helper's channel out before one unanswered sample is concluded.
307
+ *
308
+ * Node delivers one helper message per event-loop turn, and the answer this
309
+ * request waits for is written after everything the helper wrote before it, so
310
+ * it can sit behind queued messages. Each turn therefore either delivers the
311
+ * answer or another message the helper wrote; only a turn that delivers no
312
+ * message shows that the channel holds nothing more this read could observe.
313
+ *
314
+ * A message the parser still holds in parts is not delivered on any of those
315
+ * turns, so a partially received message ahead of the answer delays it past
316
+ * this read-out; the sample then reports no answer, which is all this
317
+ * transport can prove about it.
318
+ */
319
+ #readOutSample(id, pending) {
320
+ let delivered = this.helperMessages
321
+ let turns = 0
322
+ const turn = () => {
323
+ if (this.utilizationRequests.get(id) !== pending) return
324
+ if (turns >= SAMPLE_DRAIN_TURNS) {
325
+ this.utilizationRequests.delete(id)
326
+ pending.decision = undefined
327
+ pending.reject(this.#unansweredSample('backlog'))
328
+ return
329
+ }
330
+ if (this.helperMessages === delivered) {
331
+ this.utilizationRequests.delete(id)
332
+ pending.decision = undefined
333
+ pending.reject(this.#unansweredSample('silent-channel'))
334
+ return
335
+ }
336
+ delivered = this.helperMessages
337
+ turns += 1
338
+ pending.decision = setImmediate(turn)
339
+ }
340
+ pending.decision = setImmediate(turn)
341
+ }
342
+
343
+ /**
344
+ * Report one sample whose answer this transport could not observe.
345
+ *
346
+ * The transport states the observation only. Whether the caller keeps its
347
+ * worker is decided where the consecutive measurement failures are counted.
348
+ */
349
+ #unansweredSample(reason) {
350
+ const error = new Error(reason === 'backlog'
351
+ ? `kernel helper wrote at least ${SAMPLE_DRAIN_TURNS} messages ahead of this utilization sample, so its answer could not be reached before the read-out bound`
352
+ : 'kernel helper did not answer utilization sampling before its deadline')
353
+ error.code = UNANSWERED_SAMPLE_CODE
354
+ return error
355
+ }
356
+
243
357
  /** Fail every request that still needs the helper's utilization answer. */
244
358
  #rejectUtilizationRequests(error) {
245
359
  for (const pending of this.utilizationRequests.values()) {
246
360
  clearTimeout(pending.timer)
361
+ if (pending.decision !== undefined) clearImmediate(pending.decision)
247
362
  pending.reject(error)
248
363
  }
249
364
  this.utilizationRequests.clear()
@@ -268,12 +383,14 @@ export class IsolatedWorker extends EventEmitter {
268
383
 
269
384
  async #stop() {
270
385
  if (this.exited) return
271
- // Both cutoffs are anchored at the moment the close starts: an acknowledgement
272
- // never postpones the kill, and a kill still leaves a positive bounded window
273
- // for the helper's real exit to arrive.
386
+ // The configured bound is this close's exit-confirmation deadline, anchored
387
+ // at the close start so an acknowledgement never postpones the kill. The
388
+ // fixed release grace is capped to leave a grace-sized confirmation window
389
+ // for the kill, so a bound at or just above the grace is never consumed
390
+ // before the kill; the output pipes then get the same bound from their exit.
274
391
  const started = Date.now()
275
- const killAt = started + ISOLATED_RELEASE_MS
276
- const confirmBy = started + ISOLATED_STOP_MS
392
+ const confirmBy = started + this.reclaimBoundMs
393
+ const releaseAt = started + Math.min(ISOLATED_RELEASE_MS, Math.max(0, this.reclaimBoundMs - ISOLATED_RELEASE_MS))
277
394
  const exited = this.exitedPromise
278
395
  const onReleased = message => {
279
396
  if (message?.type === 'worker-message' && message.value?.type === 'shutdown-released') this.released = true
@@ -287,7 +404,7 @@ export class IsolatedWorker extends EventEmitter {
287
404
  } catch {
288
405
  // The channel is closed; the kill path below owns the instance.
289
406
  }
290
- if (!await leftWithin(this, remaining(killAt))) {
407
+ if (!await leftWithin(this, remaining(releaseAt))) {
291
408
  let killFailure
292
409
  if (!this.exited) {
293
410
  try {
@@ -410,7 +527,7 @@ export class IsolatedOwner {
410
527
  // synchronously finish or throw and clear the marker for a later retry.
411
528
  const settling = Promise.resolve().then(async () => {
412
529
  if (record.neverStarted !== true && record.transport !== undefined) {
413
- const closed = await outputClosed(record.transport, ISOLATED_OUTPUT_MS)
530
+ const closed = await outputClosed(record.transport, record.transport?.reclaimBoundMs ?? ISOLATED_OUTPUT_MS)
414
531
  if (!closed) {
415
532
  // Output never drained: keep the instance and say so instead of
416
533
  // pretending the pipes ended naturally.
@@ -42,7 +42,7 @@ import { installProgramAmbientResolver } from './dynamic-environment-runtime.js'
42
42
  const { Object, Map, Set, Proxy, Error,
43
43
  mapGet, mapSet, mapHas, mapDelete, mapClear, mapSize, mapForEach,
44
44
  setHas, setAdd, setDelete, setClear, setForEach,
45
- everyArray, someArray, appendArray, copyArray, join, sliceString, endsWith,
45
+ everyArray, someArray, isArray, min, appendArray, copyArray, join, sliceString, endsWith,
46
46
  bufferConcat, bufferIsBuffer, bufferToString,
47
47
  Reflect: privateReflect, promiseResolve, promiseReject } = moduleRuntimeIntrinsics
48
48
  const PublicPromise = globalThis.Promise
@@ -114,6 +114,11 @@ const output = new PassThrough()
114
114
  output.resume()
115
115
  const evaluationScope = createPrivateAsyncLocalStorage()
116
116
  const sessionCwd = typeof workerData?.cwd === 'string' ? workerData.cwd : undefined
117
+ /** Fallback bound for the startup conformance probe when the host config omits one. */
118
+ const DEFAULT_STARTUP_PROBE_MS = 5000
119
+ const startupProbeMs = Number.isSafeInteger(workerData?.startupProbeMs) && workerData.startupProbeMs > 0
120
+ ? workerData.startupProbeMs
121
+ : DEFAULT_STARTUP_PROBE_MS
117
122
  // The module source base is fixed when a cell starts; the operation directory
118
123
  // follows the Session's working directory while the cell runs, so a confirmed
119
124
  // `tools.working_directory` change rebases the rest of the cell exactly as the
@@ -232,6 +237,9 @@ const CONFORMANCE_CELL = `"use strict";
232
237
  }`
233
238
  let activeRun
234
239
  let activeExecution
240
+ // Compile-time proof that a binding's initializer is a bounded object literal,
241
+ // keyed by binding name for the running cell.
242
+ let declaredBoundedLiterals = new Map()
235
243
  let suppressReplCwdObservation = false
236
244
  let nextCallId = 0
237
245
  let nextStaticAdapterId = 0
@@ -964,7 +972,13 @@ async function activatePerNameUserBindings(snapshot, shadowedNames, cwd, reusePo
964
972
  let evaluated
965
973
  const activation = { id: entry.id, called: false, failed: false }
966
974
  try {
967
- if (entry.durability === 'volatile') markVolatile(`user binding ${jsonStringify(entry.id)}: ${entry.volatileReason ?? 'non-replayable source'}`)
975
+ if (entry.durability === 'volatile') {
976
+ // Name the binding and its first cause instead of the whole reason list:
977
+ // the model reads this to decide what it can no longer redo.
978
+ const reasons = String(entry.volatileReason ?? 'non-replayable source').split(', ')
979
+ const more = reasons.length > 1 ? ` (+${reasons.length - 1} more)` : ''
980
+ markVolatile(`user binding ${jsonStringify(entry.id)} depends on ${reasons[0]}${more}`)
981
+ }
968
982
  evaluated = await userBindingActivationScope.run(activation, () => evaluateUserBinding(entry, cwd, snapshot.transform))
969
983
  const namespace = evaluated.namespace
970
984
  for (let symbolIndex = 0; symbolIndex < entry.symbols.length; symbolIndex += 1) {
@@ -1297,6 +1311,131 @@ function observeBindings(names) {
1297
1311
  }) }
1298
1312
  }
1299
1313
 
1314
+ /**
1315
+ * The logical binding a committed root target names.
1316
+ *
1317
+ * A root target is `root:<sourceStart>:<name>` with an optional position suffix
1318
+ * the compiler appends for a repeated name. Only the logical name exists on the
1319
+ * recovery side, and it is the name the model reads, so the record keeps that
1320
+ * instead of the compiler's internal target.
1321
+ */
1322
+ function logicalBindingName(target) {
1323
+ const match = /^root:\d+:([^:]+)/u.exec(target)
1324
+ return match === null ? target : match[1]
1325
+ }
1326
+
1327
+ /** Cap one recorded binding: 64 KiB of encoded wire. */
1328
+ const RECORDED_BINDING_WIRE_LIMIT = 64 * 1024
1329
+ /** Cap one settlement's recorded bindings: 1 MiB of encoded wire in total. */
1330
+ /** Cap the graph a recording pass may walk before the value becomes unrecorded. */
1331
+ const RECORDED_VALUE_NODES = 2048
1332
+ const RECORDED_VALUE_EDGES = 4096
1333
+
1334
+ const RECORDED_CELL_WIRE_LIMIT = 1024 * 1024
1335
+
1336
+ /**
1337
+ * Encode the values a settlement produced.
1338
+ *
1339
+ * Every settlement records what it declared, durable or volatile: a durable
1340
+ * cell still needs its values when recovery cannot replay it (a rewind moved
1341
+ * the head past it, or the log proves it but the path does not reach it), and a
1342
+ * volatile cell cannot be replayed at all. The record keeps values as wire so
1343
+ * recovery installs them without running the cell again. Two caps keep the
1344
+ * journal bounded: 64 KiB per binding and 1 MiB per settlement. A value over a
1345
+ * cap, or one the codec cannot transfer, is reported by name instead of
1346
+ * silently disappearing from the record.
1347
+ */
1348
+ /**
1349
+ * Whether recording one binding can be attempted without walking an unbounded
1350
+ * value. The codec enumerates the value's own keys before it can apply any
1351
+ * budget, and enumerating a graph with millions of keys exhausts a worker heap
1352
+ * before a cap could reject it. So only shapes that are cheap to size are
1353
+ * recorded: primitives, and arrays whose length is already known. A plain
1354
+ * object, function or class instance is reported by name as unrecorded — its
1355
+ * binding still exists in the journal's operations, but its value is not
1356
+ * captured.
1357
+ */
1358
+ function recordingAffordable(value) {
1359
+ if (value === null) return true
1360
+ const kind = typeof value
1361
+ if (kind === 'number' || kind === 'string' || kind === 'boolean' || kind === 'bigint' || kind === 'undefined') return true
1362
+ if (isArray(value)) return value.length <= RECORDED_VALUE_NODES
1363
+ return false
1364
+ }
1365
+
1366
+ function collectBindingValues(names, limits = {}) {
1367
+ const values = []
1368
+ const unrecorded = []
1369
+ const seen = new Set()
1370
+ let recorded = 0
1371
+ for (const name of names) {
1372
+ const logical = logicalBindingName(name)
1373
+ if (setHas(seen, logical)) continue
1374
+ const local = statefulRoots.localValue(name) ?? statefulRoots.localValue(logical)
1375
+ if (local === undefined) {
1376
+ setAdd(seen, logical)
1377
+ unrecorded.push(logical)
1378
+ continue
1379
+ }
1380
+ setAdd(seen, logical)
1381
+ try {
1382
+ // A recording pass must stay cheap. The codec starts by enumerating the
1383
+ // value's own keys, so a huge declaration (millions of keys) would exhaust
1384
+ // the worker heap before any budget check could reject it. Walk the value
1385
+ // cheaply first — array length, or a `for…in` that stops after the cap
1386
+ // without materializing keys — and record the name as unrecorded instead.
1387
+ if (!recordingAffordable(local.value)) {
1388
+ unrecorded.push(logical)
1389
+ continue
1390
+ }
1391
+ const wire = encodeValue(local.value, {
1392
+ ...limits,
1393
+ maxNodes: min(limits.maxNodes ?? RECORDED_VALUE_NODES, RECORDED_VALUE_NODES),
1394
+ maxEdges: min(limits.maxEdges ?? RECORDED_VALUE_EDGES, RECORDED_VALUE_EDGES),
1395
+ maxArrayLength: min(limits.maxArrayLength ?? RECORDED_VALUE_NODES, RECORDED_VALUE_NODES),
1396
+ maxStringBytes: min(limits.maxStringBytes ?? RECORDED_BINDING_WIRE_LIMIT, RECORDED_BINDING_WIRE_LIMIT),
1397
+ })
1398
+ const size = jsonStringify(wire).length
1399
+ if (size > RECORDED_BINDING_WIRE_LIMIT || recorded + size > RECORDED_CELL_WIRE_LIMIT) {
1400
+ unrecorded.push(logical)
1401
+ continue
1402
+ }
1403
+ recorded += size
1404
+ values.push({ name: logical, wire })
1405
+ } catch {
1406
+ unrecorded.push(logical)
1407
+ }
1408
+ }
1409
+ return { values, unrecorded }
1410
+ }
1411
+
1412
+ /**
1413
+ * Install the values recovery recorded for a volatile cell.
1414
+ *
1415
+ * The worker never runs the restoration cell's source: each binding is decoded
1416
+ * and committed directly, so an ambient read, a timer or a child process
1417
+ * reached by the original cell is not dispatched a second time. A value the
1418
+ * codec cannot decode is reported by name so recovery can say which binding it
1419
+ * could not bring back.
1420
+ */
1421
+ function restoreRecordedValues(message) {
1422
+ const limits = message.valueLimits
1423
+ const restored = []
1424
+ const failed = []
1425
+ for (const entry of message.values ?? []) {
1426
+ const name = entry?.name
1427
+ if (typeof name !== 'string' || name === '') continue
1428
+ try {
1429
+ const value = decodeValue(entry.wire, limits)
1430
+ if (statefulRoots.restoreValue(name, value)) restored.push(name)
1431
+ else failed.push({ name, reason: 'the store cannot publish a session identity for this name' })
1432
+ } catch (error) {
1433
+ failed.push({ name, reason: error instanceof Error ? error.message : String(error) })
1434
+ }
1435
+ }
1436
+ return { restored, failed }
1437
+ }
1438
+
1300
1439
  function refreshLegacyPublication(name) {
1301
1440
  const publications = activeExecution?.nativePublications
1302
1441
  const publication = publications === undefined ? undefined : mapGet(publications, name)
@@ -1334,12 +1473,16 @@ async function sendCompletion(message, execution, userBindings, committedRedecla
1334
1473
  // after this point is background output and carries the attribution
1335
1474
  // diagnostic instead of being attached to a later cell.
1336
1475
  execution.open = false
1476
+ const bindingValues = collectBindingValues(committedRedeclarations, execution.valueLimits)
1337
1477
  channel.postMessage({
1338
1478
  type: 'done',
1339
1479
  id: message.id,
1340
1480
  outputFence: message.outputFence,
1341
1481
  logs: execution.logs,
1342
1482
  ...completionDurability(execution),
1483
+ ...(bindingValues.values.length === 0 && bindingValues.unrecorded.length === 0
1484
+ ? {}
1485
+ : { bindingValues }),
1343
1486
  committedRedeclarations: setValuesArray(committedRedeclarations),
1344
1487
  ...(message.rootBindings === undefined ? {} : { rootBindingFacts: statefulRoots.facts() }),
1345
1488
  ...(perName ? { userBindingNames: normalizeUserBindingNames(mapArray(mapEntriesArray(userBindingSources), entry => ({
@@ -1398,6 +1541,11 @@ async function runCell(message) {
1398
1541
  volatileReason: message.volatileReason,
1399
1542
  }
1400
1543
  activeExecution = execution
1544
+ // Compile-time evidence that a declaration's initializer is a bounded object
1545
+ // literal, keyed by binding name. Settlement uses it to decide whether a value
1546
+ // can be recorded without walking an object the codec cannot size; nothing
1547
+ // reads it yet, so this commit changes no recording behaviour.
1548
+ declaredBoundedLiterals = mapFromArray(message.declarationEvidence ?? [], entry => entry)
1401
1549
  const cellGlobals = []
1402
1550
  const committedRedeclarations = new Set()
1403
1551
 
@@ -1543,6 +1691,13 @@ channel.on('message', (message) => {
1543
1691
  channel.postMessage({ type: 'ready', id: message.id })
1544
1692
  return
1545
1693
  }
1694
+ if (message?.type === 'restore') {
1695
+ // Recovery installs the values a volatile cell recorded. No cell code runs
1696
+ // here: replaying the source would redispatch the ambient effects that made
1697
+ // the cell volatile, which is exactly what the record exists to avoid.
1698
+ channel.postMessage({ type: 'restored', id: message.id, ...restoreRecordedValues(message) })
1699
+ return
1700
+ }
1546
1701
  if (message?.type === 'observe') {
1547
1702
  channel.postMessage({ type: 'observation-started', id: message.id })
1548
1703
  channel.postMessage({ type: 'observation', id: message.id,
@@ -1585,7 +1740,16 @@ try {
1585
1740
  await Promise.race([
1586
1741
  verifyEvaluation(),
1587
1742
  new Promise((_, reject) => {
1588
- startupTimer = setTimeout(() => reject(new Error('REPL settlement probe timed out')), 5000)
1743
+ /* c8 ignore start -- the probe settles in microtasks, so the timer cannot win */
1744
+ // `verifyEvaluation` dispatches its fixed sources through this realm's own
1745
+ // evaluator, and every await it takes settles in a microtask (`throw`,
1746
+ // `await Promise.reject`, a syntax rejection). A `setTimeout` is a macrotask,
1747
+ // so it cannot run before those microtasks drain: the rejection below is
1748
+ // unreachable for any input, and a bound of 1 ms still starts the worker.
1749
+ startupTimer = setTimeout(() => reject(new Error(
1750
+ `REPL settlement probe timed out after ${startupProbeMs}ms`,
1751
+ )), startupProbeMs)
1752
+ /* c8 ignore stop */
1589
1753
  }),
1590
1754
  ])
1591
1755
  replModuleRealm = (await evaluate('({Promise,stringify:value=>`${value}`,importModule:(source,options)=>import(source,options)})')).value
@@ -1,4 +1,5 @@
1
1
  /** Historical legacy-v1 lowering, retained from c51616c8d6c8:cell-analysis.js. */
2
+ import { boundedLiteralEvidence } from './bounded-literal.js'
2
3
  import { ModuleRewriteError, rewriteModuleImportsExports } from './legacy-cell-rewriter.js'
3
4
  import { parseExecutableCell } from './legacy-cell-parser.js'
4
5
  import {
@@ -74,6 +75,7 @@ function topLevelDeclarations(body, variableRedeclarations = false) {
74
75
  if (statement.type === 'VariableDeclaration') {
75
76
  const definitionSpan = declarationSpan(statement)
76
77
  for (const declaration of statement.declarations) {
78
+ const first = declarations.length
77
79
  addPatternDeclarations(
78
80
  declaration.id,
79
81
  declarations,
@@ -81,6 +83,14 @@ function topLevelDeclarations(body, variableRedeclarations = false) {
81
83
  definitionSpan,
82
84
  statement.kind !== 'const' || variableRedeclarations,
83
85
  )
86
+ // A plain `const name = { … }` initializer proves its own size from the
87
+ // source, so recovery may record the value without walking it. A
88
+ // destructuring pattern has no single initializer to prove.
89
+ const evidence = declaration.id.type === 'Identifier' && declaration.init !== null
90
+ ? boundedLiteralEvidence(declaration.init) : undefined
91
+ if (evidence !== undefined) {
92
+ for (const record of declarations.slice(first)) record.boundedLiteral = evidence
93
+ }
84
94
  }
85
95
  } else if ((statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration')
86
96
  && statement.id !== null) {
@@ -408,6 +418,7 @@ function normalizePreparationOptions({
408
418
  importNamespaces = new Set(),
409
419
  writableBindings,
410
420
  moduleSemantics = LIVE_MODULE_SEMANTICS,
421
+ rewriteDescriptions,
411
422
  }) {
412
423
  if (typeof bindingPolicy === 'boolean') {
413
424
  bindingPolicy = {
@@ -435,7 +446,7 @@ function normalizePreparationOptions({
435
446
  throw new TypeError('ptc-plus: module semantics must define supported default-export and import-expression rules')
436
447
  }
437
448
  return { knownBindings, bindingPolicy, reservedBindings, rewritesEnabled,
438
- importBindings, importNamespaces, writableBindings, moduleSemantics }
449
+ importBindings, importNamespaces, writableBindings, moduleSemantics, rewriteDescriptions }
439
450
  }
440
451
 
441
452
  function parseMappedCell(moduleRewrite, program) {
@@ -522,7 +533,7 @@ function classifyPrepared({ code, sourceMap }, program, knownBindings, moduleRew
522
533
  export function prepareLegacyProgram(program, options = {}) {
523
534
  if (typeof program !== 'string') throw new TypeError('ptc-plus: program must be a string')
524
535
  const { knownBindings, bindingPolicy, reservedBindings, rewritesEnabled,
525
- importBindings, importNamespaces, writableBindings, moduleSemantics } = normalizePreparationOptions(options)
536
+ importBindings, importNamespaces, writableBindings, moduleSemantics, rewriteDescriptions } = normalizePreparationOptions(options)
526
537
  const unavailableGeneratedNames = new Set([
527
538
  ...knownBindings, ...reservedBindings, ...importBindings.keys(), ...importNamespaces,
528
539
  ])
@@ -561,6 +572,7 @@ export function prepareLegacyProgram(program, options = {}) {
561
572
  bindingPolicy,
562
573
  autoSplitRedeclarations: rewritesEnabled.autoSplitRedeclarations,
563
574
  commitSignal,
575
+ rewriteDescriptions,
564
576
  })
565
577
  const { executableCode, executableSourceMap, collisions, redeclared, rewrites } = convenience
566
578
  const commitTargets = new Set(moduleRewrite.commitTargets)
@@ -9,6 +9,18 @@ import {
9
9
  import { applySourceEdits, createMappedTextBuilder, identitySourceMap } from './source-position-map.js'
10
10
  import { SKIP_AST_CHILDREN, walkAst } from './ast-traversal.js'
11
11
 
12
+ /**
13
+ * Historical descriptions, used when a caller supplies no resolved document.
14
+ *
15
+ * They are presentation only: no recognizer compares a rewrite description, so a
16
+ * customized description changes what the model reads and nothing else.
17
+ */
18
+ const DEFAULT_DESCRIPTIONS = Object.freeze({
19
+ mixed: MIXED_REDECLARATION_REWRITE,
20
+ function: FUNCTION_REDECLARATION_REWRITE,
21
+ class: CLASS_REDECLARATION_REWRITE,
22
+ })
23
+
12
24
  /**
13
25
  * Optional compatibility policy for a persistent REPL. The cell language does
14
26
  * not own this policy: callers may replace it when persistence uses separate
@@ -129,6 +141,7 @@ export function rewriteReplRedeclarations({
129
141
  bindingPolicy,
130
142
  autoSplitRedeclarations,
131
143
  commitSignal,
144
+ rewriteDescriptions = DEFAULT_DESCRIPTIONS,
132
145
  }) {
133
146
  const redeclared = []
134
147
  const rewrites = []
@@ -184,8 +197,8 @@ export function rewriteReplRedeclarations({
184
197
  rewrites.push({
185
198
  kind: 'redeclaration',
186
199
  description: kind === 'class'
187
- ? CLASS_REDECLARATION_REWRITE
188
- : FUNCTION_REDECLARATION_REWRITE,
200
+ ? rewriteDescriptions.class
201
+ : rewriteDescriptions.function,
189
202
  source: statement.id.name,
190
203
  })
191
204
  }
@@ -262,7 +275,7 @@ export function rewriteReplRedeclarations({
262
275
  redeclared.push(...existing.map(collisionFor))
263
276
  rewrites.push({
264
277
  kind: 'redeclaration',
265
- description: MIXED_REDECLARATION_REWRITE,
278
+ description: rewriteDescriptions.mixed,
266
279
  source: existing.map(binding => binding.name).join(', '),
267
280
  })
268
281
  } else if (existing.length === bindings.length && bindings.length > 0) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Official persistent-REPL priority.
3
+ *
4
+ * The official `persistent-in-memory` execution mode is a live provider
5
+ * capability, not a release fact: a provider reports `programFormat` `'repl'`
6
+ * exactly when it retains a REPL per state owner. While the user selects the
7
+ * official REPL priority and that provider is live, the plugin yields PTC
8
+ * session execution to the official implementation and installs no session
9
+ * runtime: `run_code`, its descriptors and its guidance stay official. The
10
+ * decision is re-evaluated on every configuration change and host source
11
+ * change, so either side can take over without restarting the host.
12
+ */
13
+
14
+ /**
15
+ * Decide whether this configuration yields PTC session execution to the
16
+ * official provider.
17
+ * @param {object} config - resolved plugin configuration.
18
+ * @param {object} provider - the live provider surface registered for the seam.
19
+ * @returns {boolean} true when the official persistent REPL owns execution.
20
+ */
21
+ export function defersToOfficialRepl(config, provider) {
22
+ if (config?.officialReplPriority !== true) return false
23
+ try {
24
+ return provider?.programFormat === 'repl'
25
+ } catch {
26
+ // An unreadable capability descriptor cannot prove official ownership;
27
+ // the plugin keeps its execution responsibility instead of guessing.
28
+ return false
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Runtime record for an attachment that yields execution to the official
34
+ * provider. It owns no execution resources: the official provider releases
35
+ * anything it retained through its own lifecycle, and the plugin's settings,
36
+ * storage and UI owners remain available so the user can switch back.
37
+ */
38
+ export function createDeferredRuntime() {
39
+ return Object.freeze({
40
+ cancelCordisActivation() {
41
+ // No Cordis companion is installed while execution is deferred.
42
+ },
43
+ contextsForRequest: () => [],
44
+ async reconfigure() {
45
+ // This attachment has no owners to reconfigure.
46
+ },
47
+ async dispose() {
48
+ // Nothing is owned here; disposal returns immediately.
49
+ },
50
+ })
51
+ }