threadwire 0.1.5 → 0.1.8

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +17 -4
  3. package/TELEGRAM-INGRESS.md +8 -2
  4. package/bin/isolated-runtime.js +5 -0
  5. package/bin/model-broker.js +5 -0
  6. package/docs/card-10520-plan.md +45 -0
  7. package/docs/container-runtime.md +3 -2
  8. package/docs/delegated-result-protocol.md +161 -0
  9. package/docs/evidence-artifacts.md +106 -0
  10. package/docs/isolated-provider-runtime.md +137 -0
  11. package/package.json +5 -1
  12. package/scripts/provider-shims/front-door.sh.template +18 -0
  13. package/scripts/verify-package.js +22 -1
  14. package/src/absolute-deadline.js +94 -0
  15. package/src/cli.js +362 -42
  16. package/src/context-budget-metrics.js +180 -0
  17. package/src/delegated-result-admission.js +377 -0
  18. package/src/docker-api.js +131 -0
  19. package/src/evidence-store.js +1472 -0
  20. package/src/isolated-runtime-client.js +149 -0
  21. package/src/isolated-runtime.js +982 -0
  22. package/src/isolated-state.js +409 -0
  23. package/src/isolated-worker.js +123 -0
  24. package/src/model-broker-policy.js +139 -0
  25. package/src/model-broker.js +313 -0
  26. package/src/mount-policy.js +28 -0
  27. package/src/normalized-output.js +68 -0
  28. package/src/notice-queue.js +22 -7
  29. package/src/providers/opencode.js +42 -18
  30. package/src/relay-write.js +44 -0
  31. package/src/relay.js +6 -6
  32. package/src/run-worker.js +158 -40
  33. package/src/telegram-ingress/command.js +16 -0
  34. package/src/telegram-ingress/config.js +96 -11
  35. package/src/telegram-ingress/core.js +201 -89
  36. package/src/telegram-ingress/http.js +17 -1
  37. package/src/telegram-webhook.js +22 -0
  38. package/src/types.js +2 -2
  39. package/src/worker-control.js +294 -0
  40. package/src/hermes-protocol.js +0 -126
@@ -0,0 +1,294 @@
1
+ // @ts-check
2
+
3
+ import {NoticeQueue} from "./notice-queue.js"
4
+ import {Relay} from "./relay.js"
5
+
6
+ export class WorkerControl {
7
+ /**
8
+ * @param {{
9
+ * sender: import("./types.js").NoticeSender,
10
+ * processNumber: number,
11
+ * toolMessages?: boolean,
12
+ * maxOutputLength?: number,
13
+ * sentenceLatencyMs?: number,
14
+ * maxAssistantBufferLength?: number,
15
+ * setTimer?: (callback: () => void, milliseconds: number) => unknown,
16
+ * clearTimer?: (handle: unknown) => void,
17
+ * NoticeQueueClass?: typeof NoticeQueue,
18
+ * RelayClass?: typeof Relay,
19
+ * metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics,
20
+ * queueOptions?: Omit<ConstructorParameters<typeof NoticeQueue>[0], "sender" | "onDelivered">
21
+ * }} options
22
+ */
23
+ constructor(options) {
24
+ this.processLabel = `[P${options.processNumber}]`
25
+ this.metrics = options.metrics
26
+ this.toolMessages = options.toolMessages ?? false
27
+ this.saturationText = `${this.processLabel} Notifier saturated; coalescing progress updates.`
28
+ this.failure = undefined
29
+ this.saturated = false
30
+ this.saturationNoticeSent = false
31
+ this.coalescedNotice = undefined
32
+ /** @type {import("./types.js").RenderedNotice[]} */
33
+ this.renderedNotices = []
34
+ /** @type {Array<() => void>} */
35
+ this.availabilityWaiters = []
36
+ /** @type {Promise<void>} */
37
+ this.operation = Promise.resolve()
38
+ /** @type {Promise<void> | undefined} */
39
+ this.finalizePromise = undefined
40
+ /** @type {Promise<void> | undefined} */
41
+ this.closePromise = undefined
42
+ const sender = {
43
+ /** @param {string | import("./types.js").OutgoingMessage} message */
44
+ send: async (message) => {
45
+ const sent = await options.sender.send(message)
46
+ this.noteAvailability()
47
+ return sent
48
+ },
49
+ ...(options.sender.edit === undefined
50
+ ? {}
51
+ : {
52
+ /** @param {number} messageId @param {string | import("./types.js").OutgoingMessage} message */
53
+ edit: async (messageId, message) => {
54
+ await options.sender.edit?.(messageId, message)
55
+ this.noteAvailability()
56
+ }
57
+ })
58
+ }
59
+ const NoticeQueueClass = options.NoticeQueueClass ?? NoticeQueue
60
+ const RelayClass = options.RelayClass ?? Relay
61
+ this.queue = new NoticeQueueClass({
62
+ sender,
63
+ ...(options.queueOptions ?? {}),
64
+ onDelivered: (metricClass, bytes) => this.metrics?.recordProgress(metricClass, bytes, "delivered")
65
+ })
66
+ this.maxCoalescedBytes = this.queue.maxPendingBytes
67
+ const relayQueue = {
68
+ maxLength: this.queue.maxLength,
69
+ enqueue: /** @param {import("./types.js").RenderedNotice} notice */ (notice) => {
70
+ this.renderedNotices.push(notice)
71
+ this.scheduleBackgroundFlush()
72
+ },
73
+ close: async () => this.finalizeInternal()
74
+ }
75
+ this.relay = new RelayClass({
76
+ queue: /** @type {import("./notice-queue.js").NoticeQueue} */ (/** @type {unknown} */ (relayQueue)),
77
+ processNumber: options.processNumber,
78
+ ...(options.toolMessages === undefined ? {} : {toolMessages: options.toolMessages}),
79
+ ...(options.maxOutputLength === undefined ? {} : {maxOutputLength: options.maxOutputLength}),
80
+ ...(options.sentenceLatencyMs === undefined ? {} : {sentenceLatencyMs: options.sentenceLatencyMs}),
81
+ ...(options.maxAssistantBufferLength === undefined ? {} : {maxAssistantBufferLength: options.maxAssistantBufferLength}),
82
+ ...(options.setTimer === undefined ? {} : {setTimer: options.setTimer}),
83
+ ...(options.clearTimer === undefined ? {} : {clearTimer: options.clearTimer})
84
+ })
85
+ }
86
+
87
+ /** @param {import("./types.js").WorkerEvent} event */
88
+ accept(event) {
89
+ const metricClass = event.type === "text-delta" ? "assistant" : event.type
90
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8")
91
+ this.metrics?.recordProgress(metricClass, bytes, "attempted")
92
+ if (event.type === "tool" && !this.toolMessages) this.metrics?.recordProgress("tool", bytes, "suppressed")
93
+ return this.enqueueOperation(async () => {
94
+ this.ensureHealthy()
95
+ this.relay.accept(event)
96
+ await this.flushLoop(false)
97
+ this.ensureHealthy()
98
+ })
99
+ }
100
+
101
+ close() {
102
+ if (!this.closePromise) {
103
+ this.closePromise = this.enqueueOperation(async () => {
104
+ await this.relay.close()
105
+ })
106
+ }
107
+ return this.closePromise
108
+ }
109
+
110
+ noteAvailability() {
111
+ this.signalAvailability()
112
+ if (this.failure || this.finalizePromise || this.queue.failure || this.queue.closed) return
113
+ this.scheduleBackgroundFlush()
114
+ }
115
+
116
+ scheduleBackgroundFlush() {
117
+ void this.enqueueOperation(async () => {
118
+ await this.flushLoop(false)
119
+ }).catch((error) => {
120
+ this.failure = toError(error)
121
+ })
122
+ }
123
+
124
+ /** @param {() => Promise<void>} operation */
125
+ enqueueOperation(operation) {
126
+ const next = this.operation.then(operation, operation)
127
+ this.operation = next.catch(() => {})
128
+ return next
129
+ }
130
+
131
+ async finalizeInternal() {
132
+ if (!this.finalizePromise) {
133
+ this.finalizePromise = (async () => {
134
+ await this.flushLoop(true)
135
+ await this.queue.close()
136
+ if (this.failure) throw this.failure
137
+ })()
138
+ }
139
+ return this.finalizePromise
140
+ }
141
+
142
+ /** @param {boolean} drainAll */
143
+ async flushLoop(drainAll) {
144
+ while (true) {
145
+ this.ensureHealthy()
146
+ if (this.tryFlushSaturationNotice()) continue
147
+ if (this.renderedNotices.length > 0) {
148
+ const notice = this.renderedNotices[0]
149
+ if (!notice) return
150
+ if (isCoalescibleAssistantProgress(notice, this.processLabel)) {
151
+ this.renderedNotices.shift()
152
+ if (this.canResumeHealthyProgress() && this.tryEnqueue(notice)) {
153
+ this.clearSaturation()
154
+ continue
155
+ }
156
+ if (!this.saturated && this.tryEnqueue(notice)) continue
157
+ this.saturated = true
158
+ this.metrics?.recordProgress("assistant", Buffer.byteLength(notice.text, "utf8"), "coalesced")
159
+ this.coalescedNotice = mergeCoalescedNotice(this.coalescedNotice, notice, this.maxCoalescedBytes)
160
+ continue
161
+ }
162
+ if (this.tryEnqueue(notice)) {
163
+ this.renderedNotices.shift()
164
+ continue
165
+ }
166
+ await this.waitForAvailability()
167
+ continue
168
+ }
169
+ if (this.saturated && !this.saturationNoticeSent) {
170
+ if (!drainAll) return
171
+ await this.waitForAvailability()
172
+ continue
173
+ }
174
+ if (this.coalescedNotice !== undefined) {
175
+ if (this.tryEnqueue(this.coalescedNotice)) {
176
+ this.coalescedNotice = undefined
177
+ this.clearSaturation()
178
+ continue
179
+ }
180
+ if (!drainAll) return
181
+ await this.waitForAvailability()
182
+ continue
183
+ }
184
+ return
185
+ }
186
+ }
187
+
188
+ tryFlushSaturationNotice() {
189
+ if (!this.saturated || this.saturationNoticeSent) return false
190
+ const notice = {text: this.saturationText, label: `${this.processLabel} `}
191
+ if (!this.tryEnqueue(notice)) return false
192
+ this.saturationNoticeSent = true
193
+ return true
194
+ }
195
+
196
+ canResumeHealthyProgress() {
197
+ return this.saturated && this.coalescedNotice === undefined && this.saturationNoticeSent
198
+ }
199
+
200
+ clearSaturation() {
201
+ this.saturated = false
202
+ this.saturationNoticeSent = false
203
+ }
204
+
205
+ /** @param {import("./types.js").RenderedNotice} notice */
206
+ tryEnqueue(notice) {
207
+ const noticeBytes = Buffer.byteLength(notice.text, "utf8")
208
+ if (noticeBytes > this.queue.maxPendingBytes) {
209
+ this.queue.enqueue(notice)
210
+ return true
211
+ }
212
+ if (this.queue.pendingNoticeCount >= this.queue.maxPendingNotices) return false
213
+ if (this.queue.pendingBytes + noticeBytes > this.queue.maxPendingBytes) return false
214
+ this.queue.enqueue(notice)
215
+ return true
216
+ }
217
+
218
+ async waitForAvailability() {
219
+ this.ensureHealthy()
220
+ if (!this.queue.processing) return
221
+ await Promise.race([
222
+ this.queue.work ?? Promise.resolve(),
223
+ new Promise((resolve) => { this.availabilityWaiters.push(() => resolve(undefined)) })
224
+ ])
225
+ }
226
+
227
+ signalAvailability() {
228
+ const waiters = this.availabilityWaiters.splice(0)
229
+ for (const resolve of waiters) resolve()
230
+ }
231
+
232
+ ensureHealthy() {
233
+ if (this.failure) throw this.failure
234
+ if (this.queue.failure) throw this.queue.failure
235
+ if (this.queue.closed) throw new Error("Notice queue is closed")
236
+ }
237
+ }
238
+
239
+ /**
240
+ * @param {import("./types.js").RenderedNotice} notice
241
+ * @param {string} processLabel
242
+ */
243
+ function isCoalescibleAssistantProgress(notice, processLabel) {
244
+ return !("activity" in notice) && notice.format === undefined && notice.label === `${processLabel} Assistant: `
245
+ }
246
+
247
+ /**
248
+ * @param {import("./types.js").TextNotice | undefined} current
249
+ * @param {import("./types.js").TextNotice} incoming
250
+ * @param {number} maxBytes
251
+ * @returns {import("./types.js").TextNotice}
252
+ */
253
+ function mergeCoalescedNotice(current, incoming, maxBytes) {
254
+ const next = trimAssistantNotice(incoming, maxBytes)
255
+ if (
256
+ current === undefined
257
+ || current.format !== incoming.format
258
+ || current.label !== incoming.label
259
+ || current.batchSeparator !== incoming.batchSeparator
260
+ ) return next
261
+ const combined = {...current, text: `${current.text}${incoming.text}`}
262
+ if (Buffer.byteLength(combined.text, "utf8") <= maxBytes) return combined
263
+ return next
264
+ }
265
+
266
+ /** @param {import("./types.js").TextNotice} notice @param {number} maxBytes */
267
+ function trimAssistantNotice(notice, maxBytes) {
268
+ if (Buffer.byteLength(notice.text, "utf8") <= maxBytes) return notice
269
+ const label = notice.label ?? ""
270
+ const body = notice.text.startsWith(label) ? notice.text.slice(label.length) : notice.text
271
+ const available = Math.max(0, maxBytes - Buffer.byteLength(label, "utf8"))
272
+ return {...notice, text: `${label}${takeUtf8Prefix(body, available)}`}
273
+ }
274
+
275
+ /** @param {string} text @param {number} maxBytes */
276
+ function takeUtf8Prefix(text, maxBytes) {
277
+ let end = 0
278
+ let bytes = 0
279
+ while (end < text.length) {
280
+ const codePoint = text.codePointAt(end)
281
+ if (codePoint === undefined) break
282
+ const character = String.fromCodePoint(codePoint)
283
+ const nextBytes = Buffer.byteLength(character, "utf8")
284
+ if (bytes + nextBytes > maxBytes) break
285
+ bytes += nextBytes
286
+ end += character.length
287
+ }
288
+ return text.slice(0, end)
289
+ }
290
+
291
+ /** @param {unknown} error */
292
+ function toError(error) {
293
+ return error instanceof Error ? error : new Error("Worker control failed")
294
+ }
@@ -1,126 +0,0 @@
1
- // @ts-check
2
-
3
- import {redactSecrets, sanitizeControl, sanitizeOutput} from "./relay.js"
4
-
5
- export const MAX_ASSISTANT_TEXT_LENGTH = 16_384
6
- export const MAX_TOOL_NAME_LENGTH = 128
7
- export const MAX_TOOL_DETAIL_LENGTH = 1_024
8
- export const MAX_TOOL_OUTPUT_LENGTH = 4_096
9
- export const MAX_PROGRESS_RECORDS = 64
10
-
11
- /**
12
- * Low-volume, caller-facing JSONL projection of normalized worker events.
13
- * Text deltas are retained only for the terminal result; raw provider records
14
- * never enter this component.
15
- */
16
- export class HermesProtocol {
17
- /** @param {{output: NodeJS.WritableStream}} options */
18
- constructor(options) {
19
- this.output = options.output
20
- this.progressRecords = 0
21
- this.assistantText = ""
22
- this.assistantTextTruncated = false
23
- /** @type {Set<string>} */
24
- this.transitions = new Set()
25
- /** @type {Set<string>} */
26
- this.lifecyclePhases = new Set()
27
- /** @type {Map<string, {name: string, detail?: string}>} */
28
- this.activeTools = new Map()
29
- }
30
-
31
- /** @param {import("./types.js").WorkerEvent} event */
32
- accept(event) {
33
- if (event.type === "text-delta") {
34
- this.appendAssistantText(event.text)
35
- return
36
- }
37
- if (this.progressRecords >= MAX_PROGRESS_RECORDS) return
38
- if (event.type === "lifecycle") {
39
- if (this.lifecyclePhases.has(event.phase)) return
40
- this.lifecyclePhases.add(event.phase)
41
- this.emit({type: lifecycleType(event.phase), summary: safeSingleLine(event.summary, 512)})
42
- return
43
- }
44
- if (event.type === "diagnostic") {
45
- this.emit({type: "diagnostic", level: event.level, summary: safeSingleLine(event.summary, 512)})
46
- return
47
- }
48
- const transition = `${event.key}\0${event.phase}`
49
- if (this.transitions.has(transition)) return
50
- this.transitions.add(transition)
51
- const current = this.activeTools.get(event.key)
52
- const name = (current?.name ?? safeSingleLine(event.name, MAX_TOOL_NAME_LENGTH)) || "(tool)"
53
- const incomingDetail = safeOptionalSingleLine(event.detail, MAX_TOOL_DETAIL_LENGTH)
54
- const detail = current?.detail ?? incomingDetail
55
- if (event.phase === "started") this.activeTools.set(event.key, {name, ...(detail === undefined ? {} : {detail})})
56
- else this.activeTools.delete(event.key)
57
- const output = event.phase === "finished" ? safeOptionalOutput(event.output, MAX_TOOL_OUTPUT_LENGTH) : undefined
58
- this.emit({
59
- type: event.phase === "started" ? "tool_started" : "tool_finished",
60
- name,
61
- ...(detail === undefined ? {} : {detail}),
62
- ...(output === undefined ? {} : {output})
63
- })
64
- }
65
-
66
- /** @param {number} exitCode */
67
- complete(exitCode) {
68
- const assistantText = safeOptionalOutput(this.assistantText, MAX_ASSISTANT_TEXT_LENGTH, this.assistantTextTruncated)
69
- this.write({type: "completed", exitCode, ...(assistantText === undefined ? {} : {assistantText})})
70
- }
71
-
72
- /** @param {string} text */
73
- appendAssistantText(text) {
74
- if (this.assistantTextTruncated) return
75
- const remaining = MAX_ASSISTANT_TEXT_LENGTH - Array.from(this.assistantText).length
76
- const characters = Array.from(text)
77
- if (characters.length <= remaining) this.assistantText += text
78
- else {
79
- this.assistantText += characters.slice(0, Math.max(0, remaining - 1)).join("") + "…"
80
- this.assistantTextTruncated = true
81
- }
82
- }
83
-
84
- /** @param {Record<string, unknown>} record */
85
- emit(record) {
86
- this.progressRecords += 1
87
- this.write(record)
88
- }
89
-
90
- /** @param {Record<string, unknown>} record */
91
- write(record) {
92
- this.output.write(`${JSON.stringify(record)}\n`)
93
- }
94
- }
95
-
96
- /** @param {"started" | "completed" | "failed"} phase */
97
- function lifecycleType(phase) {
98
- return phase === "started" ? "worker_started" : phase === "failed" ? "worker_failed" : "worker_completed"
99
- }
100
-
101
- /** @param {string} text @param {number} limit */
102
- function safeSingleLine(text, limit) {
103
- return limitText(redactSecrets(sanitizeControl(text)), limit)
104
- }
105
-
106
- /** @param {string | undefined} text @param {number} limit */
107
- function safeOptionalSingleLine(text, limit) {
108
- if (text === undefined) return undefined
109
- const safe = safeSingleLine(text, limit)
110
- return safe.trim().length === 0 ? undefined : safe
111
- }
112
-
113
- /** @param {string | undefined} text @param {number} limit @param {boolean} [alreadyTruncated] */
114
- function safeOptionalOutput(text, limit, alreadyTruncated = false) {
115
- if (text === undefined) return undefined
116
- const safe = sanitizeOutput(text)
117
- if (safe === undefined) return undefined
118
- return alreadyTruncated ? safe : limitText(safe, limit)
119
- }
120
-
121
- /** @param {string} text @param {number} limit */
122
- function limitText(text, limit) {
123
- const characters = Array.from(text)
124
- if (characters.length <= limit) return text
125
- return `${characters.slice(0, limit - 1).join("")}…`
126
- }