@lkyhfx/dsh-bb-repl 0.1.1 → 0.2.0

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.
@@ -70,11 +70,30 @@
70
70
  (sort-by :name)
71
71
  vec))
72
72
 
73
+ (def ^:private session-generation
74
+ "The interpreter generation the host assigned to this process. It counts the
75
+ (re)starts of this Agent's session, so an Agent can tell a reused interpreter
76
+ from a rebuilt one without seeing any nREPL session id or port."
77
+ (let [raw (System/getenv "DSH_BB_SESSION_GENERATION")]
78
+ (if (str/blank? raw) 0 (or (parse-long raw) 0))))
79
+
80
+ (defn session
81
+ "Model-safe identity of this interpreter: its generation and how much state it
82
+ can be assumed to hold. :defs is the number of agent vars currently bound."
83
+ []
84
+ (let [generation (long session-generation)]
85
+ ;; generation 0 means the host did not stamp this process: report that
86
+ ;; instead of claiming a live session we cannot identify.
87
+ {:generation generation
88
+ :state (if (pos? generation) :live :unmanaged)
89
+ :defs (count (vars))}))
90
+
73
91
  (defn status
74
- "One map with the workspace, the mounted domains, each domain's own counters,
75
- and how many definitions this session currently holds. Read-only."
92
+ "One map with the workspace, the session generation, the mounted domains, each
93
+ domain's own counters, and how many definitions this session holds. Read-only."
76
94
  []
77
95
  {:workspace (str workspace-root)
96
+ :session (session)
78
97
  :domains (into (sorted-map)
79
98
  (map (fn [[id d]] [id (if-let [f (:status d)] (f) {:version (:version d)})])
80
99
  @registry))
package/cordis.patch.yml CHANGED
@@ -16,6 +16,8 @@
16
16
  # timeoutMs 120000 per-eval timeout; a timeout resets the session
17
17
  # startupMs 12000 interpreter start budget
18
18
  # maxOutputChars 16000 output clipping budget
19
+ # alwaysShowSession false also report 'session: reused' (created/rebuilt are
20
+ # always reported)
19
21
  # sandbox inherit 'inherit' goes through ctx.sandbox under the
20
22
  # session's ctx.sandboxPolicy and fails closed when no
21
23
  # backend exists; 'off' runs unconfined (tests only)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lkyhfx/dsh-bb-repl",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "DSH Cordis plugin: owner-scoped persistent Babashka nREPL tool (bb_eval)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -16,7 +16,7 @@
16
16
  "dsh": {"bundle": {"patch": "./cordis.patch.yml"}},
17
17
  "dependencies": {
18
18
  "@deepseek-ai/schemastery": "*",
19
- "@lkyhfx/dsh-bb-domain-code": "^0.1.1"
19
+ "@lkyhfx/dsh-bb-domain-code": "^0.3.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@deepseek-ai/cordis": "*",
package/src/env.js CHANGED
@@ -24,12 +24,15 @@ export function scrubbedChildEnv(parent = process.env) {
24
24
  }
25
25
 
26
26
  /** Explicit, non-secret facts this runtime hands to its Babashka child. */
27
- export function runtimeChildEnv({ workspace, scratch, port }) {
27
+ export function runtimeChildEnv({ workspace, scratch, port, generation }) {
28
28
  return {
29
29
  ...scrubbedChildEnv(),
30
30
  DSH_BB_WORKSPACE_ROOT: workspace,
31
31
  DSH_BB_SCRATCH: scratch,
32
32
  DSH_BB_NREPL_PORT: String(port),
33
+ // Session generation this interpreter belongs to: runtime-side observability
34
+ // only. It is a counter, never an nREPL session id or a host secret.
35
+ DSH_BB_SESSION_GENERATION: String(generation ?? 1),
33
36
  PAGER: 'cat',
34
37
  GIT_PAGER: 'cat',
35
38
  NO_COLOR: '1',
package/src/index.js CHANGED
@@ -11,64 +11,126 @@ export const name = 'bb-repl'
11
11
  // array `inject` can only express required services.
12
12
  export const inject = ['tools', 'workingDirectory']
13
13
 
14
- const GUIDE = `Execute Clojure programs in a persistent Babashka REPL isolated to this Agent.
15
-
16
- PERSISTENCE:
17
- - Functions defined with defn and values defined with def persist across calls
18
- in the same Agent session.
19
- - let bindings are local to one expression; they do not survive a call.
20
- - Use defonce and atoms when mutable persistent state is actually required.
21
- - Inspect existing definitions before recreating them: (domain/vars).
22
- - Ordinary Clojure evaluation errors do NOT reset the REPL: a var defined
23
- before the error is still there, so fix the form and keep going.
24
- - Timeouts, cancellations, transport failures and incompatible domain updates
25
- DO reset the session. The call result says "session reset" or "session
26
- rebuilt" when that happened, and then nothing is left but domain state.
27
- - Inspect (domain/status) or (domain/vars) whenever state is uncertain.
28
-
29
- COMPOSITION:
30
- - Compose domain primitives with let, do, ->, ->>, map, filter, reduce and the
31
- rest of clojure.core.
32
- - Prefer one evaluation for a predictable multi-step task; return a compact
33
- final value, not bulk file content.
34
- - Reuse functions and intermediate results across calls instead of repeating
35
- searches and reads.
36
- - Use domain primitives for workspace file operations; they carry the guards.
37
-
38
- DISCOVERY:
39
- - (domain/status) -> workspace, mounted domains, their counters, def count.
40
- - (domain/help) or (domain/help :code) -> the domain API guides.
41
- - (domain/vars) -> agent-defined vars with arglists and docstrings.
42
- - (code/status) -> code-domain counters and workspace.
43
- - (meta #'code/search) -> signature and docstring of one primitive.
44
-
45
- STATE RESET:
46
- - (domain/reset! "why") deliberately discards this session's definitions and
47
- starts a fresh interpreter on the next call. Prefer it over working around
48
- known-bad state.
49
-
50
- SAFETY:
51
- - Clojure is dynamically typed; type errors surface at runtime.
52
- - Code runs with the configured runtime permissions. Never perform
53
- unauthorized destructive operations.`
14
+ const GUIDE = `Execute Clojure in a persistent Babashka REPL owned by this Agent.
15
+
16
+ ALREADY LOADED: \`domain\` (domain.runtime) and \`code\` (domain.code) are mounted
17
+ before your first call. Call them directly; a repeated (require '[domain.code :as
18
+ code]) is a no-op that only wastes a call. Require other built-ins when you need
19
+ them, once. Domain API details: the DOMAIN code section below, or
20
+ (domain/help :code) / (meta #'code/search).
21
+
22
+ PERSIST ACROSS CALLS -- small calls that build on each other:
23
+ call 1: (def hits (:matches (code/search "authenticate")))
24
+ call 2: (count hits)
25
+ call 3: (->> hits (map :path) distinct vec)
26
+ \`hits\` still exists in call 3; re-searching would repeat I/O for the same data.
27
+ def keeps a value worth reusing later (search results, file text, target paths).
28
+ defn keeps a reusable operation. let is for intermediates inside one form -- most
29
+ work belongs there; do not def scratch values you will never read again.
30
+ defonce + an atom is only for state you genuinely mutate across calls.
31
+
32
+ TWO MODES: EXPLORE while the target is unknown -- one small call, read it, decide.
33
+ EXECUTE once target and operations are known -- the whole batch in ONE call:
34
+ (->> (:matches (code/search "authenticate"))
35
+ (map :path) distinct (mapv #(code/inspect %)))
36
+ A known path needs no second global search; read it: (code/read "src/a.ts" {:start 40 :limit 60}).
37
+ Results stay structured: code/search returns a MAP -- take :matches once, keep it
38
+ in a var, map over it; (:truncated true) means :limit cut it, so widen :limit or
39
+ narrow :glob/:root instead of assuming you saw everything.
40
+
41
+ PERSISTENCE SEMANTICS: def/defn/defonce persist while the session is reused. A
42
+ result reports \`session: created|rebuilt|reset|quarantined, generation: N\`
43
+ whenever the interpreter changed or is not what you assume; a plain reused call
44
+ stays quiet. Same generation = your definitions are still there
45
+ ((domain/session) confirms). An ordinary Clojure error does NOT reset anything:
46
+ vars defined before it stay bound, so fix the form and continue. Timeout, abort,
47
+ transport failure or a domain change DO replace the interpreter; the result says
48
+ so with a resetReason plus \`definitions lost\`. \`quarantined\` means cleanup could
49
+ not be confirmed: the old interpreter may still hold your state.
50
+
51
+ STATE AND DISCOVERY: (domain/status) -> workspace, session, domains, counters,
52
+ defs. (domain/vars) -> your vars with arglists/docstrings; check before
53
+ redefining. (domain/session) -> generation and def count. (domain/reset! "why")
54
+ -> deliberately drop this session when its state is bad.
55
+
56
+ OUTPUT: return a compact final value -- counts, paths, small maps, one short
57
+ window. Never print a whole file or large collection; page with :start/:limit.
58
+
59
+ SAFETY: Clojure is dynamic, so type errors surface at runtime for the form that
60
+ ran. Workspace writes go through code/replace! and code/patch!, which carry the
61
+ guards; never edit workspace sources through raw fs/spit calls.`
54
62
 
55
63
  export const Config = z.object({
56
64
  bbExecutable: z.string().default('bb'),
57
65
  timeoutMs: z.number().default(120000),
58
66
  startupMs: z.number().default(12000),
59
67
  maxOutputChars: z.number().default(16000),
68
+ // A reused session is the normal case; created/rebuilt/reset are always shown.
69
+ alwaysShowSession: z.boolean().default(false),
60
70
  sandbox: z.union([z.const('inherit'), z.const('off')]).default('inherit'),
61
71
  description: z.string().default(GUIDE),
62
72
  })
63
73
 
64
74
  const clip = (s, n) => s.length <= n ? s : `${s.slice(0, n)}\n[Output truncated; narrow your query]`
65
75
 
76
+ /**
77
+ * Render a printed value for the model.
78
+ *
79
+ * A single Clojure string containing newlines arrives as one escaped line
80
+ * (`" 1 {\n 2 .."`), which is hard to read and easy to mis-edit. Only the
81
+ * *display* of a whole-string value is relaxed here: multi-line text is shown
82
+ * unescaped, so what the Agent sees equals what it would print. Data structures,
83
+ * numbers, keywords and string fragments inside collections keep the exact
84
+ * printed form, because there the quoting carries structure that must not change.
85
+ */
86
+ const renderValue = text => {
87
+ const trimmed = text.trim()
88
+ if (trimmed.length < 2 || trimmed[0] !== '"' || trimmed.at(-1) !== '"' || !/\\n/.test(trimmed)) {
89
+ return `value:\n${text}`
90
+ }
91
+ try {
92
+ const parsed = JSON.parse(trimmed)
93
+ if (typeof parsed !== 'string') return `value:\n${text}`
94
+ return `value: (one string, unescaped for display; the value itself is still a string)\n${parsed}`
95
+ } catch {
96
+ return `value:\n${text}` // not a plain JSON-compatible literal: keep it verbatim
97
+ }
98
+ }
99
+
100
+ /**
101
+ * One model-facing line about the interpreter that served the call.
102
+ *
103
+ * Only a generation counter and a state word are exposed: the nREPL session id,
104
+ * the loopback port and the scratch path stay host-side. A rebuilt session is
105
+ * always reported (that is when definitions disappear); a reused one is only
106
+ * reported when `always` is configured, to keep ordinary results small.
107
+ */
108
+ const sessionLine = (session, always = false) => {
109
+ if (!session) return null
110
+ const parts = [`session: ${session.sessionState}`, `generation: ${session.generation}`]
111
+ // Which interpreter ran this code is separate from whether it still exists.
112
+ if (session.interpreter && session.interpreter !== session.sessionState) {
113
+ parts.push(`interpreter: ${session.interpreter}`)
114
+ }
115
+ if (session.resetReason) parts.push(`resetReason: ${session.resetReason}`)
116
+ if (session.definitionsLost) parts.push('definitions lost: this interpreter is new; re-define what you need')
117
+ if (session.sessionState === 'reset') parts.push('the next call starts a fresh interpreter')
118
+ if (session.sessionState === 'quarantined') {
119
+ parts.push('cleanup was not confirmed: the old interpreter is still alive and still holds your definitions')
120
+ }
121
+ const line = parts.join(', ')
122
+ // Quiet only for the ordinary case: same interpreter, still alive and reused.
123
+ if (!always && session.sessionState === 'reused') return null
124
+ return line
125
+ }
126
+
66
127
  export function apply(ctx, config = {}) {
67
128
  const cfg = {
68
129
  bbExecutable: config.bbExecutable ?? 'bb',
69
130
  timeoutMs: config.timeoutMs ?? 120000,
70
131
  startupMs: config.startupMs ?? 12000,
71
132
  maxOutputChars: config.maxOutputChars ?? 16000,
133
+ alwaysShowSession: config.alwaysShowSession ?? false,
72
134
  sandbox: config.sandbox ?? 'inherit',
73
135
  description: config.description ?? GUIDE,
74
136
  }
@@ -160,19 +222,33 @@ export function apply(ctx, config = {}) {
160
222
  const result = await sessions.evaluate(exec.agent, workspace, args.code, exec.signal)
161
223
  const notes = result.notes ?? []
162
224
  const body = [
225
+ sessionLine(result.session, cfg.alwaysShowSession),
163
226
  notes.length ? `runtime:\n${notes.join('\n')}` : '',
164
227
  result.out ? `stdout:\n${result.out}` : '',
165
- result.value ? `value:\n${result.value}` : '',
228
+ result.value ? renderValue(result.value) : '',
166
229
  result.err ? `stderr:\n${result.err}` : '',
167
230
  result.exception ? `exception: ${result.exception}` : '',
168
- result.error ? `error: ${result.error.code}` : '',
231
+ // A failure keeps its machine-readable identity instead of turning
232
+ // into an empty success the Agent cannot branch on.
233
+ result.error
234
+ ? `error: ${result.error.code}${result.exception ? '' : ` (${result.error.message})`}`
235
+ : '',
169
236
  `status: ${result.ok ? 'ok' : 'error'}`,
170
237
  ].filter(Boolean).join('\n')
171
238
  return clip(body, cfg.maxOutputChars)
172
239
  } catch (error) {
173
240
  if (exec.signal.aborted) return '' // Host handles ABORTED.
241
+ // Never promise a reset that did not happen: when cleanup could not be
242
+ // confirmed the old interpreter still holds the Agent's state.
243
+ const line = sessionLine(error.session, true) ?? 'session: unknown'
174
244
  const notes = error.notes?.length ? ` (${error.notes.join('; ')})` : ''
175
- throw new Error(`Babashka runtime: ${error.message}${notes}. The session was reset; re-run with a fresh interpreter.`)
245
+ const fate = error.session?.sessionState === 'reset'
246
+ ? 'The previous interpreter is gone; the next call starts a fresh session.'
247
+ // generation 0: no interpreter was ever established, so nothing was lost.
248
+ : error.session?.generation === 0
249
+ ? 'No interpreter was running for this call; nothing was discarded.'
250
+ : 'Session cleanup was NOT confirmed: the previous interpreter and its state are still alive.'
251
+ throw new Error(`Babashka runtime: ${error.message}${notes}\n${line}\n${fate}`)
176
252
  }
177
253
  },
178
254
  presentCall: args => ({ card: 'terminal', title: args.code.slice(0, 160) }),
package/src/sessions.js CHANGED
@@ -94,6 +94,25 @@ async function terminateTree(proc, onLog) {
94
94
  throw new Error('Babashka process group could not be terminated; refusing to start a new session')
95
95
  }
96
96
  }
97
+ /**
98
+ * Survival verdict inputs after a cleanup attempt.
99
+ *
100
+ * The live map, not the promise, is the source of truth: a reset that reported
101
+ * success but left the session registered (quarantined) must never be described
102
+ * as gone, and a confirmed reset of an already-absent session is still a reset.
103
+ */
104
+ function finalState(live, owner, cleaned) {
105
+ return { resetRequested: true, cleanupConfirmed: Boolean(cleaned) && !live.has(owner) }
106
+ }
107
+
108
+ /** Machine-safe reset reason for an evaluation that failed: never free text. */
109
+ function abortReason(error, signal) {
110
+ if (signal?.aborted) return 'abort'
111
+ if (/timed out/i.test(error?.message ?? '')) return 'timeout'
112
+ if (error?.code === 'NREPL_OUTPUT_LIMIT') return 'output-limit'
113
+ return 'transport-failure'
114
+ }
115
+
97
116
  const q = value => JSON.stringify(value) // strings use Clojure-compatible JSON escaping
98
117
 
99
118
  /** Stable identity of a mounted domain implementation, used to detect hot swaps. */
@@ -142,6 +161,13 @@ export class DomainSessions {
142
161
  // Owners already wired to a lifecycle cleanup; a rebuilt session must not
143
162
  // register a second disposer for the same Agent.
144
163
  this.owned = new WeakSet()
164
+ // Per-owner interpreter generation, so the Agent can tell a live session
165
+ // from a freshly rebuilt one. Never holds anything the model must not see
166
+ // (no nREPL session id, no port, no scratch path).
167
+ // Weak, so a finished Agent cannot be kept alive by bookkeeping.
168
+ this.generations = new WeakMap()
169
+ // Why the next interpreter for this owner was (re)started; consumed by start().
170
+ this.resetReasons = new WeakMap()
145
171
  this.disposed = false
146
172
  }
147
173
 
@@ -165,6 +191,15 @@ export class DomainSessions {
165
191
  async start(owner, workspace, signal, policy) {
166
192
  if (this.disposed) throw new Error('Babashka runtime disposed')
167
193
  signal?.throwIfAborted()
194
+ // Generation identity is decided before the process exists, but only
195
+ // published once the interpreter actually started: a failed start must not
196
+ // claim a generation the Agent never got.
197
+ const previous = this.generations.get(owner)
198
+ const generation = (previous?.generation ?? 0) + 1
199
+ const sessionState = previous?.generation ? 'rebuilt' : 'created'
200
+ const resetReason = previous?.generation
201
+ ? (this.resetReasons.get(owner) ?? 'interpreter-exit')
202
+ : undefined
168
203
  const scratch = await mkdtemp(join(tmpdir(), 'dsh-bb-'))
169
204
  const port = await freeLocalPort()
170
205
  const manifests = this.modules()
@@ -179,7 +214,7 @@ export class DomainSessions {
179
214
  }
180
215
  const proc = spawn(launched.argv[0], launched.argv.slice(1), {
181
216
  cwd: scratch,
182
- env: runtimeChildEnv({ workspace: resolve(workspace), scratch, port }),
217
+ env: runtimeChildEnv({ workspace: resolve(workspace), scratch, port, generation }),
183
218
  stdio: ['ignore', 'pipe', 'pipe'],
184
219
  windowsHide: true,
185
220
  // Own process group, so termination reaches the JVM and every child it
@@ -232,6 +267,11 @@ export class DomainSessions {
232
267
  throw exitError ?? new Error('Babashka exited during session initialization')
233
268
  }
234
269
  this.live.set(owner, session)
270
+ session.generation = generation
271
+ session.sessionState = sessionState
272
+ session.resetReason = resetReason
273
+ this.generations.set(owner, { generation, state: sessionState, resetReason })
274
+ this.resetReasons.delete(owner)
235
275
  if (!this.owned.has(owner)) {
236
276
  this.owned.add(owner)
237
277
  owner.ctx?.effect?.(() => () => { void this.reset(owner).catch(error => this.onLog(String(error?.message ?? error))) }, 'dsh-bb owner session cleanup')
@@ -309,8 +349,16 @@ export class DomainSessions {
309
349
  || existing.proc.exitCode !== null || existing.proc.signalCode !== null
310
350
  || existing.workspace !== resolve(workspace)
311
351
  || existing.policyKey !== (policy?.key ?? 'unconfined')
312
- if (mustStop) await this.stopSession(owner, existing)
313
- else return existing
352
+ if (mustStop) {
353
+ // A workspace or grant change is not a timeout: name it so the rebuilt
354
+ // session explains why its definitions could not be kept.
355
+ const reason = existing.cleanupPromise || existing.proc.killed
356
+ || existing.proc.exitCode !== null || existing.proc.signalCode !== null
357
+ ? 'interpreter-exit'
358
+ : 'policy-change'
359
+ this.resetReasons.set(owner, reason)
360
+ await this.stopSession(owner, existing)
361
+ } else return existing
314
362
  }
315
363
  const pending = this.pending.get(owner)
316
364
  if (pending) return pending
@@ -368,12 +416,65 @@ export class DomainSessions {
368
416
  return /DSH_BB_RESET:/.test(text)
369
417
  }
370
418
 
419
+ /**
420
+ * Model-safe session identity for one owner, as of the END of a call.
421
+ *
422
+ * Two dimensions are kept apart on purpose, because a single word cannot say
423
+ * both "which interpreter ran this code" and "is there still an interpreter":
424
+ *
425
+ * - `sessionState` is the survival verdict and always wins. `reused` means the
426
+ * same interpreter is alive; `reset` means it is gone (dropped or destroyed);
427
+ * `quarantined` means a reset was requested and cleanup could NOT be
428
+ * confirmed, so the old process may still hold the Agent's state.
429
+ * - `interpreter` says whether the interpreter that ran this call was `created`,
430
+ * `rebuilt` or `reused` relative to `beforeGeneration`, so an Agent can tell
431
+ * a brand-new process from one it was already using.
432
+ *
433
+ * Deriving the verdict from the generation delta alone is wrong: `(domain/reset!)`
434
+ * as the very first call creates generation 1 and then destroys it, which must
435
+ * report `reset`, not `created`. Nothing identifying the live transport (nREPL
436
+ * session id, port, scratch path) is ever exposed.
437
+ */
438
+ sessionSnapshot(owner, beforeGeneration = 0, { resetRequested = false, cleanupConfirmed = false } = {}) {
439
+ const info = this.generations.get(owner)
440
+ const generation = info?.generation ?? 0
441
+ const live = this.live.has(owner)
442
+ const started = generation > beforeGeneration
443
+ const interpreter = !started ? 'reused' : beforeGeneration === 0 ? 'created' : 'rebuilt'
444
+ let sessionState = interpreter
445
+ if (resetRequested && cleanupConfirmed && !live) sessionState = 'reset'
446
+ // A reset whose cleanup could not be confirmed leaves the old interpreter
447
+ // (and its bindings) alive: say so instead of reporting a clean reset. The
448
+ // same verdict covers a reset that was never confirmed because it threw.
449
+ else if (resetRequested) sessionState = 'quarantined'
450
+ // No reset was attempted but nothing is live: the interpreter died on its own.
451
+ else if (!live) sessionState = 'reset'
452
+ // No interpreter has ever run for this owner: nothing was lost, so do not
453
+ // pretend a session exists or that definitions disappeared.
454
+ if (generation === 0) {
455
+ return { generation: 0, sessionState: 'unavailable', interpreter, definitionsLost: false }
456
+ }
457
+ return {
458
+ generation,
459
+ sessionState,
460
+ interpreter,
461
+ ...(info?.resetReason && sessionState !== 'reused' ? { resetReason: info.resetReason } : {}),
462
+ // Only a session that is REALLY gone lost its definitions, and only if it
463
+ // had any: a fresh interpreter created and dropped inside one call had
464
+ // nothing to lose. A quarantined or still-live interpreter keeps its
465
+ // state, so claiming loss would make the Agent redo work still in memory.
466
+ definitionsLost: sessionState === 'rebuilt'
467
+ || (sessionState === 'reset' && beforeGeneration > 0),
468
+ }
469
+ }
470
+
371
471
  async evaluate(owner, workspace, code, signal, explicitPolicy) {
372
472
  const prior = this.queues.get(owner) ?? Promise.resolve()
373
473
  const work = prior.then(async () => {
374
474
  if (signal?.aborted) throw signal.reason ?? new Error('Cancelled')
375
475
  const policy = explicitPolicy ?? this.policyFor?.(owner, workspace)
376
476
  const notes = []
477
+ const beforeGeneration = this.generations.get(owner)?.generation ?? 0
377
478
  const syncOnce = async session => this.bounded(
378
479
  () => this.syncModules(session), this.moduleSyncMs, 'Domain synchronization', signal)
379
480
  let session
@@ -383,7 +484,7 @@ export class DomainSessions {
383
484
  sync = await syncOnce(session)
384
485
  if (sync.reset) {
385
486
  notes.push(`session rebuilt: ${sync.reset}`)
386
- await this.reset(owner)
487
+ await this.reset(owner, 'domain-update')
387
488
  session = await this.ensure(owner, workspace, signal, policy)
388
489
  sync = await syncOnce(session)
389
490
  }
@@ -392,8 +493,7 @@ export class DomainSessions {
392
493
  // nREPL socket and killing the process group is the cancellation.
393
494
  let reset = false
394
495
  try {
395
- await this.reset(owner)
396
- reset = true
496
+ reset = await this.reset(owner, abortReason(error, signal))
397
497
  } catch (resetError) {
398
498
  error.message = `${error.message} (${resetError.message})`
399
499
  }
@@ -401,6 +501,12 @@ export class DomainSessions {
401
501
  ? 'session reset: domain synchronization failed'
402
502
  : 'session cleanup failed; owner quarantined')
403
503
  error.notes = notes
504
+ // Only a *confirmed* cleanup may claim a lost session; a quarantined
505
+ // owner still holds unknown state that the Agent must not assume gone.
506
+ error.session = {
507
+ ...this.sessionSnapshot(owner, beforeGeneration, finalState(this.live, owner, reset)),
508
+ sessionReset: reset,
509
+ }
404
510
  throw error
405
511
  }
406
512
  if (sync.added.length) notes.push(`domain hot-loaded: ${sync.added.join(', ')}`)
@@ -417,17 +523,36 @@ export class DomainSessions {
417
523
  }),
418
524
  ])
419
525
  if (!result.ok && DomainSessions.isResetRequest(result)) {
420
- await this.reset(owner)
421
- notes.push('session reset: requested by domain.runtime/reset!')
422
- return { ...result, notes, sessionReset: true }
526
+ let reset = false
527
+ try {
528
+ reset = await this.reset(owner, 'manual-reset')
529
+ } catch (resetError) {
530
+ notes.push(`session reset failed; owner quarantined: ${resetError.message}`)
531
+ }
532
+ if (reset) notes.push('session reset: requested by domain.runtime/reset!')
533
+ return {
534
+ ...result,
535
+ notes,
536
+ sessionReset: reset,
537
+ // This call may have created generation 1 and then dropped it, so the
538
+ // verdict comes from what is alive now, not from the generation delta.
539
+ session: {
540
+ ...this.sessionSnapshot(owner, beforeGeneration, finalState(this.live, owner, reset)),
541
+ sessionReset: reset,
542
+ },
543
+ }
544
+ }
545
+ return {
546
+ ...result,
547
+ notes: [...notes, ...(result.notes ?? [])],
548
+ sessionReset: false,
549
+ session: { ...this.sessionSnapshot(owner, beforeGeneration), sessionReset: false },
423
550
  }
424
- return { ...result, notes: [...notes, ...(result.notes ?? [])], sessionReset: false }
425
551
  } catch (error) {
426
552
  // Timeout, abort or any transport failure leaves unknown interpreter state.
427
553
  let reset = false
428
554
  try {
429
- await this.reset(owner)
430
- reset = true
555
+ reset = await this.reset(owner, abortReason(error, signal))
431
556
  } catch (resetError) {
432
557
  error.message = `${error.message} (${resetError.message})`
433
558
  }
@@ -435,6 +560,10 @@ export class DomainSessions {
435
560
  ? 'session reset: previous interpreter state discarded'
436
561
  : 'session cleanup failed; owner quarantined')
437
562
  error.notes = notes
563
+ error.session = {
564
+ ...this.sessionSnapshot(owner, beforeGeneration, finalState(this.live, owner, reset)),
565
+ sessionReset: reset,
566
+ }
438
567
  throw error
439
568
  } finally {
440
569
  clearTimeout(timeoutId)
@@ -455,9 +584,17 @@ export class DomainSessions {
455
584
  * next call start a fresh process: overlapping writers are the failure this
456
585
  * ordering exists to prevent.
457
586
  */
458
- async reset(owner) {
587
+ async reset(owner, reason) {
588
+ // Remember *why* the next interpreter starts before anything can fail, so a
589
+ // rebuild is never reported as an unexplained 'rebuilt'.
590
+ if (reason) this.resetReasons.set(owner, reason)
459
591
  const session = this.live.get(owner)
460
- if (!session) return
592
+ if (!session) {
593
+ // Nothing was running. That is a *satisfied* reset (the interpreter is
594
+ // confirmed gone), not a failure, so the caller can report it as such.
595
+ this.resetReasons.delete(owner)
596
+ return true
597
+ }
461
598
  try {
462
599
  await this.stopSession(owner, session)
463
600
  } catch (error) {
@@ -465,11 +602,12 @@ export class DomainSessions {
465
602
  this.onLog(String(error?.message ?? error))
466
603
  throw error
467
604
  }
605
+ return true
468
606
  }
469
607
 
470
608
  async close() {
471
609
  this.disposed = true
472
610
  await Promise.allSettled([...this.creating])
473
- await Promise.allSettled([...new Set([...this.live.keys()])].map(owner => this.reset(owner)))
611
+ await Promise.allSettled([...new Set([...this.live.keys()])].map(owner => this.reset(owner, 'shutdown')))
474
612
  }
475
613
  }