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.
- package/CHANGELOG.md +24 -0
- package/README.md +14 -8
- package/TELEGRAM-INGRESS.md +7 -7
- package/docs/capacity-admission.md +147 -0
- package/docs/container-runtime.md +18 -34
- package/docs/development-container.md +77 -0
- package/docs/isolated-provider-runtime.md +23 -20
- package/package.json +2 -3
- package/scripts/provider-shims/front-door.sh.template +1 -5
- package/scripts/verify-package.js +8 -3
- package/src/absolute-deadline.js +6 -3
- package/src/activity-log.js +1 -14
- package/src/cli.js +145 -60
- package/src/docker-api.js +8 -0
- package/src/isolated-runtime-client.js +124 -4
- package/src/isolated-runtime.js +145 -11
- package/src/jsonl-record-spool.js +178 -0
- package/src/kimi-oauth-store.js +16 -0
- package/src/provider-capacity-codex.js +176 -0
- package/src/provider-capacity-kimi.js +105 -0
- package/src/provider-capacity.js +292 -0
- package/src/providers/index.js +4 -4
- package/src/providers/kimi.js +55 -5
- package/src/run-worker.js +290 -75
- package/src/telegram-ingress/config.js +14 -8
- package/src/telegram-ingress/core.js +78 -47
- package/src/telegram-ingress/http.js +0 -6
- package/src/telegram-webhook.js +11 -10
- package/src/workspace-profile.js +0 -212
- package/threadwire.workspace-profiles.json +0 -12
|
@@ -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", [
|
package/src/absolute-deadline.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
/* eslint-disable jsdoc/require-jsdoc */
|
|
3
3
|
|
|
4
4
|
export class AbsoluteDeadline {
|
|
5
|
-
|
|
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))
|
package/src/activity-log.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
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"
|
|
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
|
|
303
|
+
const usesIsolatedRuntime = parsed.relayWrite
|
|
217
304
|
const isolatedRuntimeClient = usesIsolatedRuntime
|
|
218
|
-
?
|
|
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:
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
145
|
-
if (isWorkerEventEnvelope(frame.record)) await
|
|
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)) {
|