threadwire 0.1.22 → 0.1.24

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,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Fix a race in isolated-runtime worker cleanup: Docker's ContainerStop returns
6
+ HTTP 304 when the owned container exits before the stop call, and
7
+ ContainerKill returns HTTP 409 when the container exits between the post-stop
8
+ reinspection and the kill call. `stopAndRemoveWorkerContainer` now recognizes
9
+ only the endpoint-specific structured status for each call (304 at stop, 409
10
+ at kill), reinspects the exact container ID, and continues with removal only
11
+ when the reinspection confirms a terminal state. Genuine stop/kill errors
12
+ still fail closed, no broad cleanup is performed, and lineage retention
13
+ behavior is unchanged. Add focused lifecycle tests for 304 stop, 409 kill,
14
+ 304 kill, and genuine error paths.
15
+
16
+ - Complete every short filesystem write while spooling oversized worker JSONL
17
+ records, preventing truncated or corrupted worker output. Reject stalled,
18
+ invalid, or impossible write counts and clean up the private spool artifact
19
+ instead of silently accepting partial data.
20
+
5
21
  - Fix Node 24 release-prepublish timeouts in `run-worker` process-tree cleanup.
6
22
  The dedicated `npm-release` service now runs with Docker's `init: true` so
7
23
  terminated descendants are reaped instead of becoming zombies that keep the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
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) => {
@@ -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 = {}) {
@@ -4,6 +4,14 @@ import {closeSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writ
4
4
  import {tmpdir} from "node:os"
5
5
  import {isAbsolute, join, normalize} from "node:path"
6
6
 
7
+ const filesystemOperations = {writeSync}
8
+
9
+ /**
10
+ * @typedef {object} FilesystemOperations
11
+ * @property {(fileDescriptor: number, buffer: Buffer, offset: number, length: number) => number} writeSync
12
+ * Write bytes to a file descriptor and return the count written.
13
+ */
14
+
7
15
  /**
8
16
  * Single-owner byte-oriented spool for one worker's pending JSONL record.
9
17
  *
@@ -21,13 +29,14 @@ import {isAbsolute, join, normalize} from "node:path"
21
29
  * owner calls it on every settlement path.
22
30
  */
23
31
  export class JsonlRecordSpool {
24
- /** @param {{memoryBytes: number, maxBytes: number, directory?: string}} options */
32
+ /** @param {{memoryBytes: number, maxBytes: number, directory?: string, filesystem?: FilesystemOperations}} options */
25
33
  constructor(options) {
26
34
  this.memoryBytes = positiveSafeInteger(options.memoryBytes, "memoryBytes")
27
35
  this.maxBytes = positiveSafeInteger(options.maxBytes, "maxBytes")
28
36
  const directory = options.directory ?? tmpdir()
29
37
  if (!isAbsolute(directory) || normalize(directory) !== directory) throw new Error("directory must be a normalized absolute path")
30
38
  this.directory = directory
39
+ this.filesystem = options.filesystem ?? filesystemOperations
31
40
  /** @type {Buffer[]} */
32
41
  this.segments = []
33
42
  this.pendingBytes = 0
@@ -120,7 +129,7 @@ export class JsonlRecordSpool {
120
129
  if (this.file === null) this.append(finalSegment)
121
130
  else {
122
131
  this.pendingBytes = recordBytes
123
- writeSync(this.file.fd, finalSegment)
132
+ this.writeAll(this.file.fd, finalSegment)
124
133
  }
125
134
  const file = this.file
126
135
  if (file === null) throw new Error("JSONL record spool file is unavailable")
@@ -137,7 +146,7 @@ export class JsonlRecordSpool {
137
146
  this.pendingBytes += segment.length
138
147
  if (this.pendingBytes > this.maxBytes) throw new StdoutRecordTooLargeError()
139
148
  if (this.file !== null) {
140
- writeSync(this.file.fd, segment)
149
+ this.writeAll(this.file.fd, segment)
141
150
  return
142
151
  }
143
152
  if (this.pendingBytes <= this.memoryBytes) {
@@ -150,9 +159,26 @@ export class JsonlRecordSpool {
150
159
  const path = join(this.ownedDirectory, "pending-record")
151
160
  const fd = openSync(path, "wx", 0o600)
152
161
  this.file = {fd, path}
153
- for (const buffered of this.segments) writeSync(fd, buffered)
162
+ for (const buffered of this.segments) this.writeAll(fd, buffered)
154
163
  this.segments = []
155
- writeSync(fd, segment)
164
+ this.writeAll(fd, segment)
165
+ }
166
+
167
+ /**
168
+ * Write a complete Buffer to one file descriptor, looping over short writes.
169
+ * @param {number} fd
170
+ * @param {Buffer} buffer
171
+ */
172
+ writeAll(fd, buffer) {
173
+ let offset = 0
174
+ while (offset < buffer.length) {
175
+ const written = this.filesystem.writeSync(fd, buffer, offset, buffer.length - offset)
176
+ if (written === 0) throw new Error("Spool write made zero bytes of progress")
177
+ if (!Number.isSafeInteger(written) || written < 0 || written > buffer.length - offset) {
178
+ throw new Error(`Spool write returned invalid byte count: ${written}`)
179
+ }
180
+ offset += written
181
+ }
156
182
  }
157
183
 
158
184
  /** Remove the current record's private spill directory, if one exists. */