threadwire 0.1.12 → 0.1.14

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.
@@ -24,9 +24,11 @@ const EXPECTED_FILES = [
24
24
  "bin/isolated-runtime.js",
25
25
  "bin/kimi-model-broker.js",
26
26
  "bin/model-broker.js",
27
+ "docs/capacity-admission.md",
27
28
  "docs/card-10520-plan.md",
28
29
  "docs/container-runtime.md",
29
30
  "docs/delegated-result-protocol.md",
31
+ "docs/development-container.md",
30
32
  "docs/evidence-artifacts.md",
31
33
  "docs/isolated-provider-runtime.md",
32
34
  "package.json",
@@ -50,6 +52,7 @@ const EXPECTED_FILES = [
50
52
  "src/isolated-runtime.js",
51
53
  "src/isolated-state.js",
52
54
  "src/isolated-worker.js",
55
+ "src/jsonl-record-spool.js",
53
56
  "src/kimi-model-broker-policy.js",
54
57
  "src/kimi-model-broker.js",
55
58
  "src/kimi-oauth-store.js",
@@ -66,6 +69,9 @@ const EXPECTED_FILES = [
66
69
  "src/providers/index.js",
67
70
  "src/providers/kimi.js",
68
71
  "src/providers/opencode.js",
72
+ "src/provider-capacity-codex.js",
73
+ "src/provider-capacity-kimi.js",
74
+ "src/provider-capacity.js",
69
75
  "src/relay.js",
70
76
  "src/relay-write.js",
71
77
  "src/run-worker.js",
@@ -79,9 +85,7 @@ const EXPECTED_FILES = [
79
85
  "src/telegram-webhook.js",
80
86
  "src/threadwire-binding.js",
81
87
  "src/types.js",
82
- "src/worker-control.js",
83
- "src/workspace-profile.js",
84
- "threadwire.workspace-profiles.json"
88
+ "src/worker-control.js"
85
89
  ]
86
90
 
87
91
  /** @returns {Promise<void>} */
@@ -125,6 +129,7 @@ async function main() {
125
129
  "--help"
126
130
  ], {env: npmEnvironment, maxBuffer: 10 * 1024 * 1024})
127
131
  assert.match(helpOutput, /^Usage: threadwire run /u)
132
+ assert.match(helpOutput, /threadwire capacity /u)
128
133
 
129
134
  await assert.rejects(
130
135
  execFileAsync("npm", [
@@ -2,7 +2,11 @@
2
2
  /* eslint-disable jsdoc/require-jsdoc */
3
3
 
4
4
  export class AbsoluteDeadline {
5
- constructor(expiresAt, {signal, request, response, now = Date.now, timeoutMessage = "Operation timeout", disconnectMessage = "Caller disconnected"} = {}) {
5
+ /**
6
+ * @param {number | undefined} expiresAt
7
+ * @param {{signal?: AbortSignal, request?: import("node:http").IncomingMessage, response?: import("node:http").ServerResponse, now?: () => number, timeoutMessage?: string, disconnectMessage?: string, destroyRequestOnAbort?: boolean}} [options]
8
+ */
9
+ constructor(expiresAt, {signal, request, response, now = Date.now, timeoutMessage = "Operation timeout", disconnectMessage = "Caller disconnected", destroyRequestOnAbort = true} = {}) {
6
10
  if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt <= now())) throw new Error(timeoutMessage)
7
11
  this.expiresAt = expiresAt
8
12
  this.now = now
@@ -27,13 +31,12 @@ export class AbsoluteDeadline {
27
31
  response?.once?.("close", this.abortResponse)
28
32
  this.socket?.once?.("close", this.abortSocket)
29
33
  this.timer = expiresAt === undefined ? undefined : setTimeout(() => this.controller.abort(new Error(timeoutMessage)), Math.max(1, expiresAt - now()))
30
- this.signal.addEventListener("abort", () => request?.destroy?.(this.signal.reason), {once: true})
34
+ if (destroyRequestOnAbort) this.signal.addEventListener("abort", () => request?.destroy?.(this.signal.reason), {once: true})
31
35
  if (request?.aborted || (response?.destroyed && !response.writableEnded)) this.abortRequest()
32
36
  }
33
37
  remaining() {
34
38
  this.throwIfAborted()
35
39
  if (this.expiresAt === undefined) return undefined
36
- if (this.expiresAt === undefined) return undefined
37
40
  const remaining = this.expiresAt - this.now()
38
41
  if (remaining <= 0) {
39
42
  this.controller.abort(new Error(this.timeoutMessage))
@@ -3,9 +3,6 @@
3
3
  import {closeSync, openSync, writeSync} from "node:fs"
4
4
 
5
5
  const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
6
- const PROFILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u
7
- const REVISION_PATTERN = /^[0-9a-f]{40}$/u
8
- const SOURCE_IDENTITY_PATTERN = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u
9
6
 
10
7
  export class ActivityLog {
11
8
  /** @param {string} path */
@@ -14,16 +11,6 @@ export class ActivityLog {
14
11
  this.closed = false
15
12
  }
16
13
 
17
- /** @param {string} profile @param {string} repositoryRoot @param {string} revision @param {string} sourceIdentity */
18
- recordWorkspace(profile, repositoryRoot, revision, sourceIdentity) {
19
- if (!PROFILE_PATTERN.test(profile)) throw new Error("Workspace profile is invalid")
20
- if (typeof repositoryRoot !== "string" || repositoryRoot.length === 0) throw new Error("Workspace repository root is invalid")
21
- if (!REVISION_PATTERN.test(revision) || !SOURCE_IDENTITY_PATTERN.test(sourceIdentity)) {
22
- throw new Error("Workspace provenance is invalid")
23
- }
24
- this.write({type: "workspace-selected", profile, repositoryRoot, revision, sourceIdentity})
25
- }
26
-
27
14
  /** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {number} pid */
28
15
  recordStarted(provider, pid) {
29
16
  if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Provider child PID is unavailable")
@@ -42,7 +29,7 @@ export class ActivityLog {
42
29
  closeSync(this.fileDescriptor)
43
30
  }
44
31
 
45
- /** @param {{type: "workspace-selected", profile: string, repositoryRoot: string, revision: string, sourceIdentity: string} | {type: "provider-started", provider: "codex" | "claude" | "kimi" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "kimi" | "opencode", sessionId: string}} fact */
32
+ /** @param {{type: "provider-started", provider: "codex" | "claude" | "kimi" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "kimi" | "opencode", sessionId: string}} fact */
46
33
  write(fact) {
47
34
  if (this.closed) throw new Error("Activity log is closed")
48
35
  writeSync(this.fileDescriptor, `${JSON.stringify(fact)}\n`)
package/src/cli.js CHANGED
@@ -11,20 +11,26 @@ import {runWorker} from "./run-worker.js"
11
11
  import {ActivityLog} from "./activity-log.js"
12
12
  import {DelegatedResultAdmission, validateContinuationHandle} from "./delegated-result-admission.js"
13
13
  import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
14
- import {resolveWorkspaceProfile} from "./workspace-profile.js"
15
14
  import {WorkerControl} from "./worker-control.js"
16
15
  import {EvidenceStore} from "./evidence-store.js"
17
16
  import {ContextBudgetMetrics} from "./context-budget-metrics.js"
18
- import {isolatedRuntimeClientFromEnvironment, kimiIsolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
19
- import {parseTrustedThreadwireBinding} from "./threadwire-binding.js"
20
- import {kimiSessionEnvelopeId} from "./providers/kimi.js"
17
+ import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
21
18
  import {validateRelayWriteProviderArguments} from "./relay-write.js"
22
19
  import {abortable} from "./absolute-deadline.js"
23
20
  import {NormalizedOutput} from "./normalized-output.js"
21
+ import {
22
+ CAPACITY_PROVIDERS,
23
+ DEFAULT_CAPACITY_TIMEOUT_MS,
24
+ DEFAULT_LONG_RESERVE_PERCENT,
25
+ DEFAULT_SHORT_RESERVE_PERCENT,
26
+ createCapacityProbe,
27
+ selectProvider
28
+ } from "./provider-capacity.js"
29
+ import {probeCodexCapacity} from "./provider-capacity-codex.js"
30
+ import {probeKimiCapacity} from "./provider-capacity-kimi.js"
24
31
 
25
32
  const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
26
33
  [--process-number <positive-integer>] [--cwd <directory>]
27
- [--workspace-profile <name>]
28
34
  [--relay-write]
29
35
  [--tool-messages] [--max-output-length <positive-integer>]
30
36
  [--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
@@ -32,10 +38,15 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
32
38
  [--prompt <text> | --prompt-file <path> | stdin]
33
39
  [-- <provider arguments...>]
34
40
  threadwire evidence read --handle <opaque-handle>
35
- (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
41
+ (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)
42
+ threadwire capacity [--provider <codex|kimi>]...
43
+ [--short-reserve-percent <0-100>] [--long-reserve-percent <0-100>]
44
+ [--timeout-ms <positive-integer>]`
36
45
 
37
- /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
46
+ /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
38
47
  /** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
48
+ /** @typedef {{capacity: true, providers: import("./provider-capacity.js").CapacityProvider[], shortReservePercent: number, longReservePercent: number, timeoutMs: number}} CapacityParsedArguments */
49
+ /** @typedef {{probe: (provider: import("./provider-capacity.js").CapacityProvider, timeoutMs?: number) => Promise<import("./provider-capacity.js").CapacityCandidate>}} CapacityProbeDependency */
39
50
  /**
40
51
  * @typedef {{
41
52
  * env?: NodeJS.ProcessEnv,
@@ -47,9 +58,9 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
47
58
  * evidenceStore?: EvidenceStore,
48
59
  * evidenceOwnerScope?: {destinationId: string, runId: string},
49
60
  * workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
50
- * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations,
51
- * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
52
- * kimiIsolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
61
+ * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "startRun">,
62
+ * capacityProbe?: CapacityProbeDependency,
63
+ * now?: () => number
53
64
  * }} MainDependencies
54
65
  */
55
66
 
@@ -63,7 +74,6 @@ export function parseArguments(arguments_) {
63
74
  const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
64
75
  /** @type {Partial<ParsedArguments>} */
65
76
  const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
66
- let cwdProvided = false
67
77
  let transcriptProvided = false
68
78
  for (let index = 0; index < ownArguments.length;) {
69
79
  const option = ownArguments[index]
@@ -86,8 +96,7 @@ export function parseArguments(arguments_) {
86
96
  else if (option === "--target") parsed.target = value
87
97
  else if (option === "--cwd") {
88
98
  parsed.cwd = resolve(value)
89
- cwdProvided = true
90
- } else if (option === "--workspace-profile") parsed.workspaceProfile = value
99
+ } else if (option === "--workspace-profile") throw new Error("--workspace-profile is no longer supported; use --cwd")
91
100
  else if (option === "--prompt") parsed.prompt = value
92
101
  else if (option === "--prompt-file") parsed.promptFile = resolve(value)
93
102
  else if (option === "--process-number") parsed.processNumber = positiveInteger(value, "--process-number")
@@ -108,14 +117,9 @@ export function parseArguments(arguments_) {
108
117
  }
109
118
  if (!parsed.target) throw new Error("--target is required")
110
119
  if (parsed.prompt !== undefined && parsed.promptFile !== undefined) throw new Error("Use exactly one prompt source")
111
- if (parsed.workspaceProfile !== undefined && cwdProvided) {
112
- throw new Error("--cwd and --workspace-profile are mutually exclusive")
113
- }
114
120
  if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
115
121
  throw new Error("--transcript and --activity-log must resolve to different paths")
116
122
  }
117
- if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
118
- if (parsed.provider === "kimi" && parsed.workspaceProfile === undefined) throw new Error("Kimi requires --workspace-profile")
119
123
  if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
120
124
  return /** @type {ParsedArguments} */ (parsed)
121
125
  }
@@ -148,6 +152,71 @@ function parseEvidenceArguments(arguments_) {
148
152
  return {evidenceRead: true, request}
149
153
  }
150
154
 
155
+ /** @param {string[]} arguments_ @returns {CapacityParsedArguments | {help: true}} */
156
+ function parseCapacityArguments(arguments_) {
157
+ if (arguments_.includes("--help") || arguments_.includes("-h")) return {help: true}
158
+ /** @type {import("./provider-capacity.js").CapacityProvider[]} */
159
+ const providers = []
160
+ let shortReservePercent = DEFAULT_SHORT_RESERVE_PERCENT
161
+ let longReservePercent = DEFAULT_LONG_RESERVE_PERCENT
162
+ let timeoutMs = DEFAULT_CAPACITY_TIMEOUT_MS
163
+ /** @type {Set<string>} */
164
+ const seen = new Set()
165
+ for (let index = 1; index < arguments_.length; index += 2) {
166
+ const option = arguments_[index]
167
+ const value = arguments_[index + 1]
168
+ if (!option || value === undefined) throw new Error(HELP)
169
+ if (option !== "--provider") {
170
+ if (seen.has(option)) throw new Error(`Duplicate capacity option: ${option}`)
171
+ seen.add(option)
172
+ }
173
+ if (option === "--provider") {
174
+ if (!CAPACITY_PROVIDERS.includes(/** @type {import("./provider-capacity.js").CapacityProvider} */ (value))) {
175
+ throw new Error(`--provider must be one of: ${CAPACITY_PROVIDERS.join(", ")}`)
176
+ }
177
+ const provider = /** @type {import("./provider-capacity.js").CapacityProvider} */ (value)
178
+ if (providers.includes(provider)) throw new Error(`Duplicate capacity provider: ${value}`)
179
+ providers.push(provider)
180
+ } else if (option === "--short-reserve-percent") shortReservePercent = reservePercent(value, option)
181
+ else if (option === "--long-reserve-percent") longReservePercent = reservePercent(value, option)
182
+ else if (option === "--timeout-ms") timeoutMs = positiveInteger(value, option)
183
+ else throw new Error(HELP)
184
+ }
185
+ return {
186
+ capacity: true,
187
+ providers: providers.length === 0 ? [...CAPACITY_PROVIDERS] : providers,
188
+ shortReservePercent,
189
+ longReservePercent,
190
+ timeoutMs
191
+ }
192
+ }
193
+
194
+ /**
195
+ * The real probe never rejects (it converts failures into candidates); this
196
+ * guard keeps any probe implementation from leaking an unexpected error into
197
+ * output by degrading to a fixed, credential-free candidate.
198
+ * @param {CapacityProbeDependency} probe
199
+ * @param {import("./provider-capacity.js").CapacityProvider} provider
200
+ * @param {number} timeoutMs
201
+ * @returns {Promise<import("./provider-capacity.js").CapacityCandidate>}
202
+ */
203
+ async function probeCapacityCandidate(probe, provider, timeoutMs) {
204
+ try {
205
+ return await probe.probe(provider, timeoutMs)
206
+ } catch {
207
+ return {provider, status: "unavailable", error: "Capacity probe failed"}
208
+ }
209
+ }
210
+
211
+ /** @param {string} value @param {string} option */
212
+ function reservePercent(value, option) {
213
+ const number = Number(value)
214
+ if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number > 100) {
215
+ throw new Error(`${option} must be an integer between 0 and 100`)
216
+ }
217
+ return number
218
+ }
219
+
151
220
  /** @param {string} value @param {boolean} zeroStart */
152
221
  function rangePair(value, zeroStart) {
153
222
  const match = /^(\d+):(\d+)$/u.exec(value)
@@ -175,11 +244,41 @@ export async function main(arguments_, dependencies = {}) {
175
244
  /** @type {DelegatedResultAdmission | undefined} */
176
245
  let runAdmission
177
246
  try {
178
- const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
247
+ const parsed = arguments_[0] === "evidence"
248
+ ? parseEvidenceArguments(arguments_)
249
+ : arguments_[0] === "capacity" ? parseCapacityArguments(arguments_) : parseArguments(arguments_)
179
250
  if ("help" in parsed) {
180
251
  if (!validateOnly) output.write(`${HELP}\n`)
181
252
  return 0
182
253
  }
254
+ if ("capacity" in parsed) {
255
+ if (validateOnly) return 0
256
+ const probe = dependencies.capacityProbe ?? createCapacityProbe({
257
+ env: sourceEnvironment,
258
+ probes: {
259
+ codex: ({env, timeoutMs}) => probeCodexCapacity({env, timeoutMs}),
260
+ kimi: ({env, timeoutMs}) => probeKimiCapacity({env, timeoutMs})
261
+ }
262
+ })
263
+ /** @type {import("./provider-capacity.js").CapacityCandidate[]} */
264
+ const candidates = []
265
+ for (const provider of parsed.providers) {
266
+ candidates.push(await probeCapacityCandidate(probe, provider, parsed.timeoutMs))
267
+ }
268
+ const {selection, rejected} = selectProvider(candidates, {
269
+ shortReservePercent: parsed.shortReservePercent,
270
+ longReservePercent: parsed.longReservePercent
271
+ })
272
+ output.write(`${JSON.stringify({
273
+ version: 1,
274
+ generatedAt: new Date((dependencies.now ?? Date.now)()).toISOString(),
275
+ reserves: {shortPercent: parsed.shortReservePercent, longPercent: parsed.longReservePercent},
276
+ candidates,
277
+ rejected,
278
+ selection
279
+ })}\n`)
280
+ return selection === null ? 2 : 0
281
+ }
183
282
  if ("evidenceRead" in parsed) {
184
283
  const root = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
185
284
  if (!root) throw new Error("THREADWIRE_EVIDENCE_ROOT is required")
@@ -200,35 +299,17 @@ export async function main(arguments_, dependencies = {}) {
200
299
  runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
201
300
  const target = parseTelegramTarget(parsed.target)
202
301
  if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
203
- const kimiBinding = parsed.provider === "kimi"
204
- ? parseTrustedThreadwireBinding(sourceEnvironment.THREADWIRE_KIMI_TASK_BINDING)
205
- : undefined
206
- const resolvedWorkspace = parsed.workspaceProfile === undefined
207
- ? undefined
208
- : await resolveWorkspaceProfile(
209
- {
210
- provider: /** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile,
211
- ...(kimiBinding === undefined ? {} : {binding: kimiBinding})
212
- },
213
- dependencies.workspaceProfileOperations
214
- )
215
302
  if (validateOnly) return 0
216
- const usesIsolatedRuntime = parsed.relayWrite || parsed.provider === "kimi"
303
+ const usesIsolatedRuntime = parsed.relayWrite
217
304
  const isolatedRuntimeClient = usesIsolatedRuntime
218
- ? parsed.provider === "kimi"
219
- ? dependencies.kimiIsolatedRuntimeClient ?? kimiIsolatedRuntimeClientFromEnvironment(sourceEnvironment)
220
- : dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
305
+ ? dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
221
306
  : undefined
222
307
  const isolatedPreflight = usesIsolatedRuntime
223
308
  ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
224
309
  provider: parsed.provider,
225
- profile: /** @type {string} */ (parsed.workspaceProfile),
226
- ...(parsed.provider === "kimi"
227
- ? {binding: kimiBinding}
228
- : {
229
- repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
230
- cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd
231
- }),
310
+ profile: "explicit-cwd",
311
+ repositoryRoot: parsed.cwd,
312
+ cwd: parsed.cwd,
232
313
  providerArguments: parsed.providerArguments,
233
314
  ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
234
315
  })
@@ -268,18 +349,18 @@ export async function main(arguments_, dependencies = {}) {
268
349
  await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
269
350
  evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
270
351
  }
271
- const providerEnvironment = buildProviderEnvironment(environment)
352
+ const providerEnvironment = buildProviderEnvironment(environment, {
353
+ provider: parsed.provider,
354
+ isolated: usesIsolatedRuntime
355
+ })
272
356
  const provider = createProvider(
273
357
  parsed.provider,
274
358
  parsed.providerArguments,
275
359
  prompt,
276
360
  parsed.resumeSession,
277
- usesIsolatedRuntime ? {} : providerEnvironment
361
+ providerEnvironment
278
362
  )
279
363
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
280
- if (parsed.workspaceProfile !== undefined) {
281
- if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
282
- }
283
364
  const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
284
365
  if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
285
366
  const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
@@ -292,21 +373,16 @@ export async function main(arguments_, dependencies = {}) {
292
373
  metrics
293
374
  })
294
375
  activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
295
- if (activity && resolvedWorkspace) {
296
- activity.recordWorkspace(
297
- resolvedWorkspace.profile,
298
- resolvedWorkspace.repositoryRoot,
299
- resolvedWorkspace.revision,
300
- resolvedWorkspace.sourceIdentity
301
- )
302
- }
303
376
  /** @type {import("./run-worker.js").RunWorkerOptions} */
304
377
  const workerOptions = {
305
378
  executable: provider.executable,
306
379
  arguments: provider.arguments,
307
- cwd: resolvedWorkspace?.cwd ?? parsed.cwd,
380
+ cwd: parsed.cwd,
308
381
  environment: providerEnvironment,
382
+ provider: provider.name,
309
383
  parse: provider.parse,
384
+ ...(provider.completion === undefined ? {} : {completion: provider.completion}),
385
+ ...(launchDeadline === undefined ? {} : {signal: launchDeadline.signal}),
310
386
  onEvent: (event) => {
311
387
  validateNormalizedWorkerEvent(event)
312
388
  if (event.type !== "text-delta") {
@@ -320,7 +396,7 @@ export async function main(arguments_, dependencies = {}) {
320
396
  },
321
397
  onRecord: (record) => {
322
398
  metrics.recordParsedProviderRecord("provider_stdout")
323
- const id = parsed.provider === "kimi" ? kimiSessionEnvelopeId(record) : provider.sessionId(record)
399
+ const id = provider.sessionId(record)
324
400
  if (id !== undefined) {
325
401
  admission.setContinuationHandle(id)
326
402
  activity?.recordSession(provider.name, id)
@@ -339,8 +415,9 @@ export async function main(arguments_, dependencies = {}) {
339
415
  return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
340
416
  }
341
417
  }
342
- const exitCode = usesIsolatedRuntime
343
- ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).run({
418
+ let exitCode
419
+ if (usesIsolatedRuntime) {
420
+ const isolatedRun = /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).startRun({
344
421
  preflightId: /** @type {{preflightId: string}} */ (isolatedPreflight).preflightId,
345
422
  prompt,
346
423
  providerArguments: parsed.providerArguments,
@@ -351,7 +428,15 @@ export async function main(arguments_, dependencies = {}) {
351
428
  ...(parsed.provider === "kimi" || workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
352
429
  ...(launchDeadline === undefined ? {} : {deadline: launchDeadline})
353
430
  })
354
- : await (dependencies.workerRunner ?? runWorker)(workerOptions)
431
+ try {
432
+ exitCode = await abortable(isolatedRun.completion, launchDeadline?.signal)
433
+ } catch (error) {
434
+ await isolatedRun.cancel(error instanceof Error ? error : new Error("Isolated runtime run failed"))
435
+ throw error
436
+ }
437
+ } else {
438
+ exitCode = await (dependencies.workerRunner ?? runWorker)(workerOptions)
439
+ }
355
440
  await boundedLaunch(control.close(), launchDeadline)
356
441
  terminalExitCode = exitCode
357
442
  } finally {
package/src/docker-api.js CHANGED
@@ -172,6 +172,14 @@ export class DockerApi {
172
172
  })
173
173
  }
174
174
  inspectContainer(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/json`, undefined, false, options) }
175
+ async stopContainer(id, graceSeconds, options) {
176
+ if (!Number.isSafeInteger(graceSeconds) || graceSeconds <= 0) throw new Error("Docker stop grace must be a positive safe integer")
177
+ return await this.request("POST", `/containers/${encodeURIComponent(id)}/stop?t=${graceSeconds}`, undefined, false, options)
178
+ }
179
+ async killContainer(id, signal, options) {
180
+ if (typeof signal !== "string" || !/^[A-Z][A-Z0-9]{0,15}$/u.test(signal)) throw new Error("Docker kill signal is invalid")
181
+ return await this.request("POST", `/containers/${encodeURIComponent(id)}/kill?signal=${encodeURIComponent(signal)}`, undefined, false, options)
182
+ }
175
183
  removeContainer(id, options) { return this.request("DELETE", `/containers/${encodeURIComponent(id)}?force=1&v=1`, undefined, false, options) }
176
184
  createVolume(name, labels = {}, options) {
177
185
  return this.request("POST", "/volumes/create", {Name: name, Labels: labels}, false, options)
@@ -5,14 +5,16 @@ import {AbsoluteDeadline, deadlineAfter, abortable, readResponseCapped} from "./
5
5
 
6
6
  const MAX_RESPONSE_BYTES = 2_097_152
7
7
  const MAX_RAW_OUTPUT_BYTES = 1_048_576
8
+ const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000
8
9
 
9
10
  export class IsolatedRuntimeClient {
10
- /** @param {{url: string, controlToken: string, timeoutMs?: number | string, fetchImplementation?: typeof fetch}} options */
11
+ /** @param {{url: string, controlToken: string, timeoutMs?: number | string, cleanupTimeoutMs?: number | string, fetchImplementation?: typeof fetch}} options */
11
12
  constructor(options) {
12
13
  this.url = normalizedUrl(options.url)
13
14
  this.controlToken = nonempty(options.controlToken, "isolated runtime control token")
14
15
  this.fetch = options.fetchImplementation ?? fetch
15
16
  this.timeoutMs = optionalDuration(options.timeoutMs)
17
+ this.cleanupTimeoutMs = optionalDuration(options.cleanupTimeoutMs) ?? DEFAULT_CLEANUP_TIMEOUT_MS
16
18
  }
17
19
 
18
20
  /**
@@ -79,6 +81,113 @@ export class IsolatedRuntimeClient {
79
81
  }
80
82
  }
81
83
 
84
+ /**
85
+ * Start a container-backed run whose request side remains open as the
86
+ * cancellation channel. Closing that side asks the runtime to stop its exact
87
+ * worker container while leaving the response available for terminal cleanup
88
+ * confirmation.
89
+ * @param {{
90
+ * preflightId: string, prompt: string, providerArguments: string[],
91
+ * resumeSession?: string,
92
+ * signal?: AbortSignal,
93
+ * deadline?: import("./absolute-deadline.js").AbsoluteDeadline,
94
+ * onEvent: (event: import("./types.js").WorkerEvent) => unknown | Promise<unknown>,
95
+ * onRecord?: (record: unknown) => unknown | Promise<unknown>,
96
+ * onStdoutChunk?: (chunk: Buffer) => unknown | Promise<unknown>,
97
+ * onStderrChunk?: (chunk: Buffer) => unknown | Promise<unknown>
98
+ * }} options
99
+ */
100
+ startRun(options) {
101
+ const deadline = options.deadline ?? (this.timeoutMs === undefined ? undefined : deadlineAfter(this.timeoutMs, {signal: options.signal, timeoutMessage: "Isolated runtime launch timeout"}))
102
+ const operation = deadline ?? new AbsoluteDeadline(undefined, {signal: options.signal, timeoutMessage: "Isolated runtime launch timeout"})
103
+ const requestController = new AbortController()
104
+ /** @type {ReadableStreamDefaultController<Uint8Array>} */
105
+ let bodyController
106
+ let bodyClosed = false
107
+ const body = new ReadableStream({
108
+ start(controller) {
109
+ bodyController = controller
110
+ controller.enqueue(new TextEncoder().encode(`${JSON.stringify({
111
+ preflightId: options.preflightId,
112
+ prompt: options.prompt,
113
+ providerArguments: options.providerArguments,
114
+ ...(options.resumeSession === undefined ? {} : {resumeSession: options.resumeSession})
115
+ })}\n`))
116
+ }
117
+ })
118
+ const closeRequest = () => {
119
+ if (bodyClosed) return
120
+ bodyClosed = true
121
+ bodyController.close()
122
+ }
123
+ /** @type {Error | undefined} */
124
+ let consumerError
125
+ /** @type {(error: Error) => void} */
126
+ let rejectConsumer
127
+ const consumerFailure = new Promise((_resolve, reject) => { rejectConsumer = reject })
128
+ const transportCompletion = (async () => {
129
+ const response = await this.fetch(`${this.url}/run`, {
130
+ method: "POST",
131
+ redirect: "error",
132
+ headers: {
133
+ authorization: `Bearer ${this.controlToken}`,
134
+ "content-type": "application/x-ndjson",
135
+ connection: "close",
136
+ "x-threadwire-run-lifecycle": "1",
137
+ ...(operation.expiresAt === undefined ? {} : {"x-threadwire-launch-deadline": String(operation.expiresAt)})
138
+ },
139
+ body,
140
+ duplex: "half",
141
+ signal: requestController.signal
142
+ })
143
+ if (!response.ok) {
144
+ const bytes = await readResponseCapped(response, MAX_RESPONSE_BYTES, requestController.signal)
145
+ throw new Error(safeRemoteError(bytes))
146
+ }
147
+ return consumeRunStream(response, options, {
148
+ signal: requestController.signal,
149
+ onConsumerError(error) {
150
+ if (consumerError !== undefined) return
151
+ consumerError = error
152
+ rejectConsumer(error)
153
+ }
154
+ })
155
+ })()
156
+ const runCompletion = Promise.race([transportCompletion, consumerFailure])
157
+ /** @type {Promise<void> | undefined} */
158
+ let cancellation
159
+ const cancel = (reason = new Error("Isolated runtime run cancelled")) => {
160
+ if (cancellation !== undefined) return cancellation
161
+ closeRequest()
162
+ const cleanup = deadlineAfter(this.cleanupTimeoutMs, {timeoutMessage: "Isolated runtime cleanup confirmation timeout"})
163
+ cancellation = (async () => {
164
+ try {
165
+ await abortable(transportCompletion, cleanup.signal)
166
+ } catch (error) {
167
+ requestController.abort(reason)
168
+ throw new Error("Isolated runtime cleanup could not be confirmed", {cause: error})
169
+ } finally {
170
+ cleanup.close()
171
+ }
172
+ })()
173
+ return cancellation
174
+ }
175
+ const completion = (async () => {
176
+ try {
177
+ return await abortable(runCompletion, operation.signal)
178
+ } catch (error) {
179
+ if (operation.signal.aborted || error === consumerError) {
180
+ await cancel(error instanceof Error ? error : new Error("Isolated runtime run failed"))
181
+ }
182
+ throw error
183
+ } finally {
184
+ closeRequest()
185
+ if (options.deadline === undefined) operation.close()
186
+ }
187
+ })()
188
+ return {completion, cancel}
189
+ }
190
+
82
191
  /** @param {string} path @param {unknown} body */
83
192
  async request(path, body, deadline) {
84
193
  const signal = deadline.signal
@@ -121,6 +230,17 @@ async function consumeRunStream(response, options, operation) {
121
230
  let rawBytes = 0
122
231
  /** @type {number | undefined} */
123
232
  let terminal
233
+ let consumerFailed = false
234
+ const invokeConsumer = async (callback) => {
235
+ if (consumerFailed) return
236
+ try {
237
+ await abortable(callback(), operation.signal)
238
+ } catch (error) {
239
+ if (operation.onConsumerError === undefined) throw error
240
+ consumerFailed = true
241
+ operation.onConsumerError(error instanceof Error ? error : new Error("Isolated runtime consumer failed"))
242
+ }
243
+ }
124
244
  const consume = async (line) => {
125
245
  if (line.length === 0 || terminal !== undefined) throw new Error("Isolated runtime emitted an invalid response")
126
246
  let frame
@@ -136,13 +256,13 @@ async function consumeRunStream(response, options, operation) {
136
256
  rawBytes += data.length
137
257
  if (rawBytes > MAX_RAW_OUTPUT_BYTES) throw new Error("Isolated runtime emitted an invalid response")
138
258
  const callback = frame.channel === "stdout" ? options.onStdoutChunk : options.onStderrChunk
139
- await abortable(callback?.(data), operation.signal)
259
+ if (callback !== undefined) await invokeConsumer(() => callback(data))
140
260
  return
141
261
  }
142
262
  if (frame.type === "record") {
143
263
  if (Object.keys(frame).sort().join(",") !== "record,type") throw new Error("Isolated runtime emitted an invalid response")
144
- await abortable(options.onRecord?.(frame.record), operation.signal)
145
- if (isWorkerEventEnvelope(frame.record)) await abortable(options.onEvent(frame.record.event), operation.signal)
264
+ if (options.onRecord !== undefined) await invokeConsumer(() => options.onRecord(frame.record))
265
+ if (isWorkerEventEnvelope(frame.record)) await invokeConsumer(() => options.onEvent(frame.record.event))
146
266
  return
147
267
  }
148
268
  if (frame.type === "terminal" && Object.keys(frame).sort().join(",") === "exitCode,type" && Number.isSafeInteger(frame.exitCode)) {