@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.
- package/bb-src/domain/runtime.clj +21 -2
- package/cordis.patch.yml +2 -0
- package/package.json +2 -2
- package/src/env.js +4 -1
- package/src/index.js +119 -43
- package/src/sessions.js +153 -15
|
@@ -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
|
|
75
|
-
and how many definitions this session
|
|
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.
|
|
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.
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 ?
|
|
228
|
+
result.value ? renderValue(result.value) : '',
|
|
166
229
|
result.err ? `stderr:\n${result.err}` : '',
|
|
167
230
|
result.exception ? `exception: ${result.exception}` : '',
|
|
168
|
-
|
|
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
|
-
|
|
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)
|
|
313
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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)
|
|
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
|
}
|