@botlearn-course/daemon 0.0.17-beta.1 → 0.0.17-beta.3

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.
@@ -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 response = await fetch(downloadUrl, {
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
- await writeResponseToFile(response, partPath, attachment, signal);
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
  }
@@ -446,17 +446,7 @@ export class RunDispatcher {
446
446
  if (!persistentTurn?.inputAttachmentGrant) {
447
447
  throw new Error("input attachment download authorization is unavailable");
448
448
  }
449
- const attachmentController = new AbortController();
450
- const abortAttachmentDownload = () => attachmentController.abort();
451
- controller.signal.addEventListener("abort", abortAttachmentDownload, { once: true });
452
- const attachmentTimer = setTimeout(abortAttachmentDownload, 60_000);
453
- try {
454
- inputAttachments = await materializeInputAttachments(workspaceDir, requestedAttachments, persistentTurn.inputAttachmentGrant, attachmentController.signal);
455
- }
456
- finally {
457
- clearTimeout(attachmentTimer);
458
- controller.signal.removeEventListener("abort", abortAttachmentDownload);
459
- }
449
+ inputAttachments = await materializeInputAttachments(workspaceDir, requestedAttachments, persistentTurn.inputAttachmentGrant, controller.signal);
460
450
  }
461
451
  const missingCapabilities = missingRunCapabilities(payload, workspaceDir);
462
452
  if (missingCapabilities.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.17-beta.1",
3
+ "version": "0.0.17-beta.3",
4
4
  "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {