@code-yeongyu/senpi-codemode 2026.10.4 → 2026.10.6
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/CHANGELOG.md +37 -0
- package/README.md +27 -2
- package/package.json +4 -4
- package/src/bridge/kernel-tools-protocol.ts +1 -1
- package/src/bridge/protocol.ts +11 -0
- package/src/config/feature-settings.ts +110 -0
- package/src/config/memory-settings.ts +8 -0
- package/src/config/settings-constants.ts +7 -0
- package/src/config/settings-overrides.ts +72 -0
- package/src/config/settings.ts +35 -78
- package/src/config/unknown-settings.ts +19 -0
- package/src/extension/bridge-tool-call.ts +37 -0
- package/src/extension/idle-parking-kernel.ts +272 -0
- package/src/extension/kernel-registration.ts +25 -0
- package/src/extension/kernel-replacement.ts +230 -0
- package/src/extension/kernel-tools-probe.ts +24 -0
- package/src/extension/session-manager.ts +49 -38
- package/src/kernels/jl/kernel.ts +6 -2
- package/src/kernels/jl/runner.jl +154 -0
- package/src/kernels/js/context-manager.ts +1 -1
- package/src/kernels/js/kernel-tools-types.ts +2 -1
- package/src/kernels/js/worker-core.js +1 -0
- package/src/kernels/js/worker-global-sizes.js +84 -14
- package/src/kernels/py/kernel-contract.ts +2 -1
- package/src/kernels/py/kernel.ts +48 -8
- package/src/kernels/py/prelude.py +6 -1
- package/src/kernels/py/transport.ts +25 -4
- package/src/kernels/rb/kernel.ts +6 -2
- package/src/kernels/rb/runner.rb +141 -0
- package/src/kernels/session-env.ts +3 -0
- package/src/kernels/shared/kernel-death.ts +39 -0
- package/src/kernels/shared/kernel-memory-host.ts +14 -6
- package/src/kernels/{js/kernel-tools-host.ts → shared/kernel-tools-pump.ts} +2 -2
- package/src/kernels/shared/process-group-cpu.ts +139 -0
- package/src/kernels/shared/subprocess-contract.ts +5 -1
- package/src/kernels/shared/subprocess-kernel.ts +66 -3
- package/src/kernels/shared/subprocess-queue.ts +5 -0
- package/src/kernels/shared/subprocess-startup.ts +90 -0
- package/src/tool/cell-runtime.ts +17 -4
- package/src/tool/detached-cell-notification.ts +13 -2
- package/src/tool/run-eval-cell.ts +5 -6
- package/src/tool/types.ts +14 -0
package/src/kernels/py/kernel.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// allow: SIZE_OK — one persistent Python lifecycle state machine owns queue, generation, and retirement.
|
|
2
|
-
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
2
|
+
import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
|
|
3
3
|
import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
|
|
4
4
|
import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
|
|
5
|
+
import { describeExit } from "../shared/kernel-death.ts";
|
|
5
6
|
import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
|
|
6
7
|
import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
|
|
7
8
|
import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
|
|
@@ -27,6 +28,7 @@ export class PythonKernel {
|
|
|
27
28
|
readonly #memory: KernelMemoryHost | null;
|
|
28
29
|
#generation = 0;
|
|
29
30
|
#closed = false;
|
|
31
|
+
#dead = false;
|
|
30
32
|
|
|
31
33
|
private constructor(options: PythonKernelStartOptions) {
|
|
32
34
|
this.#options = options;
|
|
@@ -43,6 +45,18 @@ export class PythonKernel {
|
|
|
43
45
|
return [];
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
isAlive(): boolean {
|
|
49
|
+
return !this.#dead;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
drainPending(): readonly PendingCell[] {
|
|
53
|
+
return [...this.#queue].map((pending) => {
|
|
54
|
+
this.#pending.delete(pending.input.cellId);
|
|
55
|
+
this.#removePending(pending);
|
|
56
|
+
return { input: pending.input, settle: pending.resolve };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
describeKernelTools(_names: readonly string[]): Promise<never> {
|
|
47
61
|
return rejectKernelToolsUnavailable();
|
|
48
62
|
}
|
|
@@ -54,6 +68,7 @@ export class PythonKernel {
|
|
|
54
68
|
run(input: PythonKernelRunOptions): Promise<ResultMessage> {
|
|
55
69
|
if (this.#failure) return Promise.reject(this.#failure);
|
|
56
70
|
if (this.#closed) return Promise.reject(new Error("Python kernel is closed"));
|
|
71
|
+
if (this.#dead) return Promise.reject(new Error("Python kernel died"));
|
|
57
72
|
return new Promise<ResultMessage>((resolve, reject) => {
|
|
58
73
|
const pending: PendingRun = { input, resolve, reject, startedAt: null, timeoutTimer: null };
|
|
59
74
|
this.#pending.set(input.cellId, pending);
|
|
@@ -156,6 +171,7 @@ export class PythonKernel {
|
|
|
156
171
|
#startNext(): void {
|
|
157
172
|
if (
|
|
158
173
|
this.#closed ||
|
|
174
|
+
this.#dead ||
|
|
159
175
|
this.#failure ||
|
|
160
176
|
this.#active ||
|
|
161
177
|
this.#starting ||
|
|
@@ -234,11 +250,11 @@ export class PythonKernel {
|
|
|
234
250
|
isOwned: () => !this.#closed && generation === this.#generation,
|
|
235
251
|
onRetirementFailure: (transport, error) => {
|
|
236
252
|
if (!this.#transport) this.#transport = transport;
|
|
237
|
-
this.#recordFailure(error);
|
|
253
|
+
this.#recoverWhenGone(transport, this.#recordFailure(error));
|
|
238
254
|
},
|
|
239
255
|
onResult: (transport, result) => this.#onResult(transport, result),
|
|
240
256
|
onError: (transport, error) => this.#onError(transport, error),
|
|
241
|
-
onExit: (transport, error) => this.#onExit(transport, error),
|
|
257
|
+
onExit: (transport, error, exit) => this.#onExit(transport, error, describeExit(exit.code, exit.signal)),
|
|
242
258
|
});
|
|
243
259
|
}
|
|
244
260
|
|
|
@@ -262,15 +278,37 @@ export class PythonKernel {
|
|
|
262
278
|
void this.reset().catch(() => undefined);
|
|
263
279
|
}
|
|
264
280
|
|
|
265
|
-
#onExit(transport: PythonKernelTransport, error: Error): void {
|
|
281
|
+
#onExit(transport: PythonKernelTransport, error: Error, reason: string): void {
|
|
266
282
|
if (this.#transport !== transport) return;
|
|
267
283
|
this.#transport = null;
|
|
284
|
+
// With an owner listening, a death is final for this instance: the cells that never started stay
|
|
285
|
+
// queued for `drainPending` and the owner replaces it. Without one it respawns lazily, as before.
|
|
286
|
+
const onDeath = this.#options.onDeath;
|
|
287
|
+
if (onDeath) this.#dead = true;
|
|
268
288
|
const active = this.#active;
|
|
269
289
|
if (active) {
|
|
270
290
|
if (active.resolveStateRetained) active.resolveStateRetained(false);
|
|
271
|
-
|
|
291
|
+
const message = onDeath ? "Python kernel died; every global is lost" : "Python kernel died";
|
|
292
|
+
this.#settleRun(active, failedPythonResult(active.input.cellId, message, error.message));
|
|
272
293
|
}
|
|
273
|
-
|
|
294
|
+
if (onDeath) onDeath(reason);
|
|
295
|
+
else this.#startNext();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A retirement that timed out is reported, and no second interpreter starts while the first may live.
|
|
300
|
+
* Once that process is seen to exit after all, the failure is over: this instance is dead, not broken.
|
|
301
|
+
*/
|
|
302
|
+
#recoverWhenGone(transport: PythonKernelTransport, failure: Error): void {
|
|
303
|
+
const onDeath = this.#options.onDeath;
|
|
304
|
+
if (!onDeath) return;
|
|
305
|
+
void transport.whenGone().then(() => {
|
|
306
|
+
if (this.#closed || this.#failure !== failure) return;
|
|
307
|
+
this.#failure = null;
|
|
308
|
+
this.#dead = true;
|
|
309
|
+
if (this.#transport === transport) this.#transport = null;
|
|
310
|
+
onDeath("an interpreter that outlived its SIGKILL grace");
|
|
311
|
+
});
|
|
274
312
|
}
|
|
275
313
|
|
|
276
314
|
#onError(transport: PythonKernelTransport, error: Error): void {
|
|
@@ -349,8 +387,10 @@ export class PythonKernel {
|
|
|
349
387
|
try {
|
|
350
388
|
await transport.retire();
|
|
351
389
|
} catch (error) {
|
|
352
|
-
if (error instanceof Error) throw
|
|
353
|
-
|
|
390
|
+
if (!(error instanceof Error)) throw error;
|
|
391
|
+
const failure = this.#recordFailure(error);
|
|
392
|
+
this.#recoverWhenGone(transport, failure);
|
|
393
|
+
throw failure;
|
|
354
394
|
}
|
|
355
395
|
if (this.#transport === transport) this.#transport = null;
|
|
356
396
|
})();
|
|
@@ -354,6 +354,11 @@ def write(path: str | Path, content: str) -> Path:
|
|
|
354
354
|
return target
|
|
355
355
|
|
|
356
356
|
|
|
357
|
+
# The bridge is a loopback call: urllib's default opener would route it through a configured proxy
|
|
358
|
+
# (the environment everywhere, the registry on Windows), which a 127.0.0.1 request must never use.
|
|
359
|
+
_BRIDGE_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
360
|
+
|
|
361
|
+
|
|
357
362
|
def bridge_post(path: str, payload: dict[str, Any]) -> Any:
|
|
358
363
|
port = CONNECTION.get("port")
|
|
359
364
|
token = CONNECTION.get("token")
|
|
@@ -369,7 +374,7 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
|
|
|
369
374
|
emit_status(TIMEOUT_PAUSE_OP, force=True)
|
|
370
375
|
try:
|
|
371
376
|
try:
|
|
372
|
-
with
|
|
377
|
+
with _BRIDGE_OPENER.open(request, timeout=60) as response:
|
|
373
378
|
response_data = response.read()
|
|
374
379
|
except urllib.error.HTTPError as exc:
|
|
375
380
|
response_data = exc.read()
|
|
@@ -52,7 +52,11 @@ export interface PythonTransportOptions {
|
|
|
52
52
|
readonly onRetirementFailure: (transport: PythonKernelTransport, error: Error) => void;
|
|
53
53
|
readonly onResult: (transport: PythonKernelTransport, result: PythonTransportResult) => void;
|
|
54
54
|
readonly onError: (transport: PythonKernelTransport, error: Error) => void;
|
|
55
|
-
readonly onExit: (
|
|
55
|
+
readonly onExit: (
|
|
56
|
+
transport: PythonKernelTransport,
|
|
57
|
+
error: Error,
|
|
58
|
+
exit: { readonly code: number | null; readonly signal: string | null },
|
|
59
|
+
) => void;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
const hardKillWaitMs = 500;
|
|
@@ -79,12 +83,29 @@ export class PythonKernelTransport {
|
|
|
79
83
|
#active = true;
|
|
80
84
|
#exited = false;
|
|
81
85
|
#retirement: Promise<void> | null = null;
|
|
86
|
+
#gone: Promise<void> | null = null;
|
|
87
|
+
#isGone = false;
|
|
82
88
|
|
|
83
89
|
private constructor(options: PythonTransportOptions, child: KernelChild) {
|
|
84
90
|
this.#options = options;
|
|
85
91
|
this.#child = child;
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
/**
|
|
95
|
+
* A retirement that timed out is confirmed only by this: the process really exited after all. The
|
|
96
|
+
* watch is attached on demand, so a transport nobody asks this of leaves no listener on its child.
|
|
97
|
+
*/
|
|
98
|
+
whenGone(): Promise<void> {
|
|
99
|
+
if (this.#exited || this.#isGone) return Promise.resolve();
|
|
100
|
+
this.#gone ??= new Promise<void>((resolve) => {
|
|
101
|
+
this.#child.once("exit", () => {
|
|
102
|
+
this.#isGone = true;
|
|
103
|
+
resolve();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
return this.#gone;
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
static async start(options: PythonTransportOptions): Promise<PythonKernelTransport> {
|
|
89
110
|
const scriptPath = resolvePythonPreludePath();
|
|
90
111
|
const invocation = splitCommand(options.interpreterPath);
|
|
@@ -139,7 +160,7 @@ export class PythonKernelTransport {
|
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
async close(): Promise<void> {
|
|
142
|
-
if (this.#exited) return;
|
|
163
|
+
if (this.#exited || this.#isGone) return;
|
|
143
164
|
if (this.#retirement) {
|
|
144
165
|
await this.#retirement;
|
|
145
166
|
return;
|
|
@@ -162,7 +183,7 @@ export class PythonKernelTransport {
|
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
retire(): Promise<void> {
|
|
165
|
-
if (this.#exited) return Promise.resolve();
|
|
186
|
+
if (this.#exited || this.#isGone) return Promise.resolve();
|
|
166
187
|
if (this.#retirement) return this.#retirement;
|
|
167
188
|
this.#active = false;
|
|
168
189
|
const retirement = hardKill(this.#child, hardKillWaitMs).finally(() => {
|
|
@@ -244,7 +265,7 @@ export class PythonKernelTransport {
|
|
|
244
265
|
const error = new Error(this.#stderrTail.trim() || `Python kernel exited (${code ?? signal ?? "unknown"})`);
|
|
245
266
|
this.#detachListeners();
|
|
246
267
|
if (!active) return;
|
|
247
|
-
if (!this.#settleStartup(error)) this.#options.onExit(this, error);
|
|
268
|
+
if (!this.#settleStartup(error)) this.#options.onExit(this, error, { code, signal });
|
|
248
269
|
}
|
|
249
270
|
|
|
250
271
|
#onError(error: Error): void {
|
package/src/kernels/rb/kernel.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
4
|
+
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
5
|
+
import { readProcessGroupCpuTime } from "../shared/process-group-cpu.ts";
|
|
4
6
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
5
7
|
import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
6
8
|
|
|
7
|
-
export interface RubyKernelStartOptions {
|
|
9
|
+
export interface RubyKernelStartOptions extends KernelLifecycle {
|
|
8
10
|
readonly cwd: string;
|
|
9
11
|
readonly sessionId: string;
|
|
10
12
|
readonly connection: BridgeConnectionConfig;
|
|
@@ -13,7 +15,7 @@ export interface RubyKernelStartOptions {
|
|
|
13
15
|
readonly command?: string;
|
|
14
16
|
readonly spawn?: SubprocessSpawn;
|
|
15
17
|
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
16
|
-
/**
|
|
18
|
+
/** Memory management: the runner reports its largest globals and the host reads the interpreter footprint for the ceiling. */
|
|
17
19
|
readonly memory?: SubprocessKernelMemory;
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -41,6 +43,8 @@ export class RubyKernel extends SubprocessKernel {
|
|
|
41
43
|
spawn: options.spawn,
|
|
42
44
|
onMessage: options.onMessage,
|
|
43
45
|
memory: options.memory && { language: "rb", ...options.memory },
|
|
46
|
+
onDeath: options.onDeath,
|
|
47
|
+
startup: { label: "Ruby", readGroupCpuTime: readProcessGroupCpuTime },
|
|
44
48
|
});
|
|
45
49
|
}
|
|
46
50
|
}
|
package/src/kernels/rb/runner.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
$stdout.write("{\"type\":\"status\",\"event\":{\"op\":\"kernel-startup\",\"stage\":\"stdlib-imports\"}}\n")
|
|
2
|
+
$stdout.flush
|
|
1
3
|
require "json"
|
|
2
4
|
require "net/http"
|
|
3
5
|
require "uri"
|
|
6
|
+
$stdout.write("{\"type\":\"status\",\"event\":{\"op\":\"kernel-startup\",\"stage\":\"runtime-init\"}}\n")
|
|
7
|
+
$stdout.flush
|
|
4
8
|
|
|
5
9
|
$__senpi_binding = TOPLEVEL_BINDING
|
|
6
10
|
$__senpi_frame_mutex = Mutex.new
|
|
@@ -119,6 +123,135 @@ rescue JSON::GeneratorError
|
|
|
119
123
|
value.inspect
|
|
120
124
|
end
|
|
121
125
|
|
|
126
|
+
$__senpi_memory = nil
|
|
127
|
+
|
|
128
|
+
SENPI_SIZER_SAMPLE = 1_000
|
|
129
|
+
SENPI_SIZER_NODE_BUDGET = 5_000
|
|
130
|
+
SENPI_SIZER_MAX_DEPTH = 64
|
|
131
|
+
SENPI_SIZER_POINTER = 8
|
|
132
|
+
SENPI_SIZER_OBJECT = 40
|
|
133
|
+
SENPI_SIZER_MIN_REPORTED = 1024 * 1024
|
|
134
|
+
SENPI_ARRAY_AT = Array.instance_method(:[])
|
|
135
|
+
SENPI_ARRAY_SIZE = Array.instance_method(:size)
|
|
136
|
+
SENPI_HASH_SIZE = Hash.instance_method(:size)
|
|
137
|
+
SENPI_HASH_EACH = Hash.instance_method(:each_pair)
|
|
138
|
+
SENPI_STRING_BYTESIZE = String.instance_method(:bytesize)
|
|
139
|
+
SENPI_IVARS = Kernel.instance_method(:instance_variables)
|
|
140
|
+
SENPI_IVAR_GET = Kernel.instance_method(:instance_variable_get)
|
|
141
|
+
|
|
142
|
+
# Sizes a global without running user code: built-ins are read through their own unbound methods, so an
|
|
143
|
+
# override in a subclass is never called. Collections are sampled, and each global gets its own node
|
|
144
|
+
# budget (leaves count against it too), so one deep global never hides the ones measured after it.
|
|
145
|
+
class SenpiGlobalSizer
|
|
146
|
+
attr_reader :approximate
|
|
147
|
+
|
|
148
|
+
def initialize
|
|
149
|
+
@seen = {}.compare_by_identity
|
|
150
|
+
@nodes = 0
|
|
151
|
+
@approximate = false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def measure(value)
|
|
155
|
+
@approximate = false
|
|
156
|
+
@nodes = 0
|
|
157
|
+
SENPI_SIZER_POINTER + size(value, 0)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def size(value, depth)
|
|
163
|
+
@nodes += 1
|
|
164
|
+
return 0 if NilClass === value || TrueClass === value || FalseClass === value || Symbol === value
|
|
165
|
+
return ObjectSpace.memsize_of(value) if Integer === value || Float === value
|
|
166
|
+
return 0 if @seen.key?(value)
|
|
167
|
+
@seen[value] = true
|
|
168
|
+
return SENPI_SIZER_OBJECT + SENPI_STRING_BYTESIZE.bind_call(value) if String === value
|
|
169
|
+
if depth >= SENPI_SIZER_MAX_DEPTH || @nodes >= SENPI_SIZER_NODE_BUDGET
|
|
170
|
+
@approximate = true
|
|
171
|
+
return ObjectSpace.memsize_of(value)
|
|
172
|
+
end
|
|
173
|
+
if Array === value
|
|
174
|
+
length = SENPI_ARRAY_SIZE.bind_call(value)
|
|
175
|
+
SENPI_SIZER_OBJECT + length * SENPI_SIZER_POINTER + sampled(length, depth) { |index| SENPI_ARRAY_AT.bind_call(value, index) }
|
|
176
|
+
elsif Hash === value
|
|
177
|
+
hash_size(value, depth)
|
|
178
|
+
else
|
|
179
|
+
ivars = SENPI_IVARS.bind_call(value)
|
|
180
|
+
ObjectSpace.memsize_of(value) + sampled(ivars.length, depth) { |index| SENPI_IVAR_GET.bind_call(value, ivars[index]) }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def hash_size(hash, depth)
|
|
185
|
+
count = SENPI_HASH_SIZE.bind_call(hash)
|
|
186
|
+
taken = 0
|
|
187
|
+
total = 0
|
|
188
|
+
SENPI_HASH_EACH.bind_call(hash) do |key, item|
|
|
189
|
+
total += size(key, depth + 1) + size(item, depth + 1)
|
|
190
|
+
taken += 1
|
|
191
|
+
break if taken >= SENPI_SIZER_SAMPLE
|
|
192
|
+
end
|
|
193
|
+
@approximate = true if taken < count
|
|
194
|
+
SENPI_SIZER_OBJECT + count * 2 * SENPI_SIZER_POINTER + (taken.zero? ? 0 : total * count / taken)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Up to SENPI_SIZER_SAMPLE evenly spaced elements; when the walk budget runs out part-way, the elements
|
|
198
|
+
# measured so far stand in for the rest, so a cut-short container is scaled up, not under-counted.
|
|
199
|
+
def sampled(length, depth)
|
|
200
|
+
return 0 if length.zero?
|
|
201
|
+
picks = [length, SENPI_SIZER_SAMPLE].min
|
|
202
|
+
@approximate = true if picks < length
|
|
203
|
+
step = length.to_f / picks
|
|
204
|
+
total = 0
|
|
205
|
+
measured = 0
|
|
206
|
+
picks.times do |sample|
|
|
207
|
+
if @nodes >= SENPI_SIZER_NODE_BUDGET
|
|
208
|
+
@approximate = true
|
|
209
|
+
break
|
|
210
|
+
end
|
|
211
|
+
total += size(yield((sample * step).floor), depth + 1)
|
|
212
|
+
measured += 1
|
|
213
|
+
end
|
|
214
|
+
measured.zero? ? 0 : (total.to_f / measured * length).round
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def __senpi_largest_globals(limit)
|
|
219
|
+
require "objspace"
|
|
220
|
+
sizer = SenpiGlobalSizer.new
|
|
221
|
+
sized = []
|
|
222
|
+
measure = lambda do |name, value|
|
|
223
|
+
bytes = sizer.measure(value)
|
|
224
|
+
next if bytes < SENPI_SIZER_MIN_REPORTED
|
|
225
|
+
entry = { "name" => name, "bytes" => bytes }
|
|
226
|
+
entry["approximate"] = true if sizer.approximate
|
|
227
|
+
sized << entry
|
|
228
|
+
end
|
|
229
|
+
$__senpi_binding.local_variables.each do |name|
|
|
230
|
+
next if $__senpi_memory_baseline_locals.include?(name)
|
|
231
|
+
measure.call(name.to_s, $__senpi_binding.local_variable_get(name))
|
|
232
|
+
end
|
|
233
|
+
global_variables.each do |name|
|
|
234
|
+
next if $__senpi_memory_baseline_globals.include?(name) || name.to_s.start_with?("$__senpi_")
|
|
235
|
+
measure.call(name.to_s, eval(name.to_s))
|
|
236
|
+
end
|
|
237
|
+
sized.sort_by { |entry| -entry["bytes"] }.first(limit)
|
|
238
|
+
rescue StandardError
|
|
239
|
+
[]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The host reads the interpreter's footprint and decides on the notice; the runner only names its
|
|
243
|
+
# largest globals. Nothing here spawns a process, so the user's `$?` is left alone.
|
|
244
|
+
def __senpi_memory_report
|
|
245
|
+
return nil if $__senpi_memory.nil?
|
|
246
|
+
named = __senpi_largest_globals(5)
|
|
247
|
+
# The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
|
|
248
|
+
report = { "liveBytes" => 0, "measure" => "footprint" }
|
|
249
|
+
report["globals"] = named unless named.empty?
|
|
250
|
+
report
|
|
251
|
+
rescue StandardError
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
122
255
|
SENPI_NON_DISPLAY_NODES = %i[
|
|
123
256
|
LASGN IASGN GASGN CVASGN DASGN OP_ASGN OP_CDECL CDECL MASGN CASGN
|
|
124
257
|
DEFN DEFS CLASS MODULE SCLASS ALIAS UNDEF
|
|
@@ -172,6 +305,8 @@ def __senpi_run_cell(message)
|
|
|
172
305
|
"durationMs" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
173
306
|
}
|
|
174
307
|
frame["valueRepr"] = __senpi_value_repr(value) if !value.nil? && __senpi_should_display_result?(source)
|
|
308
|
+
memory = __senpi_memory_report
|
|
309
|
+
frame["memory"] = memory unless memory.nil?
|
|
175
310
|
__senpi_emit(frame)
|
|
176
311
|
rescue Exception => error
|
|
177
312
|
__senpi_emit({
|
|
@@ -187,11 +322,17 @@ def __senpi_run_cell(message)
|
|
|
187
322
|
end
|
|
188
323
|
end
|
|
189
324
|
|
|
325
|
+
# Everything defined so far belongs to the runner or the interpreter, never to the user's cells.
|
|
326
|
+
$__senpi_memory_baseline_locals = $__senpi_binding.local_variables
|
|
327
|
+
$__senpi_memory_baseline_globals = global_variables
|
|
328
|
+
|
|
190
329
|
$__senpi_protocol_stdin.each_line do |line|
|
|
191
330
|
message = JSON.parse(line)
|
|
192
331
|
case message["type"]
|
|
193
332
|
when "init"
|
|
333
|
+
__senpi_emit({ "type" => "status", "event" => { "op" => "kernel-startup", "stage" => "host-init" } })
|
|
194
334
|
$__senpi_connection = message["connection"]
|
|
335
|
+
$__senpi_memory = message["memory"]
|
|
195
336
|
__senpi_emit({ "type" => "ready" })
|
|
196
337
|
when "run"
|
|
197
338
|
__senpi_run_cell(message)
|
|
@@ -17,6 +17,7 @@ export const SESSION_ENVIRONMENT_KEYS = [
|
|
|
17
17
|
"PI_PROVIDER",
|
|
18
18
|
"PI_MODEL",
|
|
19
19
|
"PI_REASONING_LEVEL",
|
|
20
|
+
"OMO_BROWSER_ENGINE",
|
|
20
21
|
] as const;
|
|
21
22
|
|
|
22
23
|
/** Resolved per-session values for {@link SESSION_ENVIRONMENT_KEYS}; absent keys stay unset. */
|
|
@@ -32,6 +33,7 @@ export interface SessionEnvironmentSource {
|
|
|
32
33
|
};
|
|
33
34
|
readonly model?: { readonly provider: string; readonly id: string } | undefined;
|
|
34
35
|
readonly thinkingLevel?: string | undefined;
|
|
36
|
+
readonly browserEngine?: string | undefined;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export function sessionEnvironmentFrom(source: SessionEnvironmentSource): SessionEnvironment {
|
|
@@ -47,6 +49,7 @@ export function sessionEnvironmentFrom(source: SessionEnvironmentSource): Sessio
|
|
|
47
49
|
env.PI_MODEL = model.id;
|
|
48
50
|
}
|
|
49
51
|
if (source.thinkingLevel) env.PI_REASONING_LEVEL = source.thinkingLevel;
|
|
52
|
+
if (source.browserEngine) env.OMO_BROWSER_ENGINE = source.browserEngine;
|
|
50
53
|
return env;
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { constants } from "node:os";
|
|
2
|
+
import type { EvalKernelResult, EvalLanguage } from "../../tool/types.ts";
|
|
3
|
+
|
|
4
|
+
/** How the session manager hears that a kernel's interpreter died. */
|
|
5
|
+
export interface KernelLifecycle {
|
|
6
|
+
/** Called once when the interpreter died on its own; the kernel then keeps its unstarted cells for `drainPending`. */
|
|
7
|
+
readonly onDeath?: (reason: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The wording a restart notice uses for an exit nobody asked for: "signal 9", "exit code 1". */
|
|
11
|
+
export function describeExit(code: number | null, signal: string | null): string {
|
|
12
|
+
if (signal !== null) {
|
|
13
|
+
const number = Object.entries(constants.signals).find(([name]) => name === signal)?.[1];
|
|
14
|
+
return `signal ${number ?? signal}`;
|
|
15
|
+
}
|
|
16
|
+
return code === null ? "an exit with no status" : `exit code ${code}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function restartNotice(language: EvalLanguage, reason: string): string {
|
|
20
|
+
return `[${language} kernel was restarted after ${reason}; every global is lost]`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const EVAL_KERNEL_UNAVAILABLE = "eval_kernel_unavailable";
|
|
24
|
+
|
|
25
|
+
/** A queued cell that never ran: its kernel died again before the replacement finished one cell. */
|
|
26
|
+
export class KernelUnavailableError extends Error {
|
|
27
|
+
readonly name = "KernelUnavailableError";
|
|
28
|
+
readonly code = EVAL_KERNEL_UNAVAILABLE;
|
|
29
|
+
|
|
30
|
+
constructor(language: EvalLanguage, reason: string) {
|
|
31
|
+
super(
|
|
32
|
+
`${EVAL_KERNEL_UNAVAILABLE}: the ${language} kernel died again (${reason}) before its replacement finished a cell; this cell never ran. Run it again to start a fresh kernel.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function unstartedResult(cellId: string, message: string): EvalKernelResult {
|
|
38
|
+
return { type: "result", cellId, ok: false, error: { message }, durationMs: 0, kernelState: "not-run" };
|
|
39
|
+
}
|
|
@@ -10,8 +10,9 @@ export type FootprintReader = (pid: number) => { readonly bytes: number } | unde
|
|
|
10
10
|
|
|
11
11
|
export interface KernelMemoryHostOptions {
|
|
12
12
|
/**
|
|
13
|
-
* Host-measured kernel (rb, jl): the
|
|
14
|
-
*
|
|
13
|
+
* Host-measured kernel (rb, jl): the host reads the interpreter's footprint after each result and
|
|
14
|
+
* replaces the runner's live reading with it; the runner still names its largest globals, so the
|
|
15
|
+
* notice and the ceiling both apply, with every reading counting (no collection precedes it).
|
|
15
16
|
*/
|
|
16
17
|
readonly readFootprint?: FootprintReader;
|
|
17
18
|
}
|
|
@@ -31,12 +32,15 @@ export class KernelMemoryHost {
|
|
|
31
32
|
this.#policy =
|
|
32
33
|
this.#readFootprint === undefined
|
|
33
34
|
? new KernelMemoryPolicy(language, thresholds)
|
|
34
|
-
: new KernelMemoryPolicy(language,
|
|
35
|
+
: new KernelMemoryPolicy(language, thresholds, { collects: false });
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
annotate(result: ResultMessage, pid?: number): ResultMessage {
|
|
39
|
+
const runnerReport = result.memory;
|
|
38
40
|
const report =
|
|
39
|
-
this.#readFootprint === undefined
|
|
41
|
+
this.#readFootprint === undefined
|
|
42
|
+
? runnerReport
|
|
43
|
+
: this.#footprintReport(this.#readFootprint, pid, runnerReport);
|
|
40
44
|
return report === undefined ? result : { ...result, memory: this.#policy.annotate(report) };
|
|
41
45
|
}
|
|
42
46
|
|
|
@@ -54,10 +58,14 @@ export class KernelMemoryHost {
|
|
|
54
58
|
this.#policy.kernelRetired();
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
#footprintReport(read: FootprintReader, pid: number | undefined) {
|
|
61
|
+
#footprintReport(read: FootprintReader, pid: number | undefined, runnerReport: ResultMessage["memory"]) {
|
|
58
62
|
if (pid === undefined) return undefined;
|
|
59
63
|
const footprint = read(pid);
|
|
60
64
|
if (footprint === undefined) return undefined;
|
|
61
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
liveBytes: Math.round(footprint.bytes),
|
|
67
|
+
measure: "footprint" as const,
|
|
68
|
+
...(runnerReport?.globals === undefined ? {} : { globals: runnerReport.globals }),
|
|
69
|
+
};
|
|
62
70
|
}
|
|
63
71
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import { generateCorrelationId } from "../../bridge/protocol.ts";
|
|
3
3
|
import { RESERVED_AGENT_TOOL } from "../../bridge/reserved.ts";
|
|
4
|
-
import { kernelToolError } from "
|
|
4
|
+
import { kernelToolError } from "../js/kernel-tools-errors.ts";
|
|
5
5
|
import type {
|
|
6
6
|
KernelToolsDescribeResult,
|
|
7
7
|
KernelToolsInvokeOptions,
|
|
8
8
|
KernelToolsInvokeRequest,
|
|
9
9
|
KernelToolsInvokeScope,
|
|
10
|
-
} from "
|
|
10
|
+
} from "../js/kernel-tools-types.ts";
|
|
11
11
|
|
|
12
12
|
type KernelToolReply = Extract<
|
|
13
13
|
KernelToHostMessage,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CPU time a process group has used so far, read from the kernel without spawning anything, in
|
|
3
|
+
* platform units that only ever grow: the startup watchdog compares two readings and never converts.
|
|
4
|
+
*
|
|
5
|
+
* - darwin: every member from `proc_listpgrppids`, each `ri_user_time + ri_system_time` from
|
|
6
|
+
* `proc_pid_rusage(RUSAGE_INFO_V2)` (libproc, via `bun:ffi`);
|
|
7
|
+
* - linux: every `/proc/<pid>/stat` whose process group is `pgid`, `utime + stime` in clock ticks;
|
|
8
|
+
* - darwin on a runtime without `bun:ffi` (Node): the members' `cputime` from one `ps` call, in
|
|
9
|
+
* hundredths of a second;
|
|
10
|
+
* - win32 and anything else: `undefined`. Windows has no process groups, and the launcher's own time
|
|
11
|
+
* misses the interpreter a launcher such as juliaup starts, so the caller treats the CPU as unknown.
|
|
12
|
+
*
|
|
13
|
+
* `bun:ffi` is fetched with `process.getBuiltinModule` so the module still loads on Node, the same
|
|
14
|
+
* runtime boundary `process-footprint.ts` in coding-agent uses. Every read is synchronous and never throws.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
type BunFfi = typeof import("bun:ffi");
|
|
20
|
+
type GroupCpuReader = (pgid: number) => bigint | undefined;
|
|
21
|
+
|
|
22
|
+
const RUSAGE_INFO_V2 = 2;
|
|
23
|
+
const RI_USER_TIME_OFFSET = 16;
|
|
24
|
+
const RI_SYSTEM_TIME_OFFSET = 24;
|
|
25
|
+
const RUSAGE_BUFFER_BYTES = 256;
|
|
26
|
+
const MAX_GROUP_MEMBERS = 4096;
|
|
27
|
+
const PS_TIMEOUT_MS = 2_000;
|
|
28
|
+
|
|
29
|
+
let groupReader: GroupCpuReader | null | undefined;
|
|
30
|
+
|
|
31
|
+
export function readProcessGroupCpuTime(pgid: number): bigint | undefined {
|
|
32
|
+
if (!Number.isInteger(pgid) || pgid <= 0) return undefined;
|
|
33
|
+
if (groupReader === undefined) groupReader = createGroupReader() ?? null;
|
|
34
|
+
return groupReader?.(pgid);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `utime + stime` and the process group from `/proc/<pid>/stat` text, or `undefined` when malformed. */
|
|
38
|
+
export function parseProcStat(stat: string): { readonly pgrp: number; readonly cpuTicks: bigint } | undefined {
|
|
39
|
+
// The command name may itself contain spaces or parentheses: the fields start after the last ")".
|
|
40
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
41
|
+
const pgrp = Number(fields[2]);
|
|
42
|
+
const utime = fields[11];
|
|
43
|
+
const stime = fields[12];
|
|
44
|
+
if (!Number.isInteger(pgrp) || utime === undefined || stime === undefined) return undefined;
|
|
45
|
+
if (!/^\d+$/.test(utime) || !/^\d+$/.test(stime)) return undefined;
|
|
46
|
+
return { pgrp, cpuTicks: BigInt(utime) + BigInt(stime) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `ps` `cputime` (`[[hh:]mm:]ss.cc`) in hundredths of a second, or `undefined` when malformed. */
|
|
50
|
+
export function parsePsCpuTime(text: string): bigint | undefined {
|
|
51
|
+
const match = /^(?:(\d+):)?(?:(\d+):)?(\d+)\.(\d{2})$/.exec(text.trim());
|
|
52
|
+
if (match === null) return undefined;
|
|
53
|
+
const [, first, second, seconds, hundredths] = match;
|
|
54
|
+
const hours = second === undefined ? 0 : Number(first ?? 0);
|
|
55
|
+
const minutes = second === undefined ? Number(first ?? 0) : Number(second);
|
|
56
|
+
return BigInt(((hours * 60 + minutes) * 60 + Number(seconds)) * 100 + Number(hundredths));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createGroupReader(): GroupCpuReader | undefined {
|
|
60
|
+
if (process.platform === "linux") return linuxGroupReader;
|
|
61
|
+
if (process.platform !== "darwin") return undefined;
|
|
62
|
+
const ffi = process.getBuiltinModule("bun:ffi") as BunFfi | undefined;
|
|
63
|
+
if (ffi === undefined) return darwinPsGroupReader;
|
|
64
|
+
try {
|
|
65
|
+
return darwinGroupReader(ffi);
|
|
66
|
+
} catch {
|
|
67
|
+
// A library or symbol this build cannot bind: read the group through ps instead.
|
|
68
|
+
return darwinPsGroupReader;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function darwinPsGroupReader(pgid: number): bigint | undefined {
|
|
73
|
+
let listing: string;
|
|
74
|
+
try {
|
|
75
|
+
listing = execFileSync("ps", ["-A", "-o", "pgid=,cputime="], { encoding: "utf8", timeout: PS_TIMEOUT_MS });
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
let total = 0n;
|
|
80
|
+
let members = 0;
|
|
81
|
+
for (const line of listing.split("\n")) {
|
|
82
|
+
const [group, cputime] = line.trim().split(/\s+/);
|
|
83
|
+
if (group !== String(pgid) || cputime === undefined) continue;
|
|
84
|
+
const used = parsePsCpuTime(cputime);
|
|
85
|
+
if (used === undefined) continue;
|
|
86
|
+
total += used;
|
|
87
|
+
members += 1;
|
|
88
|
+
}
|
|
89
|
+
return members === 0 ? undefined : total;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function linuxGroupReader(pgid: number): bigint | undefined {
|
|
93
|
+
let entries: string[];
|
|
94
|
+
try {
|
|
95
|
+
entries = readdirSync("/proc");
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
let total = 0n;
|
|
100
|
+
let members = 0;
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
103
|
+
let stat: string;
|
|
104
|
+
try {
|
|
105
|
+
stat = readFileSync(`/proc/${entry}/stat`, "utf8");
|
|
106
|
+
} catch {
|
|
107
|
+
// The process exited between the directory read and this one.
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const parsed = parseProcStat(stat);
|
|
111
|
+
if (parsed === undefined || parsed.pgrp !== pgid) continue;
|
|
112
|
+
total += parsed.cpuTicks;
|
|
113
|
+
members += 1;
|
|
114
|
+
}
|
|
115
|
+
return members === 0 ? undefined : total;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function darwinGroupReader({ dlopen, FFIType, ptr }: BunFfi): GroupCpuReader {
|
|
119
|
+
const library = dlopen("libSystem.B.dylib", {
|
|
120
|
+
proc_listpgrppids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
121
|
+
proc_pid_rusage: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
|
|
122
|
+
});
|
|
123
|
+
const pids = new Int32Array(MAX_GROUP_MEMBERS);
|
|
124
|
+
const usage = new Uint8Array(RUSAGE_BUFFER_BYTES);
|
|
125
|
+
const view = new DataView(usage.buffer);
|
|
126
|
+
return (pgid) => {
|
|
127
|
+
const count = library.symbols.proc_listpgrppids(pgid, ptr(pids), pids.byteLength);
|
|
128
|
+
if (count <= 0) return undefined;
|
|
129
|
+
let total = 0n;
|
|
130
|
+
let members = 0;
|
|
131
|
+
for (const pid of pids.subarray(0, Math.min(count, MAX_GROUP_MEMBERS))) {
|
|
132
|
+
usage.fill(0);
|
|
133
|
+
if (library.symbols.proc_pid_rusage(pid, RUSAGE_INFO_V2, ptr(usage)) !== 0) continue;
|
|
134
|
+
total += view.getBigUint64(RI_USER_TIME_OFFSET, true) + view.getBigUint64(RI_SYSTEM_TIME_OFFSET, true);
|
|
135
|
+
members += 1;
|
|
136
|
+
}
|
|
137
|
+
return members === 0 ? undefined : total;
|
|
138
|
+
};
|
|
139
|
+
}
|