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 +20 -0
- package/package.json +1 -1
- package/src/absolute-deadline.js +9 -2
- package/src/cli.js +12 -3
- package/src/docker-api.js +11 -2
- package/src/isolated-runtime-client.js +2 -2
- package/src/isolated-runtime.js +19 -3
- package/src/provider-capacity-kimi.js +12 -3
- package/src/provider-capacity.js +1 -1
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
package/src/absolute-deadline.js
CHANGED
|
@@ -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
|
|
91
|
-
throw new
|
|
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 <
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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))
|
package/src/isolated-runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
+
throw new CapacityProbeError("protocol", "Kimi usages response is not valid JSON")
|
|
74
83
|
}
|
|
75
84
|
return normalizeKimiUsages(parsed)
|
|
76
85
|
}
|
package/src/provider-capacity.js
CHANGED
|
@@ -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 =
|
|
216
|
+
const remainingPercent = Number((BigInt(remaining) * 100n) / BigInt(limit))
|
|
217
217
|
return {
|
|
218
218
|
usedPercent: 100 - remainingPercent,
|
|
219
219
|
remainingPercent,
|