@code-yeongyu/senpi-codemode 2026.10.5 → 2026.10.7
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 +41 -0
- package/README.md +20 -2
- package/package.json +4 -4
- package/src/bridge/kernel-tools-protocol.ts +1 -1
- package/src/bridge/reserved.ts +10 -0
- package/src/bridges/eval-virtual-schemas.ts +79 -0
- package/src/bridges/handle-bridge.ts +72 -0
- package/src/bridges/reserved-dispatch.ts +20 -2
- package/src/bridges/schema-bridge.ts +4 -0
- package/src/completion/handle.ts +69 -0
- package/src/completion/tool-bridge.ts +31 -12
- 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-dispatch.ts +41 -0
- package/src/extension/bridge-tool-call.ts +41 -0
- package/src/extension/host-facts.ts +18 -0
- package/src/extension/idle-parking-kernel.ts +272 -0
- package/src/extension/kernel-registration.ts +25 -0
- package/src/extension/kernel-tools-probe.ts +24 -0
- package/src/extension/removed-tool-hints.ts +15 -0
- package/src/extension/runtime-factory.ts +3 -0
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +58 -58
- package/src/extension/start-recovery.ts +111 -0
- package/src/handles/completion-handles.ts +202 -0
- package/src/handles/handle-args.ts +103 -0
- package/src/handles/handle-registry.ts +164 -0
- package/src/handles/wait.ts +214 -0
- package/src/handles/watch-queue.ts +102 -0
- package/src/index.ts +99 -94
- package/src/kernels/jl/handles.jl +124 -0
- package/src/kernels/jl/kernel.ts +3 -1
- package/src/kernels/jl/prelude.jl +9 -0
- package/src/kernels/jl/runner.jl +160 -1
- package/src/kernels/js/context-manager.ts +1 -1
- package/src/kernels/js/handles.js +93 -0
- package/src/kernels/js/kernel-tools-define.d.ts +6 -0
- package/src/kernels/js/kernel-tools-define.js +3 -1
- package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.js +18 -9
- package/src/kernels/js/kernel-tools-schema.js +6 -2
- package/src/kernels/js/kernel-tools-types.ts +2 -1
- package/src/kernels/js/worker-global-sizes.js +84 -14
- package/src/kernels/js/worker-runtime.js +10 -1
- package/src/kernels/py/host-cell.ts +42 -0
- package/src/kernels/py/kernel-contract.ts +2 -0
- package/src/kernels/py/kernel.ts +48 -4
- package/src/kernels/py/prelude.py +145 -4
- package/src/kernels/rb/handles.rb +97 -0
- package/src/kernels/rb/kernel.ts +3 -1
- package/src/kernels/rb/paths.rb +37 -0
- package/src/kernels/rb/prelude.rb +15 -38
- package/src/kernels/rb/runner.rb +141 -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 +3 -0
- package/src/kernels/shared/subprocess-kernel.ts +33 -1
- package/src/kernels/shared/subprocess-startup.ts +90 -0
- package/src/tool/cell-handler.ts +21 -18
- package/src/tool/cell-reserved-dispatch.ts +51 -0
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/run-eval-cell.ts +7 -6
- package/src/tool/types.ts +15 -0
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(value).call() 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(value).call()
|
|
175
|
+
SENPI_SIZER_OBJECT + length * SENPI_SIZER_POINTER + sampled(length, depth) { |index| SENPI_ARRAY_AT.bind(value).call(index) }
|
|
176
|
+
elsif Hash === value
|
|
177
|
+
hash_size(value, depth)
|
|
178
|
+
else
|
|
179
|
+
ivars = SENPI_IVARS.bind(value).call()
|
|
180
|
+
ObjectSpace.memsize_of(value) + sampled(ivars.length, depth) { |index| SENPI_IVAR_GET.bind(value).call(ivars[index]) }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def hash_size(hash, depth)
|
|
185
|
+
count = SENPI_HASH_SIZE.bind(hash).call()
|
|
186
|
+
taken = 0
|
|
187
|
+
total = 0
|
|
188
|
+
SENPI_HASH_EACH.bind(hash).call() 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)
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { SessionEnvironment } from "../session-env.ts";
|
|
|
5
5
|
import type { KernelLifecycle } from "./kernel-death.ts";
|
|
6
6
|
import type { FootprintReader } from "./kernel-memory-host.ts";
|
|
7
7
|
import type { SubprocessSpawn } from "./subprocess-process.ts";
|
|
8
|
+
import type { SubprocessStartupOptions } from "./subprocess-startup.ts";
|
|
8
9
|
|
|
9
10
|
export type KernelRunInput = EvalKernelRunInput;
|
|
10
11
|
|
|
@@ -29,4 +30,6 @@ export interface SubprocessKernelOptions extends KernelLifecycle {
|
|
|
29
30
|
readonly spawn?: SubprocessSpawn;
|
|
30
31
|
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
31
32
|
readonly memory?: SubprocessKernelMemory & { readonly language: EvalLanguage };
|
|
33
|
+
/** Fails a start that stops making progress before `ready`; absent, the kernel waits for `ready` indefinitely. */
|
|
34
|
+
readonly startup?: SubprocessStartupOptions;
|
|
32
35
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
settlePendingRun,
|
|
24
24
|
timeoutResult,
|
|
25
25
|
} from "./subprocess-run.ts";
|
|
26
|
+
import { SubprocessStartupWatchdog } from "./subprocess-startup.ts";
|
|
26
27
|
|
|
27
28
|
export type {
|
|
28
29
|
KernelResult,
|
|
@@ -40,6 +41,7 @@ export class SubprocessKernel {
|
|
|
40
41
|
private readonly memory: KernelMemoryHost | null;
|
|
41
42
|
private process: SubprocessProcess | null = null;
|
|
42
43
|
private processReady = false;
|
|
44
|
+
private startup: SubprocessStartupWatchdog | null = null;
|
|
43
45
|
private retirementPromise: Promise<void> | null = null;
|
|
44
46
|
private retirementProcess: SubprocessProcess | null = null;
|
|
45
47
|
private retirementFailure: Error | null = null;
|
|
@@ -133,6 +135,7 @@ export class SubprocessKernel {
|
|
|
133
135
|
async close(): Promise<void> {
|
|
134
136
|
const wasClosed = this.closed;
|
|
135
137
|
this.closed = true;
|
|
138
|
+
this.startup?.stop();
|
|
136
139
|
if (!wasClosed) {
|
|
137
140
|
this.settleAll(new KernelClosingError());
|
|
138
141
|
this.runs.clearToolCalls();
|
|
@@ -197,10 +200,21 @@ export class SubprocessKernel {
|
|
|
197
200
|
});
|
|
198
201
|
this.process = process;
|
|
199
202
|
this.processReady = false;
|
|
203
|
+
this.startup?.stop();
|
|
204
|
+
const startup = this.options.startup;
|
|
205
|
+
this.startup = startup
|
|
206
|
+
? new SubprocessStartupWatchdog(startup, child.pid, (message) => this.stalled(process, message))
|
|
207
|
+
: null;
|
|
200
208
|
this.memory?.processReplaced();
|
|
201
209
|
try {
|
|
210
|
+
const thresholds = this.options.memory?.thresholds;
|
|
202
211
|
process.send(
|
|
203
|
-
encodeBridgeFrame({
|
|
212
|
+
encodeBridgeFrame({
|
|
213
|
+
type: "init",
|
|
214
|
+
sessionId: this.options.sessionId,
|
|
215
|
+
connection: this.options.connection,
|
|
216
|
+
...(thresholds === undefined ? {} : { memory: thresholds }),
|
|
217
|
+
}),
|
|
204
218
|
);
|
|
205
219
|
} catch (error) {
|
|
206
220
|
const failure = new KernelStartupError(error instanceof Error ? error.message : String(error));
|
|
@@ -221,7 +235,9 @@ export class SubprocessKernel {
|
|
|
221
235
|
|
|
222
236
|
private handleMessage(process: SubprocessProcess, message: KernelToHostMessage): void {
|
|
223
237
|
if (!this.accepts(process)) return;
|
|
238
|
+
if (!this.processReady) this.startup?.observe(message);
|
|
224
239
|
if (message.type === "ready") {
|
|
240
|
+
this.startup?.stop();
|
|
225
241
|
this.processReady = true;
|
|
226
242
|
this.runs.handleMessage(message, this.onMessage);
|
|
227
243
|
this.pumpRuns();
|
|
@@ -247,6 +263,7 @@ export class SubprocessKernel {
|
|
|
247
263
|
|
|
248
264
|
private handleExit(process: SubprocessProcess, code: number | null, signal: NodeJS.Signals | null): void {
|
|
249
265
|
if (this.process !== process) return;
|
|
266
|
+
this.startup?.stop();
|
|
250
267
|
if (process.isRetiring) {
|
|
251
268
|
this.process = null;
|
|
252
269
|
this.retirementFailure = null;
|
|
@@ -277,6 +294,20 @@ export class SubprocessKernel {
|
|
|
277
294
|
onDeath(reason);
|
|
278
295
|
}
|
|
279
296
|
|
|
297
|
+
/** A stalled start is a death: the owner replaces the kernel and the cells that never started run there. */
|
|
298
|
+
private stalled(process: SubprocessProcess, message: string): void {
|
|
299
|
+
if (!this.accepts(process)) return;
|
|
300
|
+
const error = new KernelStartupError(message);
|
|
301
|
+
const onDeath = this.options.onDeath;
|
|
302
|
+
if (!onDeath) {
|
|
303
|
+
this.failClosed(error);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
process.retire();
|
|
307
|
+
this.trackRetirement(process, this.terminateOwnedProcess(process));
|
|
308
|
+
this.die(error, message, onDeath);
|
|
309
|
+
}
|
|
310
|
+
|
|
280
311
|
private accepts(process: SubprocessProcess): boolean {
|
|
281
312
|
return !this.closed && this.process === process && !process.isRetiring;
|
|
282
313
|
}
|
|
@@ -330,6 +361,7 @@ export class SubprocessKernel {
|
|
|
330
361
|
}
|
|
331
362
|
|
|
332
363
|
private failClosed(error: Error): void {
|
|
364
|
+
this.startup?.stop();
|
|
333
365
|
const process = this.process;
|
|
334
366
|
this.failure = error;
|
|
335
367
|
this.closed = true;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long a starting interpreter may stay completely still - no output line, no new startup stage, and
|
|
5
|
+
* no CPU used by its process group - before startup fails. A cold Julia compiling its prelude prints
|
|
6
|
+
* nothing for seconds but keeps the CPU busy, so it never trips; only a runner that is silent AND idle
|
|
7
|
+
* (blocked on a lock, a pipe or a stopped process) does. It restarts on every sign of progress, so it is
|
|
8
|
+
* not a total startup budget. Where the group's CPU cannot be read it never fires.
|
|
9
|
+
*/
|
|
10
|
+
export const subprocessStartupNoProgressMs = 30_000;
|
|
11
|
+
|
|
12
|
+
const STDERR_TAIL_CHARS = 2_000;
|
|
13
|
+
const stages = ["interpreter-launch", "stdlib-imports", "runtime-init", "host-init"] as const;
|
|
14
|
+
export type SubprocessStartupStage = (typeof stages)[number];
|
|
15
|
+
|
|
16
|
+
export interface SubprocessStartupOptions {
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly noProgressMs?: number;
|
|
19
|
+
/** CPU time used so far by a process group (any unit that only grows); `undefined` when unreadable. */
|
|
20
|
+
readonly readGroupCpuTime?: (pgid: number) => bigint | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class SubprocessStartupWatchdog {
|
|
24
|
+
readonly #options: SubprocessStartupOptions;
|
|
25
|
+
readonly #pid: number | undefined;
|
|
26
|
+
readonly #onStall: (message: string) => void;
|
|
27
|
+
readonly #noProgressMs: number;
|
|
28
|
+
#stageIndex = 0;
|
|
29
|
+
#stderrTail = "";
|
|
30
|
+
#lastCpu: bigint | undefined;
|
|
31
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
#stopped = false;
|
|
33
|
+
|
|
34
|
+
constructor(options: SubprocessStartupOptions, pid: number | undefined, onStall: (message: string) => void) {
|
|
35
|
+
this.#options = options;
|
|
36
|
+
this.#pid = pid;
|
|
37
|
+
this.#onStall = onStall;
|
|
38
|
+
this.#noProgressMs = options.noProgressMs ?? subprocessStartupNoProgressMs;
|
|
39
|
+
this.#lastCpu = this.#readCpu();
|
|
40
|
+
this.#arm();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Any message the runner sent before `ready` is progress; a later startup stage also moves the stage. */
|
|
44
|
+
observe(message: KernelToHostMessage): void {
|
|
45
|
+
if (this.#stopped) return;
|
|
46
|
+
if (message.type === "status" && message.event.op === "kernel-startup") {
|
|
47
|
+
const stage = stages.find((candidate) => candidate === message.event.stage);
|
|
48
|
+
const next = stage === undefined ? -1 : stages.indexOf(stage);
|
|
49
|
+
if (next > this.#stageIndex) this.#stageIndex = next;
|
|
50
|
+
}
|
|
51
|
+
if (message.type === "text" && message.stream === "stderr") {
|
|
52
|
+
this.#stderrTail = (this.#stderrTail + message.data).slice(-STDERR_TAIL_CHARS);
|
|
53
|
+
}
|
|
54
|
+
this.#arm();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stop(): void {
|
|
58
|
+
this.#stopped = true;
|
|
59
|
+
clearTimeout(this.#timer);
|
|
60
|
+
this.#timer = undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#arm(): void {
|
|
64
|
+
clearTimeout(this.#timer);
|
|
65
|
+
this.#timer = setTimeout(() => this.#expire(), this.#noProgressMs);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#expire(): void {
|
|
69
|
+
if (this.#stopped) return;
|
|
70
|
+
const cpu = this.#readCpu();
|
|
71
|
+
// Unreadable CPU means busy and idle look the same: never cut a start off on silence alone.
|
|
72
|
+
// Any change counts, a drop included: a member of the group exited, which is activity.
|
|
73
|
+
if (cpu === undefined || cpu !== this.#lastCpu) {
|
|
74
|
+
this.#lastCpu = cpu;
|
|
75
|
+
this.#arm();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.stop();
|
|
79
|
+
const stage = stages[this.#stageIndex] ?? "interpreter-launch";
|
|
80
|
+
const seconds = Math.round(this.#noProgressMs / 1000);
|
|
81
|
+
const tail = this.#stderrTail.trim();
|
|
82
|
+
this.#onStall(
|
|
83
|
+
`${this.#options.label} kernel stalled at ${stage}: no output, stage change or CPU use for ${seconds} s${tail ? `; stderr: ${tail}` : ""}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#readCpu(): bigint | undefined {
|
|
88
|
+
return this.#pid === undefined ? undefined : this.#options.readGroupCpuTime?.(this.#pid);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/tool/cell-handler.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { appendSchemaHint } from "../bridges/schema-hint.ts";
|
|
|
14
14
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
15
15
|
import { handleCompletionToolCall } from "../completion/tool-bridge.ts";
|
|
16
16
|
import type { ResolvedCodemodeSettings } from "../config/settings.ts";
|
|
17
|
+
import type { HandleRegistry } from "../handles/handle-registry.ts";
|
|
17
18
|
import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
|
|
18
19
|
import {
|
|
19
20
|
boundToolCallArgs,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
type ToolCallCapture,
|
|
25
26
|
toolCallResultPreview,
|
|
26
27
|
} from "./call-capture.ts";
|
|
28
|
+
import { completionCallOptions, reservedDispatchContext } from "./cell-reserved-dispatch.ts";
|
|
27
29
|
import { CellResultBuilder, type CellState } from "./cell-runtime.ts";
|
|
28
30
|
import { type EvalImageResizer, marshalToolResult, toolResultIsError } from "./image.ts";
|
|
29
31
|
import { upsertStatusEvent } from "./status-events.ts";
|
|
@@ -48,6 +50,10 @@ export interface CellBridgeRuntime {
|
|
|
48
50
|
readonly imageResizer?: EvalImageResizer;
|
|
49
51
|
/** This cell's live kernel-tool capability; only a JS kernel has one (#1754). */
|
|
50
52
|
readonly kernelTools?: KernelToolsCapability;
|
|
53
|
+
/** The session generation's handle registry behind `wait()` / `handle()` / completion handles. */
|
|
54
|
+
readonly handles?: HandleRegistry;
|
|
55
|
+
/** Absolute wall-clock deadline of this cell; a completion handle it creates is bounded by it. */
|
|
56
|
+
readonly hardDeadlineMs?: number;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
export class CellHandler {
|
|
@@ -159,17 +165,12 @@ export class CellHandler {
|
|
|
159
165
|
await this.#deliverToolReply(
|
|
160
166
|
message,
|
|
161
167
|
async () => ({
|
|
162
|
-
value: await runReservedTool(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
listTools: this.#runtime.listTools,
|
|
169
|
-
signal: this.#state.signal,
|
|
170
|
-
emitStatus: (event) => this.#recordStatus(event),
|
|
171
|
-
marshalToolResult,
|
|
172
|
-
}),
|
|
168
|
+
value: await runReservedTool(
|
|
169
|
+
message.toolName,
|
|
170
|
+
reservedDispatchContext(message, this.#runtime, this.#state.signal, (event) =>
|
|
171
|
+
this.#recordStatus(event),
|
|
172
|
+
),
|
|
173
|
+
),
|
|
173
174
|
toolCallOk: true,
|
|
174
175
|
}),
|
|
175
176
|
capture,
|
|
@@ -177,13 +178,15 @@ export class CellHandler {
|
|
|
177
178
|
return;
|
|
178
179
|
}
|
|
179
180
|
if (message.toolName === "completion" && this.#runtime.complete) {
|
|
180
|
-
const result = await handleCompletionToolCall(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
181
|
+
const result = await handleCompletionToolCall(
|
|
182
|
+
completionCallOptions(
|
|
183
|
+
message,
|
|
184
|
+
this.#kernel,
|
|
185
|
+
this.#runtime,
|
|
186
|
+
this.#runtime.complete,
|
|
187
|
+
() => this.#state.active,
|
|
188
|
+
),
|
|
189
|
+
);
|
|
187
190
|
if (!this.#state.active) return;
|
|
188
191
|
recordToolCall(this.#state.toolCalls, result.ok, capture, undefined, result.ok ? undefined : result.error);
|
|
189
192
|
this.#resultBuilder.emitUpdate(false);
|