threadwire 0.1.23 → 0.1.25

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 CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Harden `threadwire capacity` edge cases. Kimi quota remaining percent is now
6
+ computed with integer-safe arithmetic so values such as 29 % of 100 no longer
7
+ lose the inclusive reserve boundary to floating-point rounding. Kimi probe
8
+ body-read aborts, timeouts, and transport errors are classified as
9
+ `unavailable`, while oversized bodies and malformed JSON remain `protocol`;
10
+ no token or response detail is leaked. `--timeout-ms` is now rejected above
11
+ Node's maximum timer delay (`2147483647`), matching the existing positive
12
+ integer error convention and updated help text.
13
+
14
+ - Fix a race in isolated-runtime worker cleanup: Docker's ContainerStop returns
15
+ HTTP 304 when the owned container exits before the stop call, and
16
+ ContainerKill returns HTTP 409 when the container exits between the post-stop
17
+ reinspection and the kill call. `stopAndRemoveWorkerContainer` now recognizes
18
+ only the endpoint-specific structured status for each call (304 at stop, 409
19
+ at kill), reinspects the exact container ID, and continues with removal only
20
+ when the reinspection confirms a terminal state. Genuine stop/kill errors
21
+ still fail closed, no broad cleanup is performed, and lineage retention
22
+ behavior is unchanged. Add focused lifecycle tests for 304 stop, 409 kill,
23
+ 304 kill, and genuine error paths.
24
+
5
25
  - Complete every short filesystem write while spooling oversized worker JSONL
6
26
  records, preventing truncated or corrupted worker output. Reject stalled,
7
27
  invalid, or impossible write counts and clean up the private spool artifact
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -75,6 +75,13 @@ export async function abortable(promise, signal) {
75
75
  })
76
76
  }
77
77
 
78
+ export class ResponseCapacityError extends Error {
79
+ constructor() {
80
+ super("Response exceeded capacity")
81
+ this.name = "ResponseCapacityError"
82
+ }
83
+ }
84
+
78
85
  export async function readResponseCapped(response, capacity, signal) {
79
86
  const reader = response.body?.getReader()
80
87
  if (!reader) return Buffer.alloc(0)
@@ -87,8 +94,8 @@ export async function readResponseCapped(response, capacity, signal) {
87
94
  const chunk = Buffer.from(value)
88
95
  size += chunk.length
89
96
  if (size > capacity) {
90
- await reader.cancel(new Error("Response exceeded capacity")).catch(() => {})
91
- throw new Error("Response exceeded capacity")
97
+ await reader.cancel(new ResponseCapacityError()).catch(() => {})
98
+ throw new ResponseCapacityError()
92
99
  }
93
100
  chunks.push(chunk)
94
101
  }
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import {lstat, readFile, readlink, realpath} from "node:fs/promises"
4
4
  import {randomUUID} from "node:crypto"
5
5
  import {basename, dirname, isAbsolute, join, normalize, resolve} from "node:path"
6
6
  import {stdin, stderr, stdout} from "node:process"
7
- import {createFetchTransport} from "./notifiers/fetch-transport.js"
7
+ import {createFetchTransport, MAX_TIMER_DELAY_MS} from "./notifiers/fetch-transport.js"
8
8
  import {createTelegramSender, parseTelegramTarget} from "./notifiers/telegram.js"
9
9
  import {createProvider, PROVIDERS} from "./providers/index.js"
10
10
  import {runWorker} from "./run-worker.js"
@@ -45,7 +45,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
45
45
  (--summary | --events [--kind <kind>] [--after <order>] [--before <order>] [--limit <positive-integer>] | --failures [--after <order>] [--limit <positive-integer>])
46
46
  threadwire capacity [--provider <codex|kimi>]...
47
47
  [--short-reserve-percent <0-100>] [--long-reserve-percent <0-100>]
48
- [--timeout-ms <positive-integer>]
48
+ [--timeout-ms <1-2147483647>]
49
49
  threadwire status --activity-log <absolute-path>
50
50
  (Emits one closed versioned JSON status document from the activity log.)`
51
51
 
@@ -231,7 +231,7 @@ function parseCapacityArguments(arguments_) {
231
231
  providers.push(provider)
232
232
  } else if (option === "--short-reserve-percent") shortReservePercent = reservePercent(value, option)
233
233
  else if (option === "--long-reserve-percent") longReservePercent = reservePercent(value, option)
234
- else if (option === "--timeout-ms") timeoutMs = positiveInteger(value, option)
234
+ else if (option === "--timeout-ms") timeoutMs = capacityTimeoutMs(value, option)
235
235
  else throw new Error(HELP)
236
236
  }
237
237
  return {
@@ -843,6 +843,15 @@ function positiveInteger(value, option) {
843
843
  return number
844
844
  }
845
845
 
846
+ /** @param {string} value @param {string} option */
847
+ function capacityTimeoutMs(value, option) {
848
+ const number = positiveInteger(value, option)
849
+ if (number > MAX_TIMER_DELAY_MS) {
850
+ throw new Error(`${option} must be no greater than ${MAX_TIMER_DELAY_MS}`)
851
+ }
852
+ return number
853
+ }
854
+
846
855
  /** @param {string} value */
847
856
  function sessionId(value) {
848
857
  try {
package/src/docker-api.js CHANGED
@@ -3,6 +3,15 @@
3
3
 
4
4
  import {request as httpRequest} from "node:http"
5
5
 
6
+ export class DockerApiError extends Error {
7
+ /** @param {string} method @param {string} path @param {number} status */
8
+ constructor(method, path, status) {
9
+ super(`Docker API ${method} ${path} failed (${status})`)
10
+ this.name = "DockerApiError"
11
+ this.status = status
12
+ }
13
+ }
14
+
6
15
  export class DockerApi {
7
16
  /** @param {{host?: string, requestImplementation?: typeof httpRequest}} [options] */
8
17
  constructor(options = {}) {
@@ -38,7 +47,7 @@ export class DockerApi {
38
47
  const content = combined.toString("utf8")
39
48
  const status = response.statusCode ?? 500
40
49
  if (status < 200 || status >= 300) {
41
- reject(new Error(`Docker API ${method} ${path} failed (${status})`))
50
+ reject(new DockerApiError(method, path, status))
42
51
  return
43
52
  }
44
53
  if (combined.length === 0) {
@@ -135,7 +144,7 @@ export class DockerApi {
135
144
  const status = response.statusCode ?? 500
136
145
  if (status < 200 || status >= 300) {
137
146
  response.resume()
138
- fail(new Error(`Docker API GET /containers/${id}/logs failed (${status})`))
147
+ fail(new DockerApiError("GET", `/containers/${id}/logs`, status))
139
148
  return
140
149
  }
141
150
  response.on("data", (chunk) => {
@@ -1,7 +1,7 @@
1
1
  // @ts-nocheck
2
2
  /* eslint-disable jsdoc/require-jsdoc */
3
3
 
4
- import {AbsoluteDeadline, deadlineAfter, abortable, readResponseCapped} from "./absolute-deadline.js"
4
+ import {AbsoluteDeadline, deadlineAfter, abortable, readResponseCapped, ResponseCapacityError} from "./absolute-deadline.js"
5
5
 
6
6
  const MAX_RESPONSE_BYTES = 2_097_152
7
7
  const MAX_RAW_OUTPUT_BYTES = 1_048_576
@@ -207,7 +207,7 @@ export class IsolatedRuntimeClient {
207
207
  try {
208
208
  bytes = await readResponseCapped(response, MAX_RESPONSE_BYTES, signal)
209
209
  } catch (error) {
210
- if (error instanceof Error && error.message === "Response exceeded capacity") throw new Error("Isolated runtime response exceeded the configured capacity", {cause: error})
210
+ if (error instanceof ResponseCapacityError) throw new Error("Isolated runtime response exceeded the configured capacity", {cause: error})
211
211
  throw error
212
212
  }
213
213
  if (!response.ok) throw new Error(safeRemoteError(bytes))
@@ -6,7 +6,7 @@ import {once} from "node:events"
6
6
  import {lstat, realpath} from "node:fs/promises"
7
7
  import {createServer} from "node:http"
8
8
  import {isAbsolute, join, normalize, relative} from "node:path"
9
- import {DockerApi} from "./docker-api.js"
9
+ import {DockerApi, DockerApiError} from "./docker-api.js"
10
10
  import {buildKimiBindingValidatorSpec, buildKimiRelayContainerSpec, buildWorkerContainerSpec, isDigestImage} from "./isolated-worker.js"
11
11
  import {validateRelayWriteProviderArguments} from "./relay-write.js"
12
12
  import {codexSessionId, parseCodexEvent} from "./providers/codex.js"
@@ -723,11 +723,19 @@ async function stopAndRemoveWorkerContainer(docker, containerId, requestOptions)
723
723
  let inspection = await docker.inspectContainer(containerId, requestOptions)
724
724
  if (typeof inspection?.State?.Running !== "boolean") throw new Error("Worker container state unavailable")
725
725
  if (inspection.State.Running) {
726
- await docker.stopContainer(containerId, WORKER_STOP_GRACE_SECONDS, requestOptions)
726
+ try {
727
+ await docker.stopContainer(containerId, WORKER_STOP_GRACE_SECONDS, requestOptions)
728
+ } catch (error) {
729
+ if (!isContainerStopAlreadyStoppedError(error)) throw error
730
+ }
727
731
  inspection = await docker.inspectContainer(containerId, requestOptions)
728
732
  if (typeof inspection?.State?.Running !== "boolean") throw new Error("Worker container state unavailable")
729
733
  if (inspection.State.Running) {
730
- await docker.killContainer(containerId, "KILL", requestOptions)
734
+ try {
735
+ await docker.killContainer(containerId, "KILL", requestOptions)
736
+ } catch (error) {
737
+ if (!isContainerKillConflictError(error)) throw error
738
+ }
731
739
  inspection = await docker.inspectContainer(containerId, requestOptions)
732
740
  if (inspection?.State?.Running !== false) throw new ContainerCleanupConfirmationError()
733
741
  }
@@ -735,6 +743,14 @@ async function stopAndRemoveWorkerContainer(docker, containerId, requestOptions)
735
743
  await docker.removeContainer(containerId, requestOptions)
736
744
  }
737
745
 
746
+ function isContainerStopAlreadyStoppedError(error) {
747
+ return error instanceof DockerApiError && error.status === 304
748
+ }
749
+
750
+ function isContainerKillConflictError(error) {
751
+ return error instanceof DockerApiError && error.status === 409
752
+ }
753
+
738
754
  class ContainerCleanupConfirmationError extends Error {
739
755
  /** @param {{cause?: unknown}} [options] */
740
756
  constructor(options = {}) {
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import {isAbsolute, join} from "node:path"
4
- import {readResponseCapped} from "./absolute-deadline.js"
4
+ import {readResponseCapped, ResponseCapacityError} from "./absolute-deadline.js"
5
5
  import {openKimiOAuthStore} from "./kimi-oauth-store.js"
6
6
  import {CapacityProbeError, DEFAULT_CAPACITY_TIMEOUT_MS, normalizeKimiUsages} from "./provider-capacity.js"
7
7
 
@@ -64,13 +64,22 @@ export async function probeKimiCapacity(options) {
64
64
  if (contentType === null || !contentType.toLowerCase().includes("application/json")) {
65
65
  throw new CapacityProbeError("protocol", "Kimi usages response has an unexpected content type")
66
66
  }
67
+ /** @type {Buffer} */
68
+ let bytes
69
+ try {
70
+ bytes = await readResponseCapped(response, MAX_USAGES_BYTES, signal)
71
+ } catch (error) {
72
+ if (error instanceof ResponseCapacityError) {
73
+ throw new CapacityProbeError("protocol", "Kimi usages response body exceeded the size bound")
74
+ }
75
+ throw new CapacityProbeError("unavailable", "Kimi usages response body is unavailable")
76
+ }
67
77
  /** @type {unknown} */
68
78
  let parsed
69
79
  try {
70
- const bytes = await readResponseCapped(response, MAX_USAGES_BYTES, signal)
71
80
  parsed = JSON.parse(bytes.toString("utf8"))
72
81
  } catch {
73
- throw new CapacityProbeError("protocol", "Kimi usages response is not valid bounded JSON")
82
+ throw new CapacityProbeError("protocol", "Kimi usages response is not valid JSON")
74
83
  }
75
84
  return normalizeKimiUsages(parsed)
76
85
  }
@@ -213,7 +213,7 @@ function kimiQuotaWindow(value, windowDurationSeconds) {
213
213
  const used = quotaValue(value.used)
214
214
  const remaining = quotaValue(value.remaining)
215
215
  if (limit < 1 || used + remaining !== limit) throw kimiProtocolError()
216
- const remainingPercent = Math.floor((remaining / limit) * 100)
216
+ const remainingPercent = Number((BigInt(remaining) * 100n) / BigInt(limit))
217
217
  return {
218
218
  usedPercent: 100 - remainingPercent,
219
219
  remainingPercent,