threadwire 0.1.5 → 0.1.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/README.md +5 -3
- package/docs/card-10520-plan.md +45 -0
- package/docs/container-runtime.md +3 -2
- package/docs/delegated-result-protocol.md +161 -0
- package/docs/evidence-artifacts.md +106 -0
- package/package.json +1 -1
- package/scripts/verify-package.js +7 -1
- package/src/cli.js +250 -29
- package/src/context-budget-metrics.js +180 -0
- package/src/delegated-result-admission.js +377 -0
- package/src/evidence-store.js +1472 -0
- package/src/notice-queue.js +22 -7
- package/src/providers/opencode.js +42 -18
- package/src/relay.js +6 -6
- package/src/run-worker.js +158 -40
- package/src/telegram-ingress/command.js +16 -0
- package/src/telegram-ingress/config.js +89 -11
- package/src/telegram-ingress/core.js +162 -90
- package/src/telegram-ingress/http.js +17 -1
- package/src/telegram-webhook.js +16 -0
- package/src/types.js +2 -2
- package/src/worker-control.js +294 -0
- package/src/hermes-protocol.js +0 -126
package/src/notice-queue.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* A single unit of work handed to the transport: either a batched/chunked text
|
|
5
5
|
* message, or a tool-activity message that must be correlated by `activity.key`
|
|
6
6
|
* so its "finished" phase edits the exact Telegram message its "started" sent.
|
|
7
|
-
* @typedef {{message: string | import("./types.js").OutgoingMessage, activity?: {key: string, phase: "started" | "finished"}}} Delivery
|
|
7
|
+
* @typedef {{message: string | import("./types.js").OutgoingMessage, metricClass?: string, activity?: {key: string, phase: "started" | "finished"}}} Delivery
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const DEFAULT_MAX_LENGTH = 3500
|
|
@@ -13,10 +13,11 @@ const DEFAULT_MAX_PENDING_BYTES = 262_144
|
|
|
13
13
|
|
|
14
14
|
export class NoticeQueue {
|
|
15
15
|
/**
|
|
16
|
-
* @param {{sender: import("./types.js").NoticeSender, minimumIntervalMs?: number, batchWindowMs?: number, maxLength?: number, maxPendingNotices?: number, maxPendingBytes?: number, sleep?: (milliseconds: number) => Promise<void>, now?: () => number, random?: () => number}} options
|
|
16
|
+
* @param {{sender: import("./types.js").NoticeSender, onDelivered?: (metricClass: string, bytes: number) => void, minimumIntervalMs?: number, batchWindowMs?: number, maxLength?: number, maxPendingNotices?: number, maxPendingBytes?: number, sleep?: (milliseconds: number) => Promise<void>, now?: () => number, random?: () => number}} options
|
|
17
17
|
*/
|
|
18
18
|
constructor(options) {
|
|
19
19
|
this.sender = options.sender
|
|
20
|
+
this.onDelivered = options.onDelivered
|
|
20
21
|
this.minimumIntervalMs = nonnegativeSafeInteger(options.minimumIntervalMs ?? 1000, "minimumIntervalMs")
|
|
21
22
|
this.batchWindowMs = nonnegativeSafeInteger(options.batchWindowMs ?? 0, "batchWindowMs")
|
|
22
23
|
this.maxLength = positiveSafeInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength")
|
|
@@ -85,19 +86,19 @@ export class NoticeQueue {
|
|
|
85
86
|
takeBatch() {
|
|
86
87
|
const first = this.shiftPending()
|
|
87
88
|
if (!first) return {message: ""}
|
|
88
|
-
if ("activity" in first) return {message: this.capActivity(first), activity: first.activity}
|
|
89
|
-
if (first.format === "javascript-code") return {message: this.takeCodeBatch(first)}
|
|
89
|
+
if ("activity" in first) return {message: this.capActivity(first), activity: first.activity, ...metricClass(first)}
|
|
90
|
+
if (first.format === "javascript-code") return {message: this.takeCodeBatch(first), ...metricClass(first)}
|
|
90
91
|
let text = first.text
|
|
91
92
|
const batchSeparator = first.batchSeparator ?? "\n"
|
|
92
93
|
while (this.pending.length > 0) {
|
|
93
94
|
const next = this.pending[0]
|
|
94
|
-
if (!next || "activity" in next || next.format !== undefined || next.label !== first.label || (next.batchSeparator ?? "\n") !== batchSeparator) break
|
|
95
|
+
if (!next || "activity" in next || next.metricClass !== first.metricClass || next.format !== undefined || next.label !== first.label || (next.batchSeparator ?? "\n") !== batchSeparator) break
|
|
95
96
|
const combined = `${text}${batchSeparator}${next.text}`
|
|
96
97
|
if (combined.length > this.maxLength) break
|
|
97
98
|
this.shiftPending()
|
|
98
99
|
text = combined
|
|
99
100
|
}
|
|
100
|
-
if (text.length <= this.maxLength) return {message: text}
|
|
101
|
+
if (text.length <= this.maxLength) return {message: text, ...metricClass(first)}
|
|
101
102
|
const label = first.label ?? /^\[P\d+\] /u.exec(text)?.[0] ?? ""
|
|
102
103
|
if (label.length >= this.maxLength) throw new NoticeQueueChunkingError()
|
|
103
104
|
let end = safeChunkEnd(text, this.maxLength)
|
|
@@ -110,7 +111,7 @@ export class NoticeQueue {
|
|
|
110
111
|
if (end <= label.length) throw new NoticeQueueChunkingError()
|
|
111
112
|
const remainder = `${label}${text.slice(remainderStart)}`
|
|
112
113
|
this.unshiftPending({...first, text: remainder})
|
|
113
|
-
return {message: text.slice(0, end)}
|
|
114
|
+
return {message: text.slice(0, end), ...metricClass(first)}
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/**
|
|
@@ -196,10 +197,12 @@ export class NoticeQueue {
|
|
|
196
197
|
async deliver(delivery) {
|
|
197
198
|
if (delivery.activity === undefined) {
|
|
198
199
|
await this.withRetry(() => this.sender.send(delivery.message))
|
|
200
|
+
this.noteDelivered(delivery)
|
|
199
201
|
return
|
|
200
202
|
}
|
|
201
203
|
if (delivery.activity.phase === "started") {
|
|
202
204
|
const sent = await this.withRetry(() => this.sender.send(delivery.message))
|
|
205
|
+
this.noteDelivered(delivery)
|
|
203
206
|
const messageId = deliveredMessageId(sent)
|
|
204
207
|
if (messageId !== undefined) this.activityMessageIds.set(delivery.activity.key, messageId)
|
|
205
208
|
return
|
|
@@ -219,11 +222,18 @@ export class NoticeQueue {
|
|
|
219
222
|
// swallow, so only an unrecoverable edit is dropped.
|
|
220
223
|
try {
|
|
221
224
|
await this.withRetry(() => edit(messageId, delivery.message))
|
|
225
|
+
this.noteDelivered(delivery)
|
|
222
226
|
} catch {
|
|
223
227
|
// Intentionally non-fatal: a failed completion edit never poisons the queue.
|
|
224
228
|
}
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
/** @param {Delivery} delivery */
|
|
232
|
+
noteDelivered(delivery) {
|
|
233
|
+
if (delivery.metricClass === undefined) return
|
|
234
|
+
this.onDelivered?.(delivery.metricClass, Buffer.byteLength(JSON.stringify(delivery.message), "utf8"))
|
|
235
|
+
}
|
|
236
|
+
|
|
227
237
|
/** @template T @param {() => Promise<T>} operation @returns {Promise<T>} */
|
|
228
238
|
async withRetry(operation) {
|
|
229
239
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
@@ -241,6 +251,11 @@ export class NoticeQueue {
|
|
|
241
251
|
}
|
|
242
252
|
}
|
|
243
253
|
|
|
254
|
+
/** @param {import("./types.js").RenderedNotice} notice */
|
|
255
|
+
function metricClass(notice) {
|
|
256
|
+
return notice.metricClass === undefined ? {} : {metricClass: notice.metricClass}
|
|
257
|
+
}
|
|
258
|
+
|
|
244
259
|
/** @param {import("./types.js").SentMessage | void} sent @returns {number | undefined} */
|
|
245
260
|
function deliveredMessageId(sent) {
|
|
246
261
|
if (sent !== undefined && Number.isSafeInteger(sent.messageId)) return sent.messageId
|
|
@@ -18,12 +18,11 @@ export function buildOpenCodeCommand(providerArguments, prompt, resumeSession, e
|
|
|
18
18
|
export function createOpenCodeSessionId() {
|
|
19
19
|
let emitted = false
|
|
20
20
|
return (record) => {
|
|
21
|
-
if (emitted
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return id
|
|
21
|
+
if (emitted) return undefined
|
|
22
|
+
const {sessionId, trusted} = recognizeOpenCodeRecord(record, () => [])
|
|
23
|
+
if (!trusted || sessionId === undefined) return undefined
|
|
24
|
+
emitted = true
|
|
25
|
+
return sessionId
|
|
27
26
|
}
|
|
28
27
|
}
|
|
29
28
|
|
|
@@ -31,29 +30,47 @@ export function createOpenCodeSessionId() {
|
|
|
31
30
|
export function createOpenCodeParser() {
|
|
32
31
|
let started = false
|
|
33
32
|
const activeToolKeys = new Set()
|
|
34
|
-
return (record) => filterRepeatedToolStarts(
|
|
33
|
+
return (record) => filterRepeatedToolStarts(recognizeOpenCodeRecord(record, () => {
|
|
35
34
|
if (started) return []
|
|
36
35
|
started = true
|
|
37
36
|
return [{type: "lifecycle", phase: "started", summary: "OpenCode worker started"}]
|
|
38
|
-
}), activeToolKeys)
|
|
37
|
+
}).events, activeToolKeys)
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
/** @param {unknown} record @returns {import("../types.js").WorkerEvent[]} */
|
|
42
41
|
export function parseOpenCodeEvent(record) {
|
|
43
|
-
return
|
|
42
|
+
return recognizeOpenCodeRecord(record, () => [{type: "lifecycle", phase: "started", summary: "OpenCode worker started"}]).events
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
/**
|
|
46
|
+
* @param {unknown} record
|
|
47
|
+
* @param {() => import("../types.js").WorkerEvent[]} start
|
|
48
|
+
* @returns {{trusted: boolean, sessionId: string | undefined, events: import("../types.js").WorkerEvent[]}}
|
|
49
|
+
*/
|
|
50
|
+
function recognizeOpenCodeRecord(record, start) {
|
|
51
|
+
if (!isRecord(record) || typeof record.type !== "string") return {trusted: false, sessionId: undefined, events: []}
|
|
52
|
+
const part = isRecord(record.part) ? record.part : undefined
|
|
53
|
+
if (record.type === "step_start" && part?.type === "step-start") {
|
|
54
|
+
return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: start()}
|
|
55
|
+
}
|
|
56
|
+
if (record.type === "step_finish" && part?.type === "step-finish") {
|
|
57
|
+
return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: []}
|
|
58
|
+
}
|
|
59
|
+
if (record.type === "error" && part?.type === "error") {
|
|
60
|
+
return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: [{type: "lifecycle", phase: "failed", summary: "OpenCode worker failed"}]}
|
|
61
|
+
}
|
|
52
62
|
if (record.type === "text" && isRecord(record.part) && typeof record.part.text === "string") {
|
|
53
|
-
return
|
|
63
|
+
return {
|
|
64
|
+
trusted: true,
|
|
65
|
+
sessionId: sessionIdForKnownRecord(record, record.part),
|
|
66
|
+
events: [{type: "text-delta", text: record.part.text, streamId: stringValue(record.part.id, "opencode:assistant")}]
|
|
67
|
+
}
|
|
54
68
|
}
|
|
55
|
-
if (record.type === "tool_use" && isRecord(record.part))
|
|
56
|
-
|
|
69
|
+
if (record.type === "tool_use" && isRecord(record.part)) {
|
|
70
|
+
const events = parseTool(record.part)
|
|
71
|
+
if (events.length > 0) return {trusted: true, sessionId: sessionIdForKnownRecord(record, record.part), events}
|
|
72
|
+
}
|
|
73
|
+
return {trusted: false, sessionId: undefined, events: []}
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
/** @param {Record<string, unknown>} part @returns {import("../types.js").WorkerEvent[]} */
|
|
@@ -104,6 +121,13 @@ function stringValue(value, fallback) {
|
|
|
104
121
|
return typeof value === "string" ? value : fallback
|
|
105
122
|
}
|
|
106
123
|
|
|
124
|
+
/** @param {Record<string, unknown>} record @param {Record<string, unknown> | undefined} [part] */
|
|
125
|
+
function sessionIdForKnownRecord(record, part) {
|
|
126
|
+
if (typeof record.sessionID === "string") return record.sessionID
|
|
127
|
+
if (part !== undefined && typeof part.sessionID === "string") return part.sessionID
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
107
131
|
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
108
132
|
function isRecord(value) {
|
|
109
133
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
package/src/relay.js
CHANGED
|
@@ -45,7 +45,7 @@ export class Relay {
|
|
|
45
45
|
return
|
|
46
46
|
}
|
|
47
47
|
const text = this.renderActivity(event)
|
|
48
|
-
if (text) this.queue.enqueue({text, label: `${this.label}
|
|
48
|
+
if (text) this.queue.enqueue({text, label: `${this.label} `, metricClass: event.type})
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
@@ -60,7 +60,7 @@ export class Relay {
|
|
|
60
60
|
if (event.phase === "started") {
|
|
61
61
|
const description = this.toolDescription(event)
|
|
62
62
|
this.activeToolDescriptions.set(event.key, {description, hasDetail: event.detail !== undefined && event.detail.length > 0})
|
|
63
|
-
this.queue.enqueue({activity: {key: event.key, phase: "started"}, text: `🛠 ${this.label} Tool: ${description}
|
|
63
|
+
this.queue.enqueue({activity: {key: event.key, phase: "started"}, text: `🛠 ${this.label} Tool: ${description}`, metricClass: "tool"})
|
|
64
64
|
return
|
|
65
65
|
}
|
|
66
66
|
const stored = this.activeToolDescriptions.get(event.key)
|
|
@@ -71,10 +71,10 @@ export class Relay {
|
|
|
71
71
|
const status = `✅ ${this.label} Tool: ${description}`
|
|
72
72
|
const html = renderCompletedOutput(status, event.output, this.outputMessageLimit())
|
|
73
73
|
if (html === undefined) {
|
|
74
|
-
this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: status})
|
|
74
|
+
this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: status, metricClass: "tool"})
|
|
75
75
|
return
|
|
76
76
|
}
|
|
77
|
-
this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: html, parseMode: "HTML"})
|
|
77
|
+
this.queue.enqueue({activity: {key: event.key, phase: "finished"}, text: html, parseMode: "HTML", metricClass: "tool"})
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
@@ -165,10 +165,10 @@ export class Relay {
|
|
|
165
165
|
|
|
166
166
|
/** @param {string} text */
|
|
167
167
|
enqueueAssistant(text) {
|
|
168
|
-
if (this.inJavaScriptFence) this.queue.enqueue({text, format: "javascript-code", label: `${this.label} Assistant
|
|
168
|
+
if (this.inJavaScriptFence) this.queue.enqueue({text, format: "javascript-code", label: `${this.label} Assistant:`, metricClass: "assistant"})
|
|
169
169
|
else {
|
|
170
170
|
const label = `${this.label} Assistant: `
|
|
171
|
-
this.queue.enqueue({text: `${label}${text.replaceAll(/(\r\n|[\n\r\u2028\u2029])(?=.)/gu, `$1${label}`)}`, label, batchSeparator: ""})
|
|
171
|
+
this.queue.enqueue({text: `${label}${text.replaceAll(/(\r\n|[\n\r\u2028\u2029])(?=.)/gu, `$1${label}`)}`, label, batchSeparator: "", metricClass: "assistant"})
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
package/src/run-worker.js
CHANGED
|
@@ -6,17 +6,25 @@ import {StringDecoder} from "node:string_decoder"
|
|
|
6
6
|
import {buildProviderEnvironment} from "./telegram-ingress/config.js"
|
|
7
7
|
|
|
8
8
|
const DEFAULT_MAX_STDOUT_RECORD_BYTES = 1_048_576
|
|
9
|
+
const DEFAULT_TERMINATION_GRACE_PERIOD_MS = 5_000
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
* @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void
|
|
12
|
+
* @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void | Promise<void>, onStdoutChunk?: (chunk: Buffer) => void | Promise<void>, onStderrChunk?: (chunk: Buffer) => void | Promise<void>, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number, terminationGracePeriodMs?: number, setTimer?: (callback: () => void, milliseconds: number) => unknown, clearTimer?: (handle: unknown) => void, platform?: NodeJS.Platform, killProcess?: (pid: number, signal: NodeJS.Signals) => boolean}} RunWorkerOptions
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
/** @param {RunWorkerOptions} options @returns {Promise<number>} */
|
|
15
16
|
export function runWorker(options) {
|
|
16
17
|
const maxStdoutRecordBytes = positiveSafeInteger(options.maxStdoutRecordBytes ?? DEFAULT_MAX_STDOUT_RECORD_BYTES, "maxStdoutRecordBytes")
|
|
18
|
+
const terminationGracePeriodMs = positiveSafeInteger(options.terminationGracePeriodMs ?? DEFAULT_TERMINATION_GRACE_PERIOD_MS, "terminationGracePeriodMs")
|
|
19
|
+
const platform = options.platform ?? process.platform
|
|
17
20
|
const spawnImplementation = options.spawnImplementation ?? nodeSpawn
|
|
21
|
+
const killProcess = options.killProcess ?? ((pid, signal) => process.kill(pid, signal))
|
|
22
|
+
const setTimer = options.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds))
|
|
23
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(/** @type {ReturnType<typeof setTimeout>} */ (handle)))
|
|
24
|
+
const useDetachedProcessGroup = platform !== "win32"
|
|
18
25
|
const child = spawnImplementation(options.executable, options.arguments, {
|
|
19
26
|
cwd: options.cwd,
|
|
27
|
+
detached: useDetachedProcessGroup,
|
|
20
28
|
env: childEnvironment(options.environment ?? process.env),
|
|
21
29
|
shell: false,
|
|
22
30
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -28,48 +36,124 @@ export function runWorker(options) {
|
|
|
28
36
|
|
|
29
37
|
return new Promise((resolve, reject) => {
|
|
30
38
|
let settled = false
|
|
39
|
+
/** @type {number | null} */
|
|
40
|
+
let closeCode = null
|
|
41
|
+
/** @type {NodeJS.Signals | null} */
|
|
42
|
+
let closeSignal = null
|
|
43
|
+
let closeSeen = false
|
|
44
|
+
/** @type {Promise<void>} */
|
|
45
|
+
let consumer = Promise.resolve()
|
|
46
|
+
/** @type {Error | null} */
|
|
47
|
+
let shutdownError = null
|
|
48
|
+
/** @type {unknown | null} */
|
|
49
|
+
let terminationTimer = null
|
|
50
|
+
const processGroupPid = useDetachedProcessGroup && isPositiveSafeInteger(child.pid) ? child.pid : null
|
|
31
51
|
/** @param {NodeJS.Signals} signal */
|
|
32
|
-
const forwardSignal = (signal) => child
|
|
52
|
+
const forwardSignal = (signal) => signalChildTree(child, processGroupPid, signal, killProcess)
|
|
33
53
|
const onInterrupt = () => forwardSignal("SIGINT")
|
|
34
54
|
const onTerminate = () => forwardSignal("SIGTERM")
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
const clearTerminationTimer = () => {
|
|
56
|
+
if (terminationTimer === null) return
|
|
57
|
+
clearTimer(terminationTimer)
|
|
58
|
+
terminationTimer = null
|
|
59
|
+
}
|
|
60
|
+
const detachForShutdown = () => {
|
|
61
|
+
child.stdout?.removeListener("data", onStdout)
|
|
62
|
+
child.stderr?.removeListener("data", onStderr)
|
|
63
|
+
child.removeListener("spawn", onSpawn)
|
|
64
|
+
child.removeListener("error", onError)
|
|
65
|
+
removeSignalHandlers(onInterrupt, onTerminate)
|
|
66
|
+
}
|
|
67
|
+
const cleanup = () => {
|
|
68
|
+
detachForShutdown()
|
|
69
|
+
clearTerminationTimer()
|
|
70
|
+
child.removeListener("close", onClose)
|
|
71
|
+
}
|
|
72
|
+
/** @param {Error} error */
|
|
73
|
+
const rejectAndCleanup = (error) => {
|
|
37
74
|
if (settled) return
|
|
38
75
|
settled = true
|
|
39
76
|
cleanup()
|
|
40
|
-
if (terminateChild) {
|
|
41
|
-
try { child.kill("SIGTERM") } catch { /* Child may already be gone. */ }
|
|
42
|
-
}
|
|
43
77
|
reject(error)
|
|
44
78
|
}
|
|
79
|
+
/** @param {Error} error @param {boolean} [terminateChild] */
|
|
80
|
+
const fail = (error, terminateChild = true) => {
|
|
81
|
+
if (settled || shutdownError) return
|
|
82
|
+
if (!terminateChild) {
|
|
83
|
+
rejectAndCleanup(error)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
if (closeSeen) {
|
|
87
|
+
if (processGroupPid === null) {
|
|
88
|
+
try {
|
|
89
|
+
forwardSignal("SIGTERM")
|
|
90
|
+
} catch {
|
|
91
|
+
/* Child may already be gone. */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
rejectAndCleanup(error)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
shutdownError = error
|
|
98
|
+
detachForShutdown()
|
|
99
|
+
try {
|
|
100
|
+
forwardSignal("SIGTERM")
|
|
101
|
+
} catch {
|
|
102
|
+
/* Child may already be gone. */
|
|
103
|
+
}
|
|
104
|
+
terminationTimer = setTimer(() => {
|
|
105
|
+
if (settled || shutdownError === null) return
|
|
106
|
+
terminationTimer = null
|
|
107
|
+
try {
|
|
108
|
+
forwardSignal("SIGKILL")
|
|
109
|
+
} catch {
|
|
110
|
+
/* Child may already be gone. */
|
|
111
|
+
}
|
|
112
|
+
rejectAndCleanup(shutdownError)
|
|
113
|
+
}, terminationGracePeriodMs)
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* @param {() => Promise<void>} task
|
|
117
|
+
* @param {NodeJS.ReadableStream | undefined} stream
|
|
118
|
+
*/
|
|
119
|
+
const enqueueConsumer = (task, stream) => {
|
|
120
|
+
stream?.pause()
|
|
121
|
+
consumer = consumer
|
|
122
|
+
.then(async () => {
|
|
123
|
+
if (settled || shutdownError) return
|
|
124
|
+
await task()
|
|
125
|
+
})
|
|
126
|
+
.then(() => {
|
|
127
|
+
stream?.resume()
|
|
128
|
+
if (closeSeen) void finalizeClose()
|
|
129
|
+
})
|
|
130
|
+
.catch((error) => {
|
|
131
|
+
fail(toError(error))
|
|
132
|
+
})
|
|
133
|
+
}
|
|
45
134
|
/** @param {Buffer | string} chunk */
|
|
46
135
|
const onStdout = (chunk) => {
|
|
47
|
-
if (settled) return
|
|
48
|
-
|
|
136
|
+
if (settled || shutdownError) return
|
|
137
|
+
enqueueConsumer(async () => {
|
|
49
138
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
139
|
+
await options.onStdoutChunk?.(bytes)
|
|
50
140
|
stdoutRecordBytes = nextRecordByteCount(bytes, stdoutRecordBytes, maxStdoutRecordBytes)
|
|
51
141
|
stdoutBuffer += stdoutDecoder.write(bytes)
|
|
52
|
-
stdoutBuffer = drainLines(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
|
|
53
|
-
}
|
|
54
|
-
fail(toError(error))
|
|
55
|
-
}
|
|
142
|
+
stdoutBuffer = await drainLines(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
|
|
143
|
+
}, child.stdout)
|
|
56
144
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
child.removeListener("spawn", onSpawn)
|
|
70
|
-
child.removeListener("error", onError)
|
|
71
|
-
child.removeListener("close", onClose)
|
|
72
|
-
removeSignalHandlers(onInterrupt, onTerminate)
|
|
145
|
+
/** @param {Buffer | string} chunk */
|
|
146
|
+
const onStderr = (chunk) => {
|
|
147
|
+
if (settled || shutdownError) return
|
|
148
|
+
enqueueConsumer(
|
|
149
|
+
async () => {
|
|
150
|
+
await options.onStderrChunk?.(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
151
|
+
if (stderrSeen) return
|
|
152
|
+
stderrSeen = true
|
|
153
|
+
await options.onEvent({type: "diagnostic", level: "warning", summary: "Worker wrote diagnostics to stderr"})
|
|
154
|
+
},
|
|
155
|
+
child.stderr
|
|
156
|
+
)
|
|
73
157
|
}
|
|
74
158
|
/** @param {Error} error */
|
|
75
159
|
const onError = (error) => fail(error, false)
|
|
@@ -82,19 +166,36 @@ export function runWorker(options) {
|
|
|
82
166
|
fail(toError(error))
|
|
83
167
|
}
|
|
84
168
|
}
|
|
85
|
-
|
|
86
|
-
const onClose = (code, signal) => {
|
|
169
|
+
const finalizeClose = async () => {
|
|
87
170
|
if (settled) return
|
|
171
|
+
if (shutdownError) {
|
|
172
|
+
settled = true
|
|
173
|
+
cleanup()
|
|
174
|
+
reject(shutdownError)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
88
177
|
try {
|
|
178
|
+
await consumer
|
|
179
|
+
if (settled) return
|
|
89
180
|
stdoutBuffer += stdoutDecoder.end()
|
|
90
|
-
if (stdoutBuffer.trim().length > 0) parseLine(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
|
|
181
|
+
if (stdoutBuffer.trim().length > 0) await parseLine(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
|
|
91
182
|
} catch (error) {
|
|
92
183
|
fail(toError(error), false)
|
|
93
184
|
return
|
|
94
185
|
}
|
|
95
186
|
settled = true
|
|
96
187
|
cleanup()
|
|
97
|
-
resolve(
|
|
188
|
+
resolve(closeCode ?? signalExitCode(closeSignal))
|
|
189
|
+
}
|
|
190
|
+
/** @param {number | null} code @param {NodeJS.Signals | null} signal */
|
|
191
|
+
const onClose = (code, signal) => {
|
|
192
|
+
if (settled) return
|
|
193
|
+
closeSeen = true
|
|
194
|
+
removeSignalHandlers(onInterrupt, onTerminate)
|
|
195
|
+
closeCode = code
|
|
196
|
+
closeSignal = signal
|
|
197
|
+
clearTerminationTimer()
|
|
198
|
+
void finalizeClose()
|
|
98
199
|
}
|
|
99
200
|
child.stdout?.on("data", onStdout)
|
|
100
201
|
child.stderr?.on("data", onStderr)
|
|
@@ -125,26 +226,32 @@ export function childEnvironment(environment = process.env) {
|
|
|
125
226
|
}
|
|
126
227
|
|
|
127
228
|
/** @param {string} buffer @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord */
|
|
128
|
-
function drainLines(buffer, parse, onEvent, onRecord) {
|
|
229
|
+
async function drainLines(buffer, parse, onEvent, onRecord) {
|
|
129
230
|
const lines = buffer.split("\n")
|
|
130
231
|
const tail = lines.pop() ?? ""
|
|
131
|
-
for (const line of lines) parseLine(line, parse, onEvent, onRecord)
|
|
232
|
+
for (const line of lines) await parseLine(line, parse, onEvent, onRecord)
|
|
132
233
|
return tail
|
|
133
234
|
}
|
|
134
235
|
|
|
135
236
|
/** @param {string} line @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord */
|
|
136
|
-
function parseLine(line, parse, onEvent, onRecord) {
|
|
237
|
+
async function parseLine(line, parse, onEvent, onRecord) {
|
|
137
238
|
if (line.trim().length === 0) return
|
|
239
|
+
let record
|
|
240
|
+
try {
|
|
241
|
+
record = JSON.parse(line)
|
|
242
|
+
} catch {
|
|
243
|
+
await onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
await onRecord?.(record)
|
|
138
247
|
let events
|
|
139
248
|
try {
|
|
140
|
-
const record = JSON.parse(line)
|
|
141
|
-
onRecord?.(record)
|
|
142
249
|
events = parse(record)
|
|
143
250
|
} catch {
|
|
144
|
-
onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
|
|
251
|
+
await onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
|
|
145
252
|
return
|
|
146
253
|
}
|
|
147
|
-
for (const event of events) onEvent(event)
|
|
254
|
+
for (const event of events) await onEvent(event)
|
|
148
255
|
}
|
|
149
256
|
|
|
150
257
|
/** @param {NodeJS.Signals | null} signal */
|
|
@@ -153,6 +260,17 @@ function signalExitCode(signal) {
|
|
|
153
260
|
return 128 + (osConstants.signals[signal] ?? 0)
|
|
154
261
|
}
|
|
155
262
|
|
|
263
|
+
/** @param {unknown} value @returns {value is number} */
|
|
264
|
+
function isPositiveSafeInteger(value) {
|
|
265
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @param {ReturnType<typeof nodeSpawn>} child @param {number | null} processGroupPid @param {NodeJS.Signals} signal @param {(pid: number, signal: NodeJS.Signals) => boolean} killProcess */
|
|
269
|
+
function signalChildTree(child, processGroupPid, signal, killProcess) {
|
|
270
|
+
if (processGroupPid !== null) return killProcess(-processGroupPid, signal)
|
|
271
|
+
return child.kill(signal)
|
|
272
|
+
}
|
|
273
|
+
|
|
156
274
|
/** @param {() => void} onInterrupt @param {() => void} onTerminate */
|
|
157
275
|
function removeSignalHandlers(onInterrupt, onTerminate) {
|
|
158
276
|
process.removeListener("SIGINT", onInterrupt)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {PROVIDERS} from "../providers/index.js"
|
|
4
4
|
|
|
5
5
|
const COMMAND_PATTERN = /^\/code(?:@[A-Za-z0-9_]+)?\s+(codex|claude|opencode)\s+(\S[\s\S]*)$/u
|
|
6
|
+
const EVIDENCE_PATTERN = /^\/evidence(?:@[A-Za-z0-9_]+)?\s+(evidence_[A-Za-z0-9_-]{43})\s+(bytes|lines)\s+(\d+):(\d+)$/u
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* @typedef {{provider: "codex" | "claude" | "opencode", prompt: string}} CodeCommand
|
|
@@ -27,3 +28,18 @@ export function parseCodeCommand(text) {
|
|
|
27
28
|
prompt
|
|
28
29
|
}
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} text */
|
|
33
|
+
export function parseEvidenceCommand(text) {
|
|
34
|
+
if (typeof text !== "string") return null
|
|
35
|
+
const match = EVIDENCE_PATTERN.exec(text)
|
|
36
|
+
if (!match) return null
|
|
37
|
+
const handle = match[1]
|
|
38
|
+
const selector = match[2]
|
|
39
|
+
const start = Number(match[3])
|
|
40
|
+
const limit = Number(match[4])
|
|
41
|
+
if (!handle || !Number.isSafeInteger(start) || !Number.isSafeInteger(limit) || limit <= 0) return null
|
|
42
|
+
if (selector === "bytes" && start >= 0) return {handle, bytes: {offset: start, limit}}
|
|
43
|
+
if (selector === "lines" && start > 0) return {handle, lines: {start, limit}}
|
|
44
|
+
return null
|
|
45
|
+
}
|