opencode-agent-skill 13.0.0-beta.2 → 14.2.0-beta.1
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/README.md +1556 -607
- package/bin/ocskill.mjs +172 -24
- package/docs/OPENCODE-COMPAT.md +34 -97
- package/docs/PI-COMPAT.md +188 -0
- package/docs/V14-CONTEXT-MEMORY-FABRIC.md +70 -0
- package/docs/V14.1-QUALITY-PERFORMANCE-FABRIC.md +114 -0
- package/docs/V14.2-TURBO-WEAK-MODEL-RUNTIME.md +448 -0
- package/evals/v14/tasks.json +46 -0
- package/global-config/agents/executor.md +7 -0
- package/global-config/agents/visual-verifier.md +22 -3
- package/global-config/plugins/ues-router/index.js +13 -8
- package/global-config/plugins/ues-router/policy-runtime.js +7 -0
- package/global-config/plugins/ues-router/router.js +12 -2
- package/global-config/skills/ecommerce-engineering/SKILL.md +1 -1
- package/global-config/skills/file-upload-engineering/SKILL.md +1 -1
- package/global-config/skills/git-safety/SKILL.md +1 -1
- package/global-config/skills/nestjs-engineering/SKILL.md +1 -1
- package/global-config/skills/performance-engineering/SKILL.md +1 -1
- package/global-config/skills/react-native-engineering/SKILL.md +1 -1
- package/global-config/skills/rest-api-design/SKILL.md +1 -1
- package/global-config/skills/ui-ux-engineering/SKILL.md +1 -1
- package/lib/adaptive-context-budget.mjs +97 -0
- package/lib/affected-tests.mjs +260 -0
- package/lib/benchmark-confidence.mjs +41 -2
- package/lib/browser-mcp-routing.mjs +166 -0
- package/lib/capability-fabric.mjs +336 -0
- package/lib/capability-registry.mjs +9 -0
- package/lib/context-engine-v11.mjs +65 -1
- package/lib/context-graph-rank.mjs +118 -0
- package/lib/context-manifest.mjs +97 -18
- package/lib/control-center.mjs +19 -1
- package/lib/dynamic-workflow.mjs +3 -1
- package/lib/evidence-store.mjs +82 -1
- package/lib/hierarchical-context.mjs +215 -0
- package/lib/memory-engine.mjs +465 -0
- package/lib/model-performance.mjs +33 -8
- package/lib/model-policy.mjs +3 -3
- package/lib/orchestrator-policy.mjs +5 -209
- package/lib/performance-fabric.mjs +229 -0
- package/lib/pi-rpc-pool.mjs +433 -0
- package/lib/process-hang-detector.mjs +83 -0
- package/lib/process-supervisor.mjs +193 -0
- package/lib/prompt-cache.mjs +2 -0
- package/lib/repo-graph.mjs +53 -2
- package/lib/runtime-config.mjs +31 -0
- package/lib/safety.mjs +132 -0
- package/lib/semantic-index.mjs +52 -3
- package/lib/skill-compiler.mjs +128 -0
- package/lib/skill-quality.mjs +48 -2
- package/lib/task-engine.mjs +66 -5
- package/lib/task-policy.mjs +235 -0
- package/lib/verification-broker.mjs +284 -0
- package/lib/verification-command.mjs +111 -0
- package/lib/windows-shim.mjs +35 -0
- package/lib/workspace-fingerprint.mjs +198 -0
- package/package.json +52 -42
- package/pi/extensions/ues-child-runtime.ts +238 -0
- package/pi/extensions/ues.ts +3200 -0
- package/pi/prompts/ues-audit.md +9 -0
- package/pi/prompts/ues-critique.md +9 -0
- package/pi/prompts/ues-debug.md +9 -0
- package/pi/prompts/ues-feature.md +9 -0
- package/pi/prompts/ues-fix.md +9 -0
- package/pi/prompts/ues-plan.md +9 -0
- package/pi/prompts/ues-research.md +9 -0
- package/pi/prompts/ues-resume.md +9 -0
- package/pi/prompts/ues-review.md +7 -0
- package/pi/prompts/ues-run.md +17 -0
- package/pi/prompts/ues-verify.md +9 -0
- package/scripts/check-release-consistency.mjs +119 -185
- package/scripts/check-runtime-exports.mjs +66 -0
- package/scripts/check-source-integrity.mjs +184 -0
- package/scripts/eval-pi.mjs +492 -0
- package/scripts/install.mjs +16 -0
- package/scripts/smoke-package-closure.mjs +110 -0
- package/scripts/smoke-packed-install.mjs +24 -11
- package/scripts/smoke-pi-extension.mjs +144 -0
- package/scripts/uninstall.mjs +44 -0
- package/CHANGELOG.md +0 -415
- package/docs/DETERMINISTIC-TOOLS.md +0 -105
- package/docs/ENGINEERING-DESIGN.md +0 -194
- package/docs/EVALS.md +0 -158
- package/docs/GITHUB-RULESET.md +0 -50
- package/docs/NPM-PUBLISH.md +0 -116
- package/docs/RESEARCH-SOURCES.md +0 -37
- package/docs/TRACE-SCHEMA.md +0 -122
- package/docs/V11-PERCEPTION-ADAPTIVE-EXECUTION.md +0 -75
- package/docs/V11-PERCEPTION-ADAPTIVE.md +0 -220
- package/docs/V12-WEAK-MODEL-INTELLIGENCE.md +0 -27
- package/docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md +0 -86
- package/docs/V7-INTELLIGENCE-RUNTIME.md +0 -166
- package/docs/V8-INTELLIGENCE-RELIABILITY.md +0 -206
- package/docs/V9-SPEED-INTELLIGENCE.md +0 -102
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { terminateProcessTree } from "./process-supervisor.mjs"
|
|
3
|
+
|
|
4
|
+
function clamp(value, fallback, min, max) {
|
|
5
|
+
const parsed = Number(value)
|
|
6
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
7
|
+
return Math.max(min, Math.min(max, Math.trunc(parsed)))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const RPC_CONTROL_TIMEOUT_MS = clamp(
|
|
11
|
+
process.env.UES_RPC_CONTROL_TIMEOUT_MS,
|
|
12
|
+
3_000,
|
|
13
|
+
500,
|
|
14
|
+
30_000,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
async function waitForProcessClose(proc, timeoutMs = 2_000) {
|
|
18
|
+
if (!proc) return
|
|
19
|
+
const delayAfterExitMs = process.platform === "win32" ? 75 : 0
|
|
20
|
+
|
|
21
|
+
if (proc.exitCode !== null || proc.signalCode !== null) {
|
|
22
|
+
if (delayAfterExitMs > 0) {
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, delayAfterExitMs))
|
|
24
|
+
}
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await new Promise((resolve) => {
|
|
29
|
+
let settled = false
|
|
30
|
+
let timer = null
|
|
31
|
+
const finish = () => {
|
|
32
|
+
if (settled) return
|
|
33
|
+
settled = true
|
|
34
|
+
if (timer) clearTimeout(timer)
|
|
35
|
+
proc.removeListener("close", finish)
|
|
36
|
+
resolve()
|
|
37
|
+
}
|
|
38
|
+
// "close" fires after the process has exited and its stdio handles have
|
|
39
|
+
// closed. That distinction matters on Windows, where removing the worker's
|
|
40
|
+
// temporary cwd immediately after only "exit" can still fail with EBUSY.
|
|
41
|
+
proc.once("close", finish)
|
|
42
|
+
timer = setTimeout(finish, timeoutMs)
|
|
43
|
+
timer.unref?.()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (delayAfterExitMs > 0) {
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, delayAfterExitMs))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class RpcWorker {
|
|
52
|
+
constructor(spec) {
|
|
53
|
+
this.spec = spec
|
|
54
|
+
this.proc = null
|
|
55
|
+
this.buffer = ""
|
|
56
|
+
this.stderr = ""
|
|
57
|
+
this.pending = new Map()
|
|
58
|
+
this.listeners = new Set()
|
|
59
|
+
this.requestId = 0
|
|
60
|
+
this.runs = 0
|
|
61
|
+
this.dead = false
|
|
62
|
+
this.active = false
|
|
63
|
+
this.activeAbort = null
|
|
64
|
+
this.lastActivityAt = 0
|
|
65
|
+
this.tail = Promise.resolve()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async start() {
|
|
69
|
+
if (this.proc && !this.dead) return
|
|
70
|
+
const proc = spawn(this.spec.command, this.spec.args, {
|
|
71
|
+
cwd: this.spec.cwd,
|
|
72
|
+
env: this.spec.env || process.env,
|
|
73
|
+
shell: false,
|
|
74
|
+
detached: process.platform !== "win32",
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
77
|
+
})
|
|
78
|
+
this.proc = proc
|
|
79
|
+
this.dead = false
|
|
80
|
+
this.stderr = ""
|
|
81
|
+
|
|
82
|
+
proc.stdout.on("data", (data) => {
|
|
83
|
+
this.buffer += data.toString()
|
|
84
|
+
const lines = this.buffer.split("\n")
|
|
85
|
+
this.buffer = lines.pop() || ""
|
|
86
|
+
for (const line of lines) this.handleLine(line)
|
|
87
|
+
})
|
|
88
|
+
proc.stderr.on("data", (data) => {
|
|
89
|
+
if (this.stderr.length < 128 * 1024) this.stderr += data.toString()
|
|
90
|
+
})
|
|
91
|
+
proc.stdin.on("error", (error) => this.fail(new Error("Pi RPC stdin error: " + error.message)))
|
|
92
|
+
proc.on("error", (error) => this.fail(error))
|
|
93
|
+
proc.on("exit", (code, signal) => {
|
|
94
|
+
this.fail(new Error(`Pi RPC worker exited code=${code} signal=${signal || "none"}\n${this.stderr}`))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Probe readiness through the protocol rather than relying on an arbitrary sleep.
|
|
98
|
+
await this.send({ type: "get_state" }, { timeoutMs: 30_000 })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
handleLine(line) {
|
|
102
|
+
if (!line.trim()) return
|
|
103
|
+
let event
|
|
104
|
+
try {
|
|
105
|
+
event = JSON.parse(line)
|
|
106
|
+
} catch {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (event?.type === "response" && event?.id && this.pending.has(event.id)) {
|
|
110
|
+
const request = this.pending.get(event.id)
|
|
111
|
+
this.pending.delete(event.id)
|
|
112
|
+
clearTimeout(request.timer)
|
|
113
|
+
if (event.success === false) request.reject(new Error(event.error || event.message || "Pi RPC command failed"))
|
|
114
|
+
else request.resolve(event)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
for (const listener of [...this.listeners]) {
|
|
118
|
+
try { listener(event) } catch {}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fail(error) {
|
|
123
|
+
if (this.dead) return
|
|
124
|
+
this.dead = true
|
|
125
|
+
for (const request of this.pending.values()) {
|
|
126
|
+
clearTimeout(request.timer)
|
|
127
|
+
request.reject(error)
|
|
128
|
+
}
|
|
129
|
+
this.pending.clear()
|
|
130
|
+
for (const listener of [...this.listeners]) {
|
|
131
|
+
try { listener({ type: "ues_rpc_worker_error", error: String(error?.message || error) }) } catch {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async send(body, options = {}) {
|
|
136
|
+
await this.startIfNeededForSend(body)
|
|
137
|
+
if (!this.proc?.stdin || this.dead) throw new Error("Pi RPC worker is not available")
|
|
138
|
+
const id = "ues-" + (++this.requestId)
|
|
139
|
+
const timeoutMs = clamp(options.timeoutMs, 30_000, 1000, 30 * 60_000)
|
|
140
|
+
const payload = JSON.stringify({ id, ...body }) + "\n"
|
|
141
|
+
return await new Promise((resolve, reject) => {
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
this.pending.delete(id)
|
|
144
|
+
reject(new Error(`Pi RPC command timeout: ${body.type}`))
|
|
145
|
+
}, timeoutMs)
|
|
146
|
+
timer.unref?.()
|
|
147
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
148
|
+
this.proc.stdin.write(payload, (error) => {
|
|
149
|
+
if (!error) return
|
|
150
|
+
const request = this.pending.get(id)
|
|
151
|
+
if (!request) return
|
|
152
|
+
clearTimeout(request.timer)
|
|
153
|
+
this.pending.delete(id)
|
|
154
|
+
reject(error)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async startIfNeededForSend(body) {
|
|
160
|
+
// start() itself probes with get_state, so allow that first write through.
|
|
161
|
+
if (this.proc && !this.dead) return
|
|
162
|
+
if (body?.type === "get_state" && this.proc && !this.dead) return
|
|
163
|
+
if (!this.proc || this.dead) await this.start()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
onEvent(listener) {
|
|
167
|
+
this.listeners.add(listener)
|
|
168
|
+
return () => this.listeners.delete(listener)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async steer(message) {
|
|
172
|
+
if (!this.proc || this.dead || !this.active) {
|
|
173
|
+
return { accepted: false, reason: "worker-not-active" }
|
|
174
|
+
}
|
|
175
|
+
await this.send(
|
|
176
|
+
{ type: "steer", message: String(message || "") },
|
|
177
|
+
{ timeoutMs: RPC_CONTROL_TIMEOUT_MS },
|
|
178
|
+
)
|
|
179
|
+
return { accepted: true }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async abortTransport() {
|
|
183
|
+
if (!this.proc || this.dead) return false
|
|
184
|
+
try {
|
|
185
|
+
await this.send({ type: "abort" }, { timeoutMs: RPC_CONTROL_TIMEOUT_MS })
|
|
186
|
+
return true
|
|
187
|
+
} catch {
|
|
188
|
+
const proc = this.proc
|
|
189
|
+
terminateProcessTree(proc, { graceMs: 1000 })
|
|
190
|
+
this.dead = true
|
|
191
|
+
await waitForProcessClose(proc)
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async abort() {
|
|
197
|
+
if (!this.proc || this.dead) return false
|
|
198
|
+
if (typeof this.activeAbort === "function") {
|
|
199
|
+
await this.activeAbort("aborted")
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
return this.abortTransport()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async stop() {
|
|
206
|
+
const proc = this.proc
|
|
207
|
+
if (!proc) return
|
|
208
|
+
terminateProcessTree(proc, { graceMs: 500 })
|
|
209
|
+
this.dead = true
|
|
210
|
+
await waitForProcessClose(proc)
|
|
211
|
+
if (this.proc === proc) this.proc = null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
run(message, options = {}) {
|
|
215
|
+
const execute = async () => {
|
|
216
|
+
try {
|
|
217
|
+
await this.start()
|
|
218
|
+
if (this.runs > 0) {
|
|
219
|
+
await this.send({ type: "new_session" }, { timeoutMs: 30_000 })
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error && typeof error === "object") error.uesRpcPhase = "startup"
|
|
223
|
+
throw error
|
|
224
|
+
}
|
|
225
|
+
this.runs += 1
|
|
226
|
+
this.stderr = ""
|
|
227
|
+
|
|
228
|
+
const hardTimeoutMs = clamp(options.hardTimeoutMs, 30 * 60_000, 30_000, 2 * 60 * 60_000)
|
|
229
|
+
const idleTimeoutMs = clamp(options.idleTimeoutMs, 5 * 60_000, 10_000, 30 * 60_000)
|
|
230
|
+
let lastActivityAt = Date.now()
|
|
231
|
+
this.lastActivityAt = lastActivityAt
|
|
232
|
+
this.active = true
|
|
233
|
+
let finalMessage = null
|
|
234
|
+
let toolCalls = 0
|
|
235
|
+
let lastToolErrorAt = 0
|
|
236
|
+
const toolNames = new Set()
|
|
237
|
+
let settledResolve
|
|
238
|
+
let settledReject
|
|
239
|
+
const settled = new Promise((resolve, reject) => {
|
|
240
|
+
settledResolve = resolve
|
|
241
|
+
settledReject = reject
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
let aborting = false
|
|
245
|
+
const abortFor = async (reason) => {
|
|
246
|
+
if (aborting) return
|
|
247
|
+
aborting = true
|
|
248
|
+
try { await this.abortTransport() } catch {}
|
|
249
|
+
settledReject(new Error("UES RPC " + reason))
|
|
250
|
+
}
|
|
251
|
+
this.activeAbort = abortFor
|
|
252
|
+
|
|
253
|
+
const unsubscribe = this.onEvent((event) => {
|
|
254
|
+
lastActivityAt = Date.now()
|
|
255
|
+
this.lastActivityAt = lastActivityAt
|
|
256
|
+
if (event.type === "tool_execution_start") {
|
|
257
|
+
toolCalls += 1
|
|
258
|
+
lastToolErrorAt = 0
|
|
259
|
+
if (event.toolName) toolNames.add(String(event.toolName))
|
|
260
|
+
}
|
|
261
|
+
if (event.type === "tool_execution_end") {
|
|
262
|
+
lastToolErrorAt = event.isError === true ? Date.now() : 0
|
|
263
|
+
}
|
|
264
|
+
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
265
|
+
finalMessage = event.message
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const decision = options.onEvent?.(event)
|
|
269
|
+
if (decision?.abort === true) void abortFor(decision.reason || "event-abort")
|
|
270
|
+
} catch {}
|
|
271
|
+
if (event.type === "agent_settled" && !aborting) settledResolve()
|
|
272
|
+
if (event.type === "ues_rpc_worker_error") settledReject(new Error(event.error || "RPC worker failed"))
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const hardTimer = setTimeout(() => void abortFor("hard-timeout"), hardTimeoutMs)
|
|
276
|
+
hardTimer.unref?.()
|
|
277
|
+
const postToolErrorIdleTimeoutMs = clamp(
|
|
278
|
+
options.postToolErrorIdleTimeoutMs,
|
|
279
|
+
60_000,
|
|
280
|
+
5_000,
|
|
281
|
+
idleTimeoutMs,
|
|
282
|
+
)
|
|
283
|
+
const idleTimer = setInterval(() => {
|
|
284
|
+
const now = Date.now()
|
|
285
|
+
const threshold = lastToolErrorAt > 0 ? Math.min(idleTimeoutMs, postToolErrorIdleTimeoutMs) : idleTimeoutMs
|
|
286
|
+
if (now - lastActivityAt >= threshold) {
|
|
287
|
+
void abortFor(lastToolErrorAt > 0 ? "post-tool-error-stall" : "idle-timeout")
|
|
288
|
+
}
|
|
289
|
+
}, 1000)
|
|
290
|
+
idleTimer.unref?.()
|
|
291
|
+
|
|
292
|
+
const signal = options.signal
|
|
293
|
+
const onAbort = () => void abortFor("aborted")
|
|
294
|
+
if (signal) {
|
|
295
|
+
if (signal.aborted) onAbort()
|
|
296
|
+
else signal.addEventListener("abort", onAbort, { once: true })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
await this.send({ type: "prompt", message }, { timeoutMs: 30_000 })
|
|
301
|
+
await settled
|
|
302
|
+
return {
|
|
303
|
+
message: finalMessage,
|
|
304
|
+
stderr: this.stderr,
|
|
305
|
+
toolCalls,
|
|
306
|
+
toolNames: [...toolNames],
|
|
307
|
+
}
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error && typeof error === "object") error.uesRpcPhase = "runtime"
|
|
310
|
+
throw error
|
|
311
|
+
} finally {
|
|
312
|
+
this.active = false
|
|
313
|
+
this.activeAbort = null
|
|
314
|
+
clearTimeout(hardTimer)
|
|
315
|
+
clearInterval(idleTimer)
|
|
316
|
+
unsubscribe()
|
|
317
|
+
if (signal) signal.removeEventListener("abort", onAbort)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const promise = this.tail.then(execute, execute)
|
|
322
|
+
this.tail = promise.catch(() => {})
|
|
323
|
+
return promise
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export class PiRpcWorkerPool {
|
|
328
|
+
constructor(options = {}) {
|
|
329
|
+
this.maxWorkers = Math.max(1, Math.min(16, Number(options.maxWorkers || 8)))
|
|
330
|
+
this.workers = new Map()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async pruneIdleWorkers(protectedKey = null) {
|
|
334
|
+
while (this.workers.size > this.maxWorkers) {
|
|
335
|
+
let candidateKey = null
|
|
336
|
+
let candidate = null
|
|
337
|
+
for (const [key, worker] of this.workers.entries()) {
|
|
338
|
+
if (key === protectedKey) continue
|
|
339
|
+
if (!worker || worker.dead) {
|
|
340
|
+
candidateKey = key
|
|
341
|
+
candidate = worker
|
|
342
|
+
break
|
|
343
|
+
}
|
|
344
|
+
if (worker.active) continue
|
|
345
|
+
candidateKey = key
|
|
346
|
+
candidate = worker
|
|
347
|
+
break
|
|
348
|
+
}
|
|
349
|
+
if (!candidateKey) break
|
|
350
|
+
this.workers.delete(candidateKey)
|
|
351
|
+
await candidate?.stop().catch(() => {})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async run(key, spec, message, options = {}) {
|
|
356
|
+
let worker = this.workers.get(key)
|
|
357
|
+
let workerReused = Boolean(worker && !worker.dead && worker.runs > 0)
|
|
358
|
+
if (!worker || worker.dead) {
|
|
359
|
+
if (worker) {
|
|
360
|
+
await worker.stop().catch(() => {})
|
|
361
|
+
this.workers.delete(key)
|
|
362
|
+
}
|
|
363
|
+
worker = new RpcWorker(spec)
|
|
364
|
+
workerReused = false
|
|
365
|
+
this.workers.set(key, worker)
|
|
366
|
+
await this.pruneIdleWorkers(key)
|
|
367
|
+
} else {
|
|
368
|
+
// Refresh insertion order so the bounded worker map behaves as an LRU.
|
|
369
|
+
this.workers.delete(key)
|
|
370
|
+
this.workers.set(key, worker)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const result = await worker.run(message, options)
|
|
375
|
+
await this.pruneIdleWorkers(key)
|
|
376
|
+
return { ...result, workerReused }
|
|
377
|
+
} catch (error) {
|
|
378
|
+
this.workers.delete(key)
|
|
379
|
+
await worker.stop().catch(() => {})
|
|
380
|
+
await this.pruneIdleWorkers().catch(() => {})
|
|
381
|
+
throw error
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
activeEntries() {
|
|
386
|
+
return [...this.workers.entries()]
|
|
387
|
+
.filter(([, worker]) => worker && !worker.dead && worker.active)
|
|
388
|
+
.map(([key, worker]) => ({
|
|
389
|
+
key,
|
|
390
|
+
lastActivityAt: worker.lastActivityAt || 0,
|
|
391
|
+
}))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async steerActive(message) {
|
|
395
|
+
const active = [...this.workers.entries()]
|
|
396
|
+
.filter(([, worker]) => worker && !worker.dead && worker.active)
|
|
397
|
+
if (active.length !== 1) {
|
|
398
|
+
return {
|
|
399
|
+
accepted: false,
|
|
400
|
+
reason: active.length === 0 ? "no-active-worker" : "multiple-active-workers",
|
|
401
|
+
active: active.length,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const [key, worker] = active[0]
|
|
405
|
+
const result = await worker.steer(message)
|
|
406
|
+
return { ...result, key, active: 1 }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async abortActive() {
|
|
410
|
+
const active = [...this.workers.values()]
|
|
411
|
+
.filter((worker) => worker && !worker.dead && worker.active)
|
|
412
|
+
const outcomes = await Promise.all(
|
|
413
|
+
active.map((worker) => worker.abort().catch(() => false)),
|
|
414
|
+
)
|
|
415
|
+
return { aborted: outcomes.filter(Boolean).length }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async stopAll() {
|
|
419
|
+
const workers = [...this.workers.values()]
|
|
420
|
+
this.workers.clear()
|
|
421
|
+
await Promise.all(workers.map((worker) => worker.stop().catch(() => {})))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
status() {
|
|
425
|
+
const active = this.activeEntries()
|
|
426
|
+
return {
|
|
427
|
+
workers: this.workers.size,
|
|
428
|
+
activeWorkers: active.length,
|
|
429
|
+
keys: [...this.workers.keys()],
|
|
430
|
+
active,
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const SHELL_TOOLS = new Set(["bash", "powershell"])
|
|
2
|
+
|
|
3
|
+
const JEST_OPEN_HANDLE =
|
|
4
|
+
/Jest did not exit one second after the test run has completed\.?/i
|
|
5
|
+
|
|
6
|
+
const JEST_OPEN_HANDLE_HINT =
|
|
7
|
+
/asynchronous operations that weren't stopped in your tests|--detectOpenHandles/i
|
|
8
|
+
|
|
9
|
+
function bounded(text, limit = 6000) {
|
|
10
|
+
const value = String(text || "")
|
|
11
|
+
return value.length <= limit ? value : value.slice(value.length - limit)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function toolResultText(value) {
|
|
15
|
+
if (value == null) return ""
|
|
16
|
+
if (typeof value === "string") return value
|
|
17
|
+
if (Array.isArray(value)) return value.map((item) => toolResultText(item)).filter(Boolean).join("\n")
|
|
18
|
+
if (typeof value !== "object") return String(value)
|
|
19
|
+
|
|
20
|
+
if (typeof value.text === "string") return value.text
|
|
21
|
+
if (Array.isArray(value.content)) {
|
|
22
|
+
return value.content
|
|
23
|
+
.map((item) => toolResultText(item))
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join("\n")
|
|
26
|
+
}
|
|
27
|
+
if (value.partialResult) return toolResultText(value.partialResult)
|
|
28
|
+
if (value.result) return toolResultText(value.result)
|
|
29
|
+
return ""
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function detectHungToolEvidence(input = {}) {
|
|
33
|
+
const toolName = String(input.toolName || "").toLowerCase()
|
|
34
|
+
if (!SHELL_TOOLS.has(toolName)) return null
|
|
35
|
+
|
|
36
|
+
const text = String(input.text || "")
|
|
37
|
+
if (!text) return null
|
|
38
|
+
|
|
39
|
+
if (JEST_OPEN_HANDLE.test(text)) {
|
|
40
|
+
return {
|
|
41
|
+
schemaVersion: 1,
|
|
42
|
+
kind: "jest-open-handle",
|
|
43
|
+
confidence: "high",
|
|
44
|
+
toolName,
|
|
45
|
+
message:
|
|
46
|
+
"Jest reported that the test run completed but the process did not exit, indicating an open async handle/resource leak.",
|
|
47
|
+
evidence: bounded(text),
|
|
48
|
+
diagnosticHint: JEST_OPEN_HANDLE_HINT.test(text)
|
|
49
|
+
? "Run the narrow failing suite with --detectOpenHandles and close leaked timers, servers, Prisma/Redis/BullMQ connections or workers."
|
|
50
|
+
: "Run the narrow failing suite with --detectOpenHandles and inspect leaked async resources.",
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createToolOutputAccumulator(options = {}) {
|
|
58
|
+
const maxChars = Math.max(1024, Math.min(64 * 1024, Number(options.maxChars || 12_000)))
|
|
59
|
+
const values = new Map()
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
append(toolCallId, text) {
|
|
63
|
+
const key = String(toolCallId || "")
|
|
64
|
+
if (!key) return String(text || "")
|
|
65
|
+
const next = (String(values.get(key) || "") + String(text || "")).slice(-maxChars)
|
|
66
|
+
values.set(key, next)
|
|
67
|
+
return next
|
|
68
|
+
},
|
|
69
|
+
get(toolCallId) {
|
|
70
|
+
return String(values.get(String(toolCallId || "")) || "")
|
|
71
|
+
},
|
|
72
|
+
delete(toolCallId) {
|
|
73
|
+
values.delete(String(toolCallId || ""))
|
|
74
|
+
},
|
|
75
|
+
clear() {
|
|
76
|
+
values.clear()
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isToolExecutionError(event = {}) {
|
|
82
|
+
return event?.type === "tool_execution_end" && event?.isError === true
|
|
83
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
function clamp(value, fallback, min, max) {
|
|
4
|
+
const parsed = Number(value)
|
|
5
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
6
|
+
return Math.max(min, Math.min(max, Math.trunc(parsed)))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function terminateProcessTree(proc, options = {}) {
|
|
10
|
+
if (!proc?.pid) return false
|
|
11
|
+
const graceMs = clamp(options.graceMs, 1500, 0, 30_000)
|
|
12
|
+
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
const result = spawnSync("taskkill", ["/PID", String(proc.pid), "/T", "/F"], {
|
|
15
|
+
stdio: "ignore",
|
|
16
|
+
windowsHide: true,
|
|
17
|
+
})
|
|
18
|
+
return result.status === 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Processes created with detached=true become their own process group.
|
|
22
|
+
let signaled = false
|
|
23
|
+
try {
|
|
24
|
+
process.kill(-proc.pid, "SIGTERM")
|
|
25
|
+
signaled = true
|
|
26
|
+
} catch {
|
|
27
|
+
try {
|
|
28
|
+
signaled = proc.kill("SIGTERM") !== false
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (signaled && graceMs > 0) {
|
|
33
|
+
const processGroupId = proc.pid
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
// The direct child can exit while a descendant survives and keeps an
|
|
36
|
+
// inherited pipe open. Probe the process group itself before escalation.
|
|
37
|
+
let groupAlive = false
|
|
38
|
+
try {
|
|
39
|
+
process.kill(-processGroupId, 0)
|
|
40
|
+
groupAlive = true
|
|
41
|
+
} catch {}
|
|
42
|
+
if (!groupAlive) return
|
|
43
|
+
try {
|
|
44
|
+
process.kill(-processGroupId, "SIGKILL")
|
|
45
|
+
} catch {
|
|
46
|
+
try { proc.kill("SIGKILL") } catch {}
|
|
47
|
+
}
|
|
48
|
+
}, graceMs)
|
|
49
|
+
timer.unref?.()
|
|
50
|
+
}
|
|
51
|
+
return signaled
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function runSupervisedProcess(command, args = [], options = {}) {
|
|
55
|
+
const cwd = options.cwd || process.cwd()
|
|
56
|
+
const stdoutLimit = clamp(options.stdoutLimit, 512 * 1024, 1024, 64 * 1024 * 1024)
|
|
57
|
+
const stderrLimit = clamp(options.stderrLimit, 128 * 1024, 1024, 64 * 1024 * 1024)
|
|
58
|
+
const hardTimeoutMs = clamp(options.hardTimeoutMs, 0, 0, 24 * 60 * 60_000)
|
|
59
|
+
const idleTimeoutMs = clamp(options.idleTimeoutMs, 0, 0, 24 * 60 * 60_000)
|
|
60
|
+
const drainTimeoutMs = clamp(options.drainTimeoutMs, 1500, 50, 30_000)
|
|
61
|
+
const signal = options.signal
|
|
62
|
+
|
|
63
|
+
return await new Promise((resolve) => {
|
|
64
|
+
const startedAt = Date.now()
|
|
65
|
+
let lastActivityAt = startedAt
|
|
66
|
+
let stdout = ""
|
|
67
|
+
let stderr = ""
|
|
68
|
+
let stdoutTruncated = false
|
|
69
|
+
let stderrTruncated = false
|
|
70
|
+
let settled = false
|
|
71
|
+
let exitSeen = false
|
|
72
|
+
let exitCode = null
|
|
73
|
+
let exitSignal = null
|
|
74
|
+
let stopReason = null
|
|
75
|
+
let hardTimer = null
|
|
76
|
+
let idleTimer = null
|
|
77
|
+
let drainTimer = null
|
|
78
|
+
let abortListener = null
|
|
79
|
+
|
|
80
|
+
const proc = spawn(command, args, {
|
|
81
|
+
cwd,
|
|
82
|
+
env: options.env || process.env,
|
|
83
|
+
shell: false,
|
|
84
|
+
detached: process.platform !== "win32",
|
|
85
|
+
windowsHide: true,
|
|
86
|
+
stdio: options.stdin === "pipe" ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const clear = () => {
|
|
90
|
+
if (hardTimer) clearTimeout(hardTimer)
|
|
91
|
+
if (idleTimer) clearInterval(idleTimer)
|
|
92
|
+
if (drainTimer) clearTimeout(drainTimer)
|
|
93
|
+
if (signal && abortListener) signal.removeEventListener("abort", abortListener)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const finish = () => {
|
|
97
|
+
if (settled) return
|
|
98
|
+
settled = true
|
|
99
|
+
clear()
|
|
100
|
+
resolve({
|
|
101
|
+
pid: proc.pid || null,
|
|
102
|
+
exitCode: stopReason === "aborted"
|
|
103
|
+
? 130
|
|
104
|
+
: stopReason
|
|
105
|
+
? 124
|
|
106
|
+
: Number.isInteger(exitCode)
|
|
107
|
+
? exitCode
|
|
108
|
+
: 1,
|
|
109
|
+
signal: exitSignal,
|
|
110
|
+
stopReason,
|
|
111
|
+
stdout,
|
|
112
|
+
stderr,
|
|
113
|
+
stdoutTruncated,
|
|
114
|
+
stderrTruncated,
|
|
115
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
116
|
+
finishedAt: new Date().toISOString(),
|
|
117
|
+
durationMs: Date.now() - startedAt,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const stop = (reason) => {
|
|
122
|
+
if (settled || stopReason) return
|
|
123
|
+
stopReason = reason
|
|
124
|
+
terminateProcessTree(proc, { graceMs: options.killGraceMs })
|
|
125
|
+
// Do not wait forever for inherited stdout/stderr descriptors held by grandchildren.
|
|
126
|
+
drainTimer = setTimeout(finish, drainTimeoutMs)
|
|
127
|
+
drainTimer.unref?.()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const append = (kind, chunk) => {
|
|
131
|
+
lastActivityAt = Date.now()
|
|
132
|
+
const text = String(chunk)
|
|
133
|
+
if (kind === "stdout") {
|
|
134
|
+
const room = stdoutLimit - stdout.length
|
|
135
|
+
if (room > 0) stdout += text.slice(0, room)
|
|
136
|
+
if (text.length > Math.max(0, room)) stdoutTruncated = true
|
|
137
|
+
} else {
|
|
138
|
+
const room = stderrLimit - stderr.length
|
|
139
|
+
if (room > 0) stderr += text.slice(0, room)
|
|
140
|
+
if (text.length > Math.max(0, room)) stderrTruncated = true
|
|
141
|
+
}
|
|
142
|
+
try { options.onOutput?.({ kind, text, at: Date.now() }) } catch {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
proc.stdout?.on("data", (data) => append("stdout", data))
|
|
146
|
+
proc.stderr?.on("data", (data) => append("stderr", data))
|
|
147
|
+
|
|
148
|
+
proc.on("error", (error) => {
|
|
149
|
+
stderr += (stderr ? "\n" : "") + String(error?.message || error)
|
|
150
|
+
exitCode = 1
|
|
151
|
+
finish()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
proc.on("exit", (code, sig) => {
|
|
155
|
+
exitSeen = true
|
|
156
|
+
exitCode = code
|
|
157
|
+
exitSignal = sig
|
|
158
|
+
// close can be delayed forever if a grandchild inherited the pipe.
|
|
159
|
+
drainTimer = setTimeout(finish, drainTimeoutMs)
|
|
160
|
+
drainTimer.unref?.()
|
|
161
|
+
})
|
|
162
|
+
proc.on("close", (code, sig) => {
|
|
163
|
+
if (code !== null) exitCode = code
|
|
164
|
+
if (sig) exitSignal = sig
|
|
165
|
+
finish()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if (options.stdin === "pipe" && typeof options.input === "string") {
|
|
169
|
+
proc.stdin?.on("error", () => {})
|
|
170
|
+
proc.stdin?.end(options.input)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (hardTimeoutMs > 0) {
|
|
174
|
+
hardTimer = setTimeout(() => stop("hard-timeout"), hardTimeoutMs)
|
|
175
|
+
hardTimer.unref?.()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (idleTimeoutMs > 0) {
|
|
179
|
+
idleTimer = setInterval(() => {
|
|
180
|
+
if (!settled && !exitSeen && Date.now() - lastActivityAt >= idleTimeoutMs) {
|
|
181
|
+
stop("idle-timeout")
|
|
182
|
+
}
|
|
183
|
+
}, Math.min(1000, Math.max(100, Math.floor(idleTimeoutMs / 10))))
|
|
184
|
+
idleTimer.unref?.()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
abortListener = () => stop("aborted")
|
|
188
|
+
if (signal) {
|
|
189
|
+
if (signal.aborted) abortListener()
|
|
190
|
+
else signal.addEventListener("abort", abortListener, { once: true })
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
}
|