threadwire 0.1.13 → 0.1.15
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 +12 -0
- package/README.md +11 -2
- package/docs/capacity-admission.md +147 -0
- package/docs/development-container.md +77 -0
- package/package.json +1 -1
- package/scripts/verify-package.js +8 -0
- package/src/absolute-deadline.js +6 -3
- package/src/activity-log-status.js +291 -0
- package/src/activity-log.js +91 -3
- package/src/cli.js +153 -8
- package/src/docker-api.js +8 -0
- package/src/isolated-runtime-client.js +124 -4
- package/src/isolated-runtime.js +145 -11
- package/src/jsonl-record-spool.js +178 -0
- package/src/kimi-oauth-store.js +16 -0
- package/src/provider-capacity-codex.js +176 -0
- package/src/provider-capacity-kimi.js +105 -0
- package/src/provider-capacity.js +292 -0
- package/src/providers/index.js +110 -6
- package/src/providers/kimi.js +30 -0
- package/src/run-worker.js +286 -71
- package/src/telegram-ingress/core.js +51 -22
package/src/run-worker.js
CHANGED
|
@@ -2,23 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
import {spawn as nodeSpawn} from "node:child_process"
|
|
4
4
|
import {constants as osConstants} from "node:os"
|
|
5
|
-
import {
|
|
5
|
+
import {JsonlRecordSpool, StdoutRecordTooLargeError} from "./jsonl-record-spool.js"
|
|
6
6
|
import {buildProviderEnvironment} from "./telegram-ingress/config.js"
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
export {StdoutRecordTooLargeError}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_STDOUT_RECORD_BYTES = 16_777_216
|
|
11
|
+
const DEFAULT_STDOUT_RECORD_MEMORY_BYTES = 1_048_576
|
|
9
12
|
const DEFAULT_TERMINATION_GRACE_PERIOD_MS = 5_000
|
|
13
|
+
const DEFAULT_CLEANUP_CONFIRMATION_MS = 5_000
|
|
14
|
+
const CLEANUP_PROBE_INTERVAL_MS = 10
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
|
-
* @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, provider?: string, 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
|
|
17
|
+
* @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, provider?: string, 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>, completion?: (record: unknown) => boolean, signal?: AbortSignal, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number, stdoutRecordMemoryBytes?: number, spoolDirectory?: string, terminationGracePeriodMs?: number, cleanupConfirmationMs?: number, setTimer?: (callback: () => void, milliseconds: number) => unknown, clearTimer?: (handle: unknown) => void, platform?: NodeJS.Platform, killProcess?: (pid: number, signal: NodeJS.Signals) => boolean, probeProcessGroup?: (pid: number) => boolean}} RunWorkerOptions
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
/** @param {RunWorkerOptions} options @returns {Promise<number>} */
|
|
16
21
|
export function runWorker(options) {
|
|
17
22
|
const maxStdoutRecordBytes = positiveSafeInteger(options.maxStdoutRecordBytes ?? DEFAULT_MAX_STDOUT_RECORD_BYTES, "maxStdoutRecordBytes")
|
|
23
|
+
const stdoutRecordMemoryBytes = positiveSafeInteger(options.stdoutRecordMemoryBytes ?? DEFAULT_STDOUT_RECORD_MEMORY_BYTES, "stdoutRecordMemoryBytes")
|
|
18
24
|
const terminationGracePeriodMs = positiveSafeInteger(options.terminationGracePeriodMs ?? DEFAULT_TERMINATION_GRACE_PERIOD_MS, "terminationGracePeriodMs")
|
|
25
|
+
const cleanupConfirmationMs = positiveSafeInteger(options.cleanupConfirmationMs ?? DEFAULT_CLEANUP_CONFIRMATION_MS, "cleanupConfirmationMs")
|
|
19
26
|
const platform = options.platform ?? process.platform
|
|
20
27
|
const spawnImplementation = options.spawnImplementation ?? nodeSpawn
|
|
21
28
|
const killProcess = options.killProcess ?? ((pid, signal) => process.kill(pid, signal))
|
|
29
|
+
// Attempt-owned liveness probe: signal 0 against the exact process group.
|
|
30
|
+
// Never a name match or a broad process scan.
|
|
31
|
+
const probeProcessGroup = options.probeProcessGroup ?? ((pid) => {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(-pid, 0)
|
|
34
|
+
return true
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
})
|
|
22
39
|
const setTimer = options.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds))
|
|
23
40
|
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(/** @type {ReturnType<typeof setTimeout>} */ (handle)))
|
|
24
41
|
const useDetachedProcessGroup = platform !== "win32"
|
|
@@ -29,9 +46,14 @@ export function runWorker(options) {
|
|
|
29
46
|
shell: false,
|
|
30
47
|
stdio: ["ignore", "pipe", "pipe"]
|
|
31
48
|
})
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
// Byte-oriented record spool: small pending records stay in bounded memory,
|
|
50
|
+
// oversized ones spill to one private run-scoped file, and a separate
|
|
51
|
+
// absolute cap rejects unterminated output. Closed on every settlement path.
|
|
52
|
+
const spool = new JsonlRecordSpool({
|
|
53
|
+
memoryBytes: stdoutRecordMemoryBytes,
|
|
54
|
+
maxBytes: maxStdoutRecordBytes,
|
|
55
|
+
...(options.spoolDirectory === undefined ? {} : {directory: options.spoolDirectory})
|
|
56
|
+
})
|
|
35
57
|
let stderrSeen = false
|
|
36
58
|
|
|
37
59
|
return new Promise((resolve, reject) => {
|
|
@@ -47,11 +69,18 @@ export function runWorker(options) {
|
|
|
47
69
|
let shutdownError = null
|
|
48
70
|
/** @type {unknown | null} */
|
|
49
71
|
let terminationTimer = null
|
|
72
|
+
let protocolComplete = false
|
|
73
|
+
/** @type {NodeJS.Signals | null} */
|
|
74
|
+
let cancellationSignal = null
|
|
50
75
|
const processGroupPid = useDetachedProcessGroup && isPositiveSafeInteger(child.pid) ? child.pid : null
|
|
51
76
|
/** @param {NodeJS.Signals} signal */
|
|
52
77
|
const forwardSignal = (signal) => signalChildTree(child, processGroupPid, signal, killProcess)
|
|
53
|
-
const
|
|
54
|
-
|
|
78
|
+
const groupMembersRemain = () => processGroupPid !== null && probeProcessGroup(processGroupPid)
|
|
79
|
+
// Owned-tree liveness: where a process group exists, the exact group probe
|
|
80
|
+
// covers the child and every descendant (a reaped-away zombie stays in the
|
|
81
|
+
// group until close); where none exists (win32), only the direct child's
|
|
82
|
+
// own close proves termination.
|
|
83
|
+
const ownedTreeGone = () => processGroupPid === null ? closeSeen : !groupMembersRemain()
|
|
55
84
|
const clearTerminationTimer = () => {
|
|
56
85
|
if (terminationTimer === null) return
|
|
57
86
|
clearTimer(terminationTimer)
|
|
@@ -62,12 +91,17 @@ export function runWorker(options) {
|
|
|
62
91
|
child.stderr?.removeListener("data", onStderr)
|
|
63
92
|
child.removeListener("spawn", onSpawn)
|
|
64
93
|
child.removeListener("error", onError)
|
|
65
|
-
|
|
94
|
+
options.signal?.removeEventListener("abort", onAbort)
|
|
66
95
|
}
|
|
67
96
|
const cleanup = () => {
|
|
68
97
|
detachForShutdown()
|
|
69
98
|
clearTerminationTimer()
|
|
70
99
|
child.removeListener("close", onClose)
|
|
100
|
+
spool.close()
|
|
101
|
+
// Process signal handlers stay installed through every settlement path
|
|
102
|
+
// so a real parent SIGINT/SIGTERM during cleanup can never hit the
|
|
103
|
+
// default action and kill the Threadwire parent mid-finalization.
|
|
104
|
+
removeSignalHandlers(onInterrupt, onTerminate)
|
|
71
105
|
}
|
|
72
106
|
/** @param {Error} error */
|
|
73
107
|
const rejectAndCleanup = (error) => {
|
|
@@ -76,22 +110,101 @@ export function runWorker(options) {
|
|
|
76
110
|
cleanup()
|
|
77
111
|
reject(error)
|
|
78
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Bounded cleanup confirmation after SIGKILL: settlement waits until the
|
|
115
|
+
* attempt-owned tree is confirmed gone (exact group probe, or the direct
|
|
116
|
+
* child's close where no process group exists). If it cannot be confirmed
|
|
117
|
+
* within cleanupConfirmationMs, settlement becomes a cleanup failure —
|
|
118
|
+
* never success. This is a cleanup bound, not an idle/tool timeout.
|
|
119
|
+
* @param {() => void} settleClean @param {() => void} settleCleanupFailure
|
|
120
|
+
*/
|
|
121
|
+
const confirmOwnedCleanup = (settleClean, settleCleanupFailure) => {
|
|
122
|
+
const startedAt = Date.now()
|
|
123
|
+
const confirm = () => {
|
|
124
|
+
if (settled) return
|
|
125
|
+
if (ownedTreeGone()) {
|
|
126
|
+
terminationTimer = null
|
|
127
|
+
settleClean()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if (Date.now() - startedAt >= cleanupConfirmationMs) {
|
|
131
|
+
terminationTimer = null
|
|
132
|
+
settleCleanupFailure()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
terminationTimer = setTimer(confirm, CLEANUP_PROBE_INTERVAL_MS)
|
|
136
|
+
}
|
|
137
|
+
confirm()
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Grace expiry shared by protocol, cancellation, and failure escalation:
|
|
141
|
+
* SIGKILL whatever of the attempt-owned tree remains, then settle only
|
|
142
|
+
* after bounded cleanup confirmation — never while owned processes still
|
|
143
|
+
* exist.
|
|
144
|
+
* @param {() => void} settleClean @param {() => void} settleCleanupFailure
|
|
145
|
+
*/
|
|
146
|
+
const escalateAndConfirm = (settleClean, settleCleanupFailure) => {
|
|
147
|
+
if (settled) return
|
|
148
|
+
terminationTimer = null
|
|
149
|
+
if (!ownedTreeGone()) {
|
|
150
|
+
try {
|
|
151
|
+
forwardSignal("SIGKILL")
|
|
152
|
+
} catch {
|
|
153
|
+
/* Owned processes may already be gone. */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
confirmOwnedCleanup(settleClean, settleCleanupFailure)
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Shared failure escalation: after the grace period, SIGKILL the exact
|
|
160
|
+
* attempt-owned group and reject with the original failure once cleanup is
|
|
161
|
+
* confirmed.
|
|
162
|
+
*/
|
|
163
|
+
const armFailureEscalation = () => {
|
|
164
|
+
terminationTimer = setTimer(() => {
|
|
165
|
+
if (settled || shutdownError === null) return
|
|
166
|
+
const error = shutdownError
|
|
167
|
+
escalateAndConfirm(
|
|
168
|
+
() => rejectAndCleanup(error),
|
|
169
|
+
() => rejectAndCleanup(error)
|
|
170
|
+
)
|
|
171
|
+
}, terminationGracePeriodMs)
|
|
172
|
+
}
|
|
79
173
|
/** @param {Error} error @param {boolean} [terminateChild] */
|
|
80
174
|
const fail = (error, terminateChild = true) => {
|
|
81
175
|
if (settled || shutdownError) return
|
|
176
|
+
// Failure always replaces any pending protocol-success or cancellation
|
|
177
|
+
// escalation atomically, so an explicit failure can never lose to a
|
|
178
|
+
// success timer.
|
|
179
|
+
clearTerminationTimer()
|
|
82
180
|
if (!terminateChild) {
|
|
83
181
|
rejectAndCleanup(error)
|
|
84
182
|
return
|
|
85
183
|
}
|
|
86
184
|
if (closeSeen) {
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
185
|
+
if (ownedTreeGone()) {
|
|
186
|
+
// Safe post-close path: no surviving attempt-owned group, so no
|
|
187
|
+
// signal is sent (win32 still terminates the direct child handle).
|
|
188
|
+
if (processGroupPid === null) {
|
|
189
|
+
try {
|
|
190
|
+
forwardSignal("SIGTERM")
|
|
191
|
+
} catch {
|
|
192
|
+
/* Child may already be gone. */
|
|
193
|
+
}
|
|
92
194
|
}
|
|
195
|
+
rejectAndCleanup(error)
|
|
196
|
+
return
|
|
93
197
|
}
|
|
94
|
-
|
|
198
|
+
// The direct child closed but attempt-owned descendants survive:
|
|
199
|
+
// terminate and escalate before rejecting the original failure.
|
|
200
|
+
shutdownError = error
|
|
201
|
+
detachForShutdown()
|
|
202
|
+
try {
|
|
203
|
+
forwardSignal("SIGTERM")
|
|
204
|
+
} catch {
|
|
205
|
+
/* Group members may already be gone. */
|
|
206
|
+
}
|
|
207
|
+
armFailureEscalation()
|
|
95
208
|
return
|
|
96
209
|
}
|
|
97
210
|
shutdownError = error
|
|
@@ -101,17 +214,102 @@ export function runWorker(options) {
|
|
|
101
214
|
} catch {
|
|
102
215
|
/* Child may already be gone. */
|
|
103
216
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
217
|
+
armFailureEscalation()
|
|
218
|
+
}
|
|
219
|
+
/** Explicit caller cancellation/deadline: failure semantics, never success. */
|
|
220
|
+
const onAbort = () => {
|
|
221
|
+
if (cancellationSignal !== null) return
|
|
222
|
+
const reason = options.signal?.aborted ? options.signal.reason : undefined
|
|
223
|
+
fail(reason instanceof Error ? reason : new Error("Worker run aborted"))
|
|
224
|
+
}
|
|
225
|
+
const settleProtocolSuccess = () => {
|
|
226
|
+
// Protocol success can never settle over an explicit failure or abort.
|
|
227
|
+
if (settled || shutdownError) return
|
|
228
|
+
settled = true
|
|
229
|
+
cleanup()
|
|
230
|
+
resolve(0)
|
|
114
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Grace-period escalation after protocol completion. Success settles only
|
|
234
|
+
* once the attempt-owned tree is confirmed gone after SIGKILL; if cleanup
|
|
235
|
+
* cannot be confirmed within the bounded window, the run settles as a
|
|
236
|
+
* cleanup failure — never success.
|
|
237
|
+
*/
|
|
238
|
+
const escalateProtocolTermination = () => {
|
|
239
|
+
escalateAndConfirm(
|
|
240
|
+
settleProtocolSuccess,
|
|
241
|
+
() => rejectAndCleanup(new CleanupConfirmationError("protocol completion"))
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The provider protocol confirmed a completed run: stop consuming output
|
|
246
|
+
* and terminate the attempt-owned process tree instead of waiting for the
|
|
247
|
+
* provider to exit on its own. Success resolves exactly once with status 0;
|
|
248
|
+
* a provider or descendant that ignores SIGTERM is escalated and never
|
|
249
|
+
* leaked.
|
|
250
|
+
*/
|
|
251
|
+
const completeProtocol = () => {
|
|
252
|
+
if (settled || shutdownError || protocolComplete) return
|
|
253
|
+
protocolComplete = true
|
|
254
|
+
child.stdout?.removeListener("data", onStdout)
|
|
255
|
+
child.stderr?.removeListener("data", onStderr)
|
|
256
|
+
// Process signal handlers deliberately stay installed: a parent
|
|
257
|
+
// SIGINT/SIGTERM during protocol settlement must be handled by onCancel,
|
|
258
|
+
// never hit the default action.
|
|
259
|
+
if (ownedTreeGone()) return
|
|
260
|
+
try {
|
|
261
|
+
forwardSignal("SIGTERM")
|
|
262
|
+
} catch {
|
|
263
|
+
/* Child may already be gone. */
|
|
264
|
+
}
|
|
265
|
+
terminationTimer = setTimer(escalateProtocolTermination, terminationGracePeriodMs)
|
|
266
|
+
}
|
|
267
|
+
const settleCancellation = () => {
|
|
268
|
+
if (settled || shutdownError) return
|
|
269
|
+
settled = true
|
|
270
|
+
cleanup()
|
|
271
|
+
resolve(signalExitCode(cancellationSignal))
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Grace-period escalation for explicit parent cancellation: settle with the
|
|
275
|
+
* original signal-derived status only once the attempt-owned tree is
|
|
276
|
+
* confirmed gone, SIGKILLing the exact group when members survive and
|
|
277
|
+
* failing cleanup (never succeeding) when confirmation times out.
|
|
278
|
+
*/
|
|
279
|
+
const escalateCancellation = () => {
|
|
280
|
+
escalateAndConfirm(
|
|
281
|
+
settleCancellation,
|
|
282
|
+
() => rejectAndCleanup(new CleanupConfirmationError(`cancellation (${String(cancellationSignal)})`))
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Parent SIGINT/SIGTERM is explicit cancellation owned by Threadwire: stop
|
|
287
|
+
* consuming provider output, forward the signal to the exact attempt-owned
|
|
288
|
+
* process group, and arm the grace/escalation timer. During the
|
|
289
|
+
* protocol-complete settlement window it atomically replaces the pending
|
|
290
|
+
* protocol-success escalation: explicit cancellation always keeps the
|
|
291
|
+
* forwarded signal's exit semantics (never protocol success), settles
|
|
292
|
+
* exactly once, and cleans the owned tree first. After close (without
|
|
293
|
+
* protocol completion) it stays the safe no-signal no-op.
|
|
294
|
+
* @param {NodeJS.Signals} signal
|
|
295
|
+
*/
|
|
296
|
+
const onCancel = (signal) => {
|
|
297
|
+
if (settled || shutdownError || cancellationSignal !== null) return
|
|
298
|
+
if (!protocolComplete && closeSeen) return
|
|
299
|
+
cancellationSignal = signal
|
|
300
|
+
// Atomically replace any pending protocol-success escalation timer.
|
|
301
|
+
clearTerminationTimer()
|
|
302
|
+
child.stdout?.removeListener("data", onStdout)
|
|
303
|
+
child.stderr?.removeListener("data", onStderr)
|
|
304
|
+
try {
|
|
305
|
+
forwardSignal(signal)
|
|
306
|
+
} catch {
|
|
307
|
+
/* Child may already be gone. */
|
|
308
|
+
}
|
|
309
|
+
terminationTimer = setTimer(escalateCancellation, terminationGracePeriodMs)
|
|
310
|
+
}
|
|
311
|
+
const onInterrupt = () => onCancel("SIGINT")
|
|
312
|
+
const onTerminate = () => onCancel("SIGTERM")
|
|
115
313
|
/**
|
|
116
314
|
* @param {() => Promise<void>} task
|
|
117
315
|
* @param {NodeJS.ReadableStream | undefined} stream
|
|
@@ -120,7 +318,7 @@ export function runWorker(options) {
|
|
|
120
318
|
stream?.pause()
|
|
121
319
|
consumer = consumer
|
|
122
320
|
.then(async () => {
|
|
123
|
-
if (settled || shutdownError) return
|
|
321
|
+
if (settled || shutdownError || protocolComplete || cancellationSignal !== null) return
|
|
124
322
|
await task()
|
|
125
323
|
})
|
|
126
324
|
.then(() => {
|
|
@@ -133,18 +331,21 @@ export function runWorker(options) {
|
|
|
133
331
|
}
|
|
134
332
|
/** @param {Buffer | string} chunk */
|
|
135
333
|
const onStdout = (chunk) => {
|
|
136
|
-
if (settled || shutdownError) return
|
|
334
|
+
if (settled || shutdownError || protocolComplete || cancellationSignal !== null) return
|
|
137
335
|
enqueueConsumer(async () => {
|
|
138
336
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
139
337
|
await options.onStdoutChunk?.(bytes)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
338
|
+
for (const record of spool.push(bytes)) {
|
|
339
|
+
// Once completion fires, stop consuming this chunk immediately:
|
|
340
|
+
// trailing records must never emit duplicate output or consumer
|
|
341
|
+
// failures.
|
|
342
|
+
if (await parseLine(record, options.parse, options.onEvent, options.onRecord, options.completion, completeProtocol)) break
|
|
343
|
+
}
|
|
143
344
|
}, child.stdout)
|
|
144
345
|
}
|
|
145
346
|
/** @param {Buffer | string} chunk */
|
|
146
347
|
const onStderr = (chunk) => {
|
|
147
|
-
if (settled || shutdownError) return
|
|
348
|
+
if (settled || shutdownError || protocolComplete || cancellationSignal !== null) return
|
|
148
349
|
enqueueConsumer(
|
|
149
350
|
async () => {
|
|
150
351
|
await options.onStderrChunk?.(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
@@ -176,24 +377,52 @@ export function runWorker(options) {
|
|
|
176
377
|
}
|
|
177
378
|
try {
|
|
178
379
|
await consumer
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
380
|
+
// A consumer failure that landed during the wait owns settlement
|
|
381
|
+
// through its armed escalation; never resolve over it.
|
|
382
|
+
if (settled || shutdownError !== null) return
|
|
383
|
+
const tail = spool.end()
|
|
384
|
+
if (tail.trim().length > 0) {
|
|
385
|
+
await parseLine(tail, options.parse, options.onEvent, options.onRecord, options.completion, completeProtocol)
|
|
386
|
+
}
|
|
182
387
|
} catch (error) {
|
|
183
388
|
fail(toError(error), false)
|
|
184
389
|
return
|
|
185
390
|
}
|
|
391
|
+
// Protocol completion with surviving group members defers settlement to
|
|
392
|
+
// the escalation timer started by completeProtocol.
|
|
393
|
+
if (protocolComplete && !ownedTreeGone()) return
|
|
186
394
|
settled = true
|
|
187
395
|
cleanup()
|
|
188
|
-
resolve(closeCode ?? signalExitCode(closeSignal))
|
|
396
|
+
resolve(protocolComplete ? 0 : (closeCode ?? signalExitCode(closeSignal)))
|
|
189
397
|
}
|
|
190
398
|
/** @param {number | null} code @param {NodeJS.Signals | null} signal */
|
|
191
399
|
const onClose = (code, signal) => {
|
|
192
400
|
if (settled) return
|
|
193
401
|
closeSeen = true
|
|
194
|
-
removeSignalHandlers(onInterrupt, onTerminate)
|
|
195
402
|
closeCode = code
|
|
196
403
|
closeSignal = signal
|
|
404
|
+
// An explicit failure (including an abort after protocol completion)
|
|
405
|
+
// always outranks protocol success and cancellation at close.
|
|
406
|
+
if (shutdownError !== null) {
|
|
407
|
+
// Surviving owned processes retain the escalation timer so SIGKILL
|
|
408
|
+
// still reaches the exact group before rejection.
|
|
409
|
+
if (!ownedTreeGone()) return
|
|
410
|
+
clearTerminationTimer()
|
|
411
|
+
void finalizeClose()
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
// Explicit cancellation settles with the forwarded signal's status once
|
|
415
|
+
// the owned tree is gone; surviving members keep the escalation alive.
|
|
416
|
+
if (cancellationSignal !== null) {
|
|
417
|
+
if (ownedTreeGone()) settleCancellation()
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
if (protocolComplete) {
|
|
421
|
+
// Settle promptly only when the entire attempt-owned tree is gone;
|
|
422
|
+
// otherwise the retained grace/escalation timer owns settlement.
|
|
423
|
+
if (ownedTreeGone()) settleProtocolSuccess()
|
|
424
|
+
return
|
|
425
|
+
}
|
|
197
426
|
clearTerminationTimer()
|
|
198
427
|
void finalizeClose()
|
|
199
428
|
}
|
|
@@ -202,15 +431,20 @@ export function runWorker(options) {
|
|
|
202
431
|
child.once("spawn", onSpawn)
|
|
203
432
|
child.once("error", onError)
|
|
204
433
|
child.once("close", onClose)
|
|
205
|
-
|
|
206
|
-
|
|
434
|
+
// Persistent until final cleanup: handlers must outlive every settlement
|
|
435
|
+
// window so a real parent signal can never hit the default action.
|
|
436
|
+
process.on("SIGINT", onInterrupt)
|
|
437
|
+
process.on("SIGTERM", onTerminate)
|
|
438
|
+
options.signal?.addEventListener("abort", onAbort, {once: true})
|
|
439
|
+
if (options.signal?.aborted) onAbort()
|
|
207
440
|
})
|
|
208
441
|
}
|
|
209
442
|
|
|
210
|
-
export class
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
443
|
+
export class CleanupConfirmationError extends Error {
|
|
444
|
+
/** @param {string} context */
|
|
445
|
+
constructor(context) {
|
|
446
|
+
super(`Worker cleanup after ${context} could not be confirmed within the bounded confirmation window`)
|
|
447
|
+
this.name = "CleanupConfirmationError"
|
|
214
448
|
}
|
|
215
449
|
}
|
|
216
450
|
|
|
@@ -225,23 +459,15 @@ export function childEnvironment(environment = process.env, provider) {
|
|
|
225
459
|
return childEnvironment
|
|
226
460
|
}
|
|
227
461
|
|
|
228
|
-
/** @param {string}
|
|
229
|
-
async function
|
|
230
|
-
|
|
231
|
-
const tail = lines.pop() ?? ""
|
|
232
|
-
for (const line of lines) await parseLine(line, parse, onEvent, onRecord)
|
|
233
|
-
return tail
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** @param {string} line @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord */
|
|
237
|
-
async function parseLine(line, parse, onEvent, onRecord) {
|
|
238
|
-
if (line.trim().length === 0) return
|
|
462
|
+
/** @param {string} line @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord @param {RunWorkerOptions["completion"]} completion @param {() => void} onProtocolComplete @returns {Promise<boolean>} whether protocol completion fired */
|
|
463
|
+
async function parseLine(line, parse, onEvent, onRecord, completion, onProtocolComplete) {
|
|
464
|
+
if (line.trim().length === 0) return false
|
|
239
465
|
let record
|
|
240
466
|
try {
|
|
241
467
|
record = JSON.parse(line)
|
|
242
468
|
} catch {
|
|
243
469
|
await onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
|
|
244
|
-
return
|
|
470
|
+
return false
|
|
245
471
|
}
|
|
246
472
|
await onRecord?.(record)
|
|
247
473
|
let events
|
|
@@ -249,9 +475,14 @@ async function parseLine(line, parse, onEvent, onRecord) {
|
|
|
249
475
|
events = parse(record)
|
|
250
476
|
} catch {
|
|
251
477
|
await onEvent({type: "diagnostic", level: "warning", summary: "Worker emitted an unreadable event"})
|
|
252
|
-
return
|
|
478
|
+
return false
|
|
253
479
|
}
|
|
254
480
|
for (const event of events) await onEvent(event)
|
|
481
|
+
if (completion?.(record) === true) {
|
|
482
|
+
onProtocolComplete()
|
|
483
|
+
return true
|
|
484
|
+
}
|
|
485
|
+
return false
|
|
255
486
|
}
|
|
256
487
|
|
|
257
488
|
/** @param {NodeJS.Signals | null} signal */
|
|
@@ -282,22 +513,6 @@ function toError(error) {
|
|
|
282
513
|
return error instanceof Error ? error : new Error("Worker event consumer failed")
|
|
283
514
|
}
|
|
284
515
|
|
|
285
|
-
/** @param {Buffer} bytes @param {number} currentBytes @param {number} maximumBytes */
|
|
286
|
-
function nextRecordByteCount(bytes, currentBytes, maximumBytes) {
|
|
287
|
-
let recordBytes = currentBytes
|
|
288
|
-
let segmentStart = 0
|
|
289
|
-
for (let index = 0; index < bytes.length; index += 1) {
|
|
290
|
-
if (bytes[index] !== 0x0a) continue
|
|
291
|
-
recordBytes += index - segmentStart
|
|
292
|
-
if (recordBytes > maximumBytes) throw new StdoutRecordTooLargeError()
|
|
293
|
-
recordBytes = 0
|
|
294
|
-
segmentStart = index + 1
|
|
295
|
-
}
|
|
296
|
-
recordBytes += bytes.length - segmentStart
|
|
297
|
-
if (recordBytes > maximumBytes) throw new StdoutRecordTooLargeError()
|
|
298
|
-
return recordBytes
|
|
299
|
-
}
|
|
300
|
-
|
|
301
516
|
/** @param {number} value @param {string} name */
|
|
302
517
|
function positiveSafeInteger(value, name) {
|
|
303
518
|
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import {createHash, randomUUID, timingSafeEqual} from "node:crypto"
|
|
4
|
+
import {abortable} from "../absolute-deadline.js"
|
|
4
5
|
import {evidenceTelegramDestination} from "../evidence-store.js"
|
|
5
6
|
import {NoticeQueue} from "../notice-queue.js"
|
|
6
7
|
import {createFetchTransport} from "../notifiers/fetch-transport.js"
|
|
@@ -30,8 +31,8 @@ import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
|
|
|
30
31
|
* kimiBinding?: unknown,
|
|
31
32
|
* activity?: Pick<import("../activity-log.js").ActivityLog, "recordStarted" | "recordSession" | "close">,
|
|
32
33
|
* evidenceStore?: import("../evidence-store.js").EvidenceStore,
|
|
33
|
-
* isolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "
|
|
34
|
-
* kimiIsolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "
|
|
34
|
+
* isolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "startRun">,
|
|
35
|
+
* kimiIsolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "startRun">,
|
|
35
36
|
* onWorkerFailure?: (message: string) => void,
|
|
36
37
|
* onWorkerSettled?: () => void
|
|
37
38
|
* }} IngressDependencies
|
|
@@ -127,30 +128,44 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
127
128
|
})
|
|
128
129
|
: undefined
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
131
|
+
/** @type {ReturnType<typeof createProvider>} */
|
|
132
|
+
let provider
|
|
133
|
+
/** @type {ReturnType<typeof createTelegramSender>} */
|
|
134
|
+
let sender
|
|
135
|
+
/** @type {WorkerControl} */
|
|
136
|
+
let control
|
|
137
|
+
/** @type {Awaited<ReturnType<import("../evidence-store.js").EvidenceStore["createArtifact"]>> | undefined} */
|
|
138
|
+
let evidence
|
|
139
|
+
try {
|
|
140
|
+
provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment)
|
|
141
|
+
const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
|
|
142
|
+
sender = createTelegramSenderImpl(job.target, transport)
|
|
143
|
+
control = new WorkerControlImpl({
|
|
144
|
+
sender,
|
|
145
|
+
processNumber,
|
|
146
|
+
toolMessages: config.toolMessages ?? false,
|
|
147
|
+
NoticeQueueClass: NoticeQueueImpl,
|
|
148
|
+
RelayClass: RelayImpl
|
|
149
|
+
})
|
|
150
|
+
const evidenceOwner = dependencies.evidenceStore?.createOwnerScope({
|
|
151
|
+
destinationId: evidenceTelegramDestination(job.target, job.senderId),
|
|
152
|
+
runId: randomUUID()
|
|
153
|
+
})
|
|
154
|
+
evidence = evidenceOwner === undefined ? undefined : await dependencies.evidenceStore?.createArtifact(evidenceOwner, {
|
|
155
|
+
contentType: "text/plain; charset=utf-8",
|
|
156
|
+
redactions: await collectEvidenceRedactions(dependencies.providerEnvironment ?? {})
|
|
157
|
+
})
|
|
158
|
+
} catch (error) {
|
|
159
|
+
earlyKimiPreflight?.deadline?.close()
|
|
160
|
+
throw error
|
|
161
|
+
}
|
|
148
162
|
let evidenceTransferred = false
|
|
149
163
|
try {
|
|
150
164
|
await evidence?.append("prompt", `prompt\n${job.prompt}\nprovider-stream\n`)
|
|
151
165
|
|
|
152
166
|
if (isolatedRuntimeClient !== undefined) {
|
|
153
167
|
evidenceTransferred = true
|
|
168
|
+
let isolatedDeadline = earlyKimiPreflight?.deadline
|
|
154
169
|
try {
|
|
155
170
|
const preflight = earlyKimiPreflight ?? await isolatedRuntimeClient.preflight({
|
|
156
171
|
provider: job.provider,
|
|
@@ -160,7 +175,8 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
160
175
|
: {repositoryRoot: cwd, cwd}),
|
|
161
176
|
providerArguments: []
|
|
162
177
|
})
|
|
163
|
-
|
|
178
|
+
isolatedDeadline = preflight.deadline
|
|
179
|
+
const isolatedRun = isolatedRuntimeClient.startRun({
|
|
164
180
|
preflightId: preflight.preflightId,
|
|
165
181
|
prompt: job.prompt,
|
|
166
182
|
providerArguments: [],
|
|
@@ -175,6 +191,13 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
175
191
|
onStderrChunk: (chunk) => evidence?.append("provider-stderr", chunk)
|
|
176
192
|
})
|
|
177
193
|
})
|
|
194
|
+
let exitCode
|
|
195
|
+
try {
|
|
196
|
+
exitCode = await abortable(isolatedRun.completion, preflight.deadline?.signal)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
await isolatedRun.cancel(error instanceof Error ? error : new Error("Isolated runtime run failed"))
|
|
199
|
+
throw error
|
|
200
|
+
}
|
|
178
201
|
if (exitCode !== 0) throw new Error(`Isolated ${job.provider === "kimi" ? "Kimi" : "Codex"} worker exited with status ${exitCode}`)
|
|
179
202
|
await control.close()
|
|
180
203
|
if (evidence !== undefined) {
|
|
@@ -187,6 +210,7 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
187
210
|
await evidence?.abort()
|
|
188
211
|
throw error
|
|
189
212
|
} finally {
|
|
213
|
+
isolatedDeadline?.close()
|
|
190
214
|
dependencies.onWorkerSettled?.()
|
|
191
215
|
}
|
|
192
216
|
}
|
|
@@ -235,6 +259,7 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
235
259
|
environment: providerEnvironment,
|
|
236
260
|
provider: provider.name,
|
|
237
261
|
parse: provider.parse,
|
|
262
|
+
...(provider.completion === undefined ? {} : {completion: provider.completion}),
|
|
238
263
|
onEvent: async (event) => control.accept(event),
|
|
239
264
|
onSpawn: (pid) => {
|
|
240
265
|
if (pid !== undefined) dependencies.activity?.recordStarted(provider.name, pid)
|
|
@@ -318,7 +343,11 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
318
343
|
|
|
319
344
|
await spawned
|
|
320
345
|
} finally {
|
|
321
|
-
|
|
346
|
+
try {
|
|
347
|
+
if (!evidenceTransferred) await evidence?.abort()
|
|
348
|
+
} finally {
|
|
349
|
+
earlyKimiPreflight?.deadline?.close()
|
|
350
|
+
}
|
|
322
351
|
}
|
|
323
352
|
}
|
|
324
353
|
|