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/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 {StringDecoder} from "node:string_decoder"
5
+ import {JsonlRecordSpool, StdoutRecordTooLargeError} from "./jsonl-record-spool.js"
6
6
  import {buildProviderEnvironment} from "./telegram-ingress/config.js"
7
7
 
8
- const DEFAULT_MAX_STDOUT_RECORD_BYTES = 1_048_576
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
- const stdoutDecoder = new StringDecoder("utf8")
33
- let stdoutBuffer = ""
34
- let stdoutRecordBytes = 0
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 onInterrupt = () => forwardSignal("SIGINT")
54
- const onTerminate = () => forwardSignal("SIGTERM")
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
- removeSignalHandlers(onInterrupt, onTerminate)
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 (processGroupPid === null) {
88
- try {
89
- forwardSignal("SIGTERM")
90
- } catch {
91
- /* Child may already be gone. */
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
- rejectAndCleanup(error)
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
- 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)
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
- stdoutRecordBytes = nextRecordByteCount(bytes, stdoutRecordBytes, maxStdoutRecordBytes)
141
- stdoutBuffer += stdoutDecoder.write(bytes)
142
- stdoutBuffer = await drainLines(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
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
- if (settled) return
180
- stdoutBuffer += stdoutDecoder.end()
181
- if (stdoutBuffer.trim().length > 0) await parseLine(stdoutBuffer, options.parse, options.onEvent, options.onRecord)
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
- process.once("SIGINT", onInterrupt)
206
- process.once("SIGTERM", onTerminate)
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 StdoutRecordTooLargeError extends Error {
211
- constructor() {
212
- super("Worker stdout record exceeded the configured byte capacity")
213
- this.name = "StdoutRecordTooLargeError"
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} buffer @param {RunWorkerOptions["parse"]} parse @param {RunWorkerOptions["onEvent"]} onEvent @param {RunWorkerOptions["onRecord"]} onRecord */
229
- async function drainLines(buffer, parse, onEvent, onRecord) {
230
- const lines = buffer.split("\n")
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" | "run">,
34
- * kimiIsolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
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
- const provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment)
131
- const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
132
- const sender = createTelegramSenderImpl(job.target, transport)
133
- const control = new WorkerControlImpl({
134
- sender,
135
- processNumber,
136
- toolMessages: config.toolMessages ?? false,
137
- NoticeQueueClass: NoticeQueueImpl,
138
- RelayClass: RelayImpl
139
- })
140
- const evidenceOwner = dependencies.evidenceStore?.createOwnerScope({
141
- destinationId: evidenceTelegramDestination(job.target, job.senderId),
142
- runId: randomUUID()
143
- })
144
- const evidence = evidenceOwner === undefined ? undefined : await dependencies.evidenceStore?.createArtifact(evidenceOwner, {
145
- contentType: "text/plain; charset=utf-8",
146
- redactions: await collectEvidenceRedactions(dependencies.providerEnvironment ?? {})
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
- const exitCode = await isolatedRuntimeClient.run({
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
- if (!evidenceTransferred) await evidence?.abort()
346
+ try {
347
+ if (!evidenceTransferred) await evidence?.abort()
348
+ } finally {
349
+ earlyKimiPreflight?.deadline?.close()
350
+ }
322
351
  }
323
352
  }
324
353