@botlearn-course/daemon 0.0.17-beta.2 → 0.0.17
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/dist/input-attachments.js +52 -11
- package/dist/run-dispatcher.js +1 -15
- package/package.json +1 -1
|
@@ -3,6 +3,11 @@ import { chmod, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
const MAX_ATTACHMENTS = 5;
|
|
5
5
|
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
// Abort a stalled transfer without rejecting a slow cross-cloud source while verified bytes
|
|
7
|
+
// continue to arrive. The absolute cap prevents a trickle response from holding a daemon turn
|
|
8
|
+
// indefinitely. These are daemon compatibility invariants, not environment tuning knobs.
|
|
9
|
+
const ATTACHMENT_DOWNLOAD_IDLE_TIMEOUT_MS = 60_000;
|
|
10
|
+
const ATTACHMENT_DOWNLOAD_MAX_DURATION_MS = 10 * 60_000;
|
|
6
11
|
const ATTACHMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
12
|
const SHA256 = /^[0-9a-f]{64}$/i;
|
|
8
13
|
const CONTENT_TYPE_EXTENSIONS = {
|
|
@@ -126,7 +131,7 @@ async function verifyExisting(filePath, attachment) {
|
|
|
126
131
|
throw error;
|
|
127
132
|
}
|
|
128
133
|
}
|
|
129
|
-
async function writeResponseToFile(response, partPath, attachment, signal) {
|
|
134
|
+
async function writeResponseToFile(response, partPath, attachment, signal, onDownloadProgress) {
|
|
130
135
|
if (!response.body)
|
|
131
136
|
throw new Error("input attachment response has no body");
|
|
132
137
|
const declaredLength = response.headers.get("content-length");
|
|
@@ -146,13 +151,17 @@ async function writeResponseToFile(response, partPath, attachment, signal) {
|
|
|
146
151
|
try {
|
|
147
152
|
const reader = response.body.getReader();
|
|
148
153
|
while (true) {
|
|
149
|
-
if (signal.aborted)
|
|
154
|
+
if (signal.aborted) {
|
|
155
|
+
if (signal.reason instanceof Error)
|
|
156
|
+
throw signal.reason;
|
|
150
157
|
throw new Error("input attachment download aborted");
|
|
158
|
+
}
|
|
151
159
|
const { done, value } = await reader.read();
|
|
152
160
|
if (done)
|
|
153
161
|
break;
|
|
154
162
|
const chunk = Buffer.from(value);
|
|
155
163
|
received += chunk.length;
|
|
164
|
+
onDownloadProgress?.();
|
|
156
165
|
if (received > attachment.size_bytes || received > MAX_ATTACHMENT_BYTES) {
|
|
157
166
|
throw new Error("input attachment response exceeds declared size");
|
|
158
167
|
}
|
|
@@ -183,6 +192,36 @@ async function writeResponseToFile(response, partPath, attachment, signal) {
|
|
|
183
192
|
await file.close();
|
|
184
193
|
}
|
|
185
194
|
}
|
|
195
|
+
function startAttachmentDownloadBudget(parentSignal) {
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
const abortFromParent = () => controller.abort(parentSignal.reason);
|
|
198
|
+
const abortStalledDownload = () => controller.abort(new Error("input attachment download stalled for 60s"));
|
|
199
|
+
const abortOverlongDownload = () => controller.abort(new Error("input attachment download exceeded the 600s maximum duration"));
|
|
200
|
+
if (parentSignal.aborted) {
|
|
201
|
+
abortFromParent();
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
parentSignal.addEventListener("abort", abortFromParent, { once: true });
|
|
205
|
+
}
|
|
206
|
+
const maxDurationTimer = setTimeout(abortOverlongDownload, ATTACHMENT_DOWNLOAD_MAX_DURATION_MS);
|
|
207
|
+
let idleTimer;
|
|
208
|
+
const recordProgress = () => {
|
|
209
|
+
if (idleTimer !== undefined)
|
|
210
|
+
clearTimeout(idleTimer);
|
|
211
|
+
idleTimer = setTimeout(abortStalledDownload, ATTACHMENT_DOWNLOAD_IDLE_TIMEOUT_MS);
|
|
212
|
+
};
|
|
213
|
+
recordProgress();
|
|
214
|
+
return {
|
|
215
|
+
signal: controller.signal,
|
|
216
|
+
recordProgress,
|
|
217
|
+
dispose: () => {
|
|
218
|
+
if (idleTimer !== undefined)
|
|
219
|
+
clearTimeout(idleTimer);
|
|
220
|
+
clearTimeout(maxDurationTimer);
|
|
221
|
+
parentSignal.removeEventListener("abort", abortFromParent);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
186
225
|
export async function materializeInputAttachments(workspaceDir, attachments, grant, signal) {
|
|
187
226
|
if (attachments.length > MAX_ATTACHMENTS) {
|
|
188
227
|
throw new Error("too many input attachments");
|
|
@@ -215,20 +254,22 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
|
|
|
215
254
|
const partPath = `${filePath}.part`;
|
|
216
255
|
await rm(partPath, { force: true });
|
|
217
256
|
const downloadUrl = downloadUrls.get(attachment.attachment_id);
|
|
218
|
-
const
|
|
219
|
-
headers: { "accept-encoding": "identity" },
|
|
220
|
-
redirect: "error",
|
|
221
|
-
signal,
|
|
222
|
-
});
|
|
223
|
-
if (!response.ok) {
|
|
224
|
-
throw new Error(`input attachment download failed with HTTP ${response.status}`);
|
|
225
|
-
}
|
|
257
|
+
const budget = startAttachmentDownloadBudget(signal);
|
|
226
258
|
try {
|
|
227
|
-
|
|
259
|
+
const response = await fetch(downloadUrl, {
|
|
260
|
+
headers: { "accept-encoding": "identity" },
|
|
261
|
+
redirect: "error",
|
|
262
|
+
signal: budget.signal,
|
|
263
|
+
});
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
throw new Error(`input attachment download failed with HTTP ${response.status}`);
|
|
266
|
+
}
|
|
267
|
+
await writeResponseToFile(response, partPath, attachment, budget.signal, budget.recordProgress);
|
|
228
268
|
await rename(partPath, filePath);
|
|
229
269
|
await chmod(filePath, 0o640);
|
|
230
270
|
}
|
|
231
271
|
finally {
|
|
272
|
+
budget.dispose();
|
|
232
273
|
await rm(partPath, { force: true });
|
|
233
274
|
}
|
|
234
275
|
}
|
package/dist/run-dispatcher.js
CHANGED
|
@@ -18,10 +18,6 @@ const DEFAULT_TIMEOUT_SECONDS = 900;
|
|
|
18
18
|
const MIN_TIMEOUT_SECONDS = 30;
|
|
19
19
|
const MAX_TIMEOUT_SECONDS = 7200;
|
|
20
20
|
const DEFAULT_MAX_OUTPUT_CHARS = 100_000;
|
|
21
|
-
// Attachment size is bounded at 10 MiB in input-attachments.ts. Keep one universal
|
|
22
|
-
// materialization budget that also works for supported cross-cloud object stores; this is
|
|
23
|
-
// a daemon compatibility invariant, not an environment-specific tuning knob.
|
|
24
|
-
const INPUT_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
|
25
21
|
// wire 上单块文本上限;完整文本在本地 transcript。
|
|
26
22
|
const BLOCK_TEXT_MAX_CHARS = 4000;
|
|
27
23
|
const CONTENT_FLUSH_MAX_CHARS = 512;
|
|
@@ -450,17 +446,7 @@ export class RunDispatcher {
|
|
|
450
446
|
if (!persistentTurn?.inputAttachmentGrant) {
|
|
451
447
|
throw new Error("input attachment download authorization is unavailable");
|
|
452
448
|
}
|
|
453
|
-
|
|
454
|
-
const abortAttachmentDownload = () => attachmentController.abort();
|
|
455
|
-
controller.signal.addEventListener("abort", abortAttachmentDownload, { once: true });
|
|
456
|
-
const attachmentTimer = setTimeout(abortAttachmentDownload, INPUT_ATTACHMENT_DOWNLOAD_TIMEOUT_MS);
|
|
457
|
-
try {
|
|
458
|
-
inputAttachments = await materializeInputAttachments(workspaceDir, requestedAttachments, persistentTurn.inputAttachmentGrant, attachmentController.signal);
|
|
459
|
-
}
|
|
460
|
-
finally {
|
|
461
|
-
clearTimeout(attachmentTimer);
|
|
462
|
-
controller.signal.removeEventListener("abort", abortAttachmentDownload);
|
|
463
|
-
}
|
|
449
|
+
inputAttachments = await materializeInputAttachments(workspaceDir, requestedAttachments, persistentTurn.inputAttachmentGrant, controller.signal);
|
|
464
450
|
}
|
|
465
451
|
const missingCapabilities = missingRunCapabilities(payload, workspaceDir);
|
|
466
452
|
if (missingCapabilities.length > 0) {
|
package/package.json
CHANGED