@sema-agent/server 5.8.0 → 5.9.0

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.
@@ -15,6 +15,7 @@
15
15
  * pullDiff stays as the compat path for static (leader-owned) envs — M2 降兼容.
16
16
  */
17
17
  import { safeRev } from "./diffout.js";
18
+ import { OBJECT_STORE_IO_TIMEOUT_MS } from "../plugins/blob-backend.js";
18
19
  const DEFAULT_MAX_DIFF_BYTES = 5 * 1024 * 1024;
19
20
  /** The staged upload script. `repoDir`/`workDir` are deployment config; `baseSha` is interpolated into a
20
21
  * shell line, so it is re-validated HERE (fail-closed) — callers should already have applied diffout's
@@ -52,7 +53,9 @@ export async function fetchUploadedDiff(getUrl, opts = {}) {
52
53
  const f = opts.fetchImpl ?? fetch;
53
54
  let res;
54
55
  try {
55
- res = await f(getUrl);
56
+ // C6(2026-08-03):abort 上界(半开 MinIO 此前把 leader merge 阶段挂满 undici ~300s,钉死整个
57
+ // fanout)。maxBytes 检查仍在 text() 缓冲之后=已知残留(流式读上界另案),本修只收超时轴。
58
+ res = await f(getUrl, { signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
56
59
  }
57
60
  catch (e) {
58
61
  return { ok: false, error: `diff fetch failed: ${e instanceof Error ? e.message.replaceAll(getUrl, "<presigned-url>") : "network error"}` };
@@ -102,6 +102,11 @@ export interface MinioBlobBackendConfig {
102
102
  * under a hash it doesn't match) — treated as ABSENT (undefined), NEVER returning wrong bytes. This mirrors the
103
103
  * importManifest content-address check the SQL stores already do.
104
104
  */
105
+ /** 鲁棒性批2 C2/C3/C6(2026-08-03):S3 兼容端点 IO 的统一 abort 上界(fleet-client C1 定常量形
106
+ * 先例)。半开端点此前各挂 undici 默认 ~300s headersTimeout——blob GET 钉死 restore/session-import
107
+ * 同步路径,SendUserFile 工具调用永不返回。120s 对慢链路 ~64MiB 单 PUT/GET 仍宽裕(presignTtlSec
108
+ * 注释同口径),远小于病形。send-user-file 与 leader/diffup 同 import 此常量。 */
109
+ export declare const OBJECT_STORE_IO_TIMEOUT_MS = 120000;
105
110
  export declare class MinioBlobBackend implements BlobBackend {
106
111
  private readonly cfg;
107
112
  private readonly keyPrefix;
@@ -222,6 +222,11 @@ async function sqlDeleteIndexRowsPastGrace(dialect, pool, hashes) {
222
222
  * under a hash it doesn't match) — treated as ABSENT (undefined), NEVER returning wrong bytes. This mirrors the
223
223
  * importManifest content-address check the SQL stores already do.
224
224
  */
225
+ /** 鲁棒性批2 C2/C3/C6(2026-08-03):S3 兼容端点 IO 的统一 abort 上界(fleet-client C1 定常量形
226
+ * 先例)。半开端点此前各挂 undici 默认 ~300s headersTimeout——blob GET 钉死 restore/session-import
227
+ * 同步路径,SendUserFile 工具调用永不返回。120s 对慢链路 ~64MiB 单 PUT/GET 仍宽裕(presignTtlSec
228
+ * 注释同口径),远小于病形。send-user-file 与 leader/diffup 同 import 此常量。 */
229
+ export const OBJECT_STORE_IO_TIMEOUT_MS = 120_000;
225
230
  export class MinioBlobBackend {
226
231
  cfg;
227
232
  keyPrefix;
@@ -268,7 +273,9 @@ export class MinioBlobBackend {
268
273
  * catches a corrupted PUT at the next getBlob sha-check (fail-closed); the one-time migration script DOES read-back. */
269
274
  async putBlob(hash, bytes) {
270
275
  const url = this.presign(hash, "PUT");
271
- const res = await fetch(url, { method: "PUT", body: bytes });
276
+ // C2(2026-08-03):走注入座(此前直用全局 fetch=测试桩接不进的站点)+ abort 上界(半开端点
277
+ // 此前挂 undici ~300s;120s 对慢链路大对象仍宽裕,presignTtlSec 注释同口径)。六站点同批。
278
+ const res = await (this.cfg.fetchImpl ?? fetch)(url, { method: "PUT", body: bytes, signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
272
279
  if (!res.ok) {
273
280
  const detail = await res.text().catch(() => "");
274
281
  throw new Error(`MinioBlobBackend.putBlob ${hash}: HTTP ${res.status} ${detail.slice(0, 200)}`);
@@ -295,7 +302,7 @@ export class MinioBlobBackend {
295
302
  const url = this.presign(hash, "GET");
296
303
  let res;
297
304
  try {
298
- res = await fetch(url, { method: "GET" });
305
+ res = await (this.cfg.fetchImpl ?? fetch)(url, { method: "GET", signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
299
306
  }
300
307
  catch (err) {
301
308
  throw new Error(`MinioBlobBackend.getBlob ${hash}: transient fetch failure: ${err.message}`);
@@ -323,7 +330,7 @@ export class MinioBlobBackend {
323
330
  const present = new Set();
324
331
  await mapBounded(distinct, MINIO_OP_CONCURRENCY, async (hash) => {
325
332
  try {
326
- const res = await fetch(this.presign(hash, "HEAD"), { method: "HEAD" });
333
+ const res = await (this.cfg.fetchImpl ?? fetch)(this.presign(hash, "HEAD"), { method: "HEAD", signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
327
334
  if (res.ok)
328
335
  present.add(hash);
329
336
  }
@@ -360,7 +367,7 @@ export class MinioBlobBackend {
360
367
  ...(this.cfg.region ? { region: this.cfg.region } : {}),
361
368
  expiresSec: this.ttl,
362
369
  });
363
- const res = await f(url, { method: "GET" });
370
+ const res = await f(url, { method: "GET", signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
364
371
  if (!res.ok)
365
372
  throw new Error(`listObjects: MinIO ${res.status}`);
366
373
  const xml = await res.text();
@@ -384,7 +391,7 @@ export class MinioBlobBackend {
384
391
  * put 会 PUT 同键 ⇒ 刷新 LastModified,所以「删前再 HEAD 一次」能看见「刚刚被重新引用」的对象。 */
385
392
  async headObject(hash) {
386
393
  const f = this.cfg.fetchImpl ?? fetch;
387
- const res = await f(this.presign(hash, "HEAD"), { method: "HEAD" });
394
+ const res = await f(this.presign(hash, "HEAD"), { method: "HEAD", signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
388
395
  if (!res.ok)
389
396
  return undefined;
390
397
  const lm = res.headers.get("last-modified");
@@ -417,7 +424,7 @@ export class MinioBlobBackend {
417
424
  let okCount = 0;
418
425
  await mapBounded(toDelete, MINIO_OP_CONCURRENCY, async (hash) => {
419
426
  try {
420
- const res = await (this.cfg.fetchImpl ?? fetch)(this.presign(hash, "DELETE"), { method: "DELETE" });
427
+ const res = await (this.cfg.fetchImpl ?? fetch)(this.presign(hash, "DELETE"), { method: "DELETE", signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
421
428
  // 🔴 复审 F4:统计**真实**删除数——index-less(附件)分支此前恒返回入参长度,MinIO 完全不可写
422
429
  // 时指标/日志照报「清理成功」。2xx/404(已不在)都算删成功;其余=失败,不计数。
423
430
  if (res.ok || res.status === 404)
@@ -116,5 +116,7 @@ export declare function createSendUserFileIssuer(cfg: SendUserFileConfig, deps?:
116
116
  /** Injectable object delete (tests). Default = signed DELETE against the INTERNAL endpoint. */
117
117
  remove?: (bucket: string, key: string) => Promise<void>;
118
118
  now?: () => Date;
119
+ /** C3(2026-08-03)测试座:默认实现的六个 S3 fetch 站点走此座——signal 在场性自此可钉。 */
120
+ fetchImpl?: typeof fetch;
119
121
  }): SendUserFileIssuer;
120
122
  //# sourceMappingURL=send-user-file.d.ts.map
@@ -26,6 +26,7 @@
26
26
  import { createHmac } from "node:crypto";
27
27
  import { uuidv7 } from "@sema-agent/core";
28
28
  import { presignS3CopyUrl, presignS3Url } from "./s3-presign.js";
29
+ import { OBJECT_STORE_IO_TIMEOUT_MS } from "./blob-backend.js";
29
30
  /** SigV4 physical ceiling (AWS: X-Amz-Expires max 604800s = 7 days). */
30
31
  export const PRESIGN_MAX_TTL_SEC = 604_800;
31
32
  /**
@@ -80,6 +81,10 @@ export function sanitizeSendFileName(name) {
80
81
  return clean.length > 0 ? clean : "file";
81
82
  }
82
83
  export function createSendUserFileIssuer(cfg, deps) {
84
+ // C3:六站点统一 abort 上界(半开 S3 端点此前把用户可见的工具调用挂满 undici ~300s,
85
+ // reclaim 也挂住 ⇒ staging 孤儿+误报「reclaim FAILED」)。上界常量与 blob-backend 同源。
86
+ const f = deps?.fetchImpl ?? fetch;
87
+ const bounded = () => ({ signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
83
88
  const upload = deps?.upload ??
84
89
  (async (bucket, key, bytes) => {
85
90
  const url = presignS3Url({
@@ -93,7 +98,7 @@ export function createSendUserFileIssuer(cfg, deps) {
93
98
  expiresSec: 600,
94
99
  ...(deps?.now !== undefined ? { now: deps.now() } : {}),
95
100
  });
96
- const res = await fetch(url, { method: "PUT", body: Buffer.from(bytes) });
101
+ const res = await f(url, { method: "PUT", body: Buffer.from(bytes), ...bounded() });
97
102
  if (!res.ok)
98
103
  throw new Error(`SendUserFile upload failed: ${res.status} ${res.statusText} (bucket=${bucket})`);
99
104
  });
@@ -196,7 +201,7 @@ export function createSendUserFileIssuer(cfg, deps) {
196
201
  // TRUE outcome so error messages never claim "reclaimed" for a delete that itself failed (F2 posture).
197
202
  const reclaim = async (b, k) => {
198
203
  try {
199
- const r = await fetch(sign("DELETE", b, k), { method: "DELETE" });
204
+ const r = await f(sign("DELETE", b, k), { method: "DELETE", ...bounded() });
200
205
  return r.ok || r.status === 404; // S3 DELETE is idempotent — a missing key IS reclaimed
201
206
  }
202
207
  catch {
@@ -208,7 +213,7 @@ export function createSendUserFileIssuer(cfg, deps) {
208
213
  // (fail before copying a huge object). Advisory only for size — the authoritative reading is ④.
209
214
  let head;
210
215
  try {
211
- head = await fetch(sign("HEAD", stagingBucket, stagingKey), { method: "HEAD" });
216
+ head = await f(sign("HEAD", stagingBucket, stagingKey), { method: "HEAD", ...bounded() });
212
217
  }
213
218
  catch (e) {
214
219
  const r = await reclaim(stagingBucket, stagingKey);
@@ -245,7 +250,7 @@ export function createSendUserFileIssuer(cfg, deps) {
245
250
  ...(deps?.now !== undefined ? { now: deps.now() } : {}),
246
251
  });
247
252
  try {
248
- const res = await fetch(copy.url, { method: "PUT", headers: copy.headers });
253
+ const res = await f(copy.url, { method: "PUT", headers: copy.headers, ...bounded() });
249
254
  const body = await res.text().catch(() => "");
250
255
  if (!res.ok || !body.includes("CopyObjectResult"))
251
256
  throw new Error(`copy returned ${res.status}`);
@@ -263,7 +268,7 @@ export function createSendUserFileIssuer(cfg, deps) {
263
268
  // staging between ① and ②, swapping in different bytes).
264
269
  let fin;
265
270
  try {
266
- fin = await fetch(sign("HEAD", bucket, key), { method: "HEAD" });
271
+ fin = await f(sign("HEAD", bucket, key), { method: "HEAD", ...bounded() });
267
272
  }
268
273
  catch (e) {
269
274
  const r = await reclaim(bucket, key);
@@ -322,7 +327,7 @@ export function createSendUserFileIssuer(cfg, deps) {
322
327
  expiresSec: 60,
323
328
  ...(deps?.now !== undefined ? { now: deps.now() } : {}),
324
329
  });
325
- const res = await fetch(url, { method: "DELETE" });
330
+ const res = await f(url, { method: "DELETE", ...bounded() });
326
331
  if (!res.ok && res.status !== 404)
327
332
  throw new Error(`SendUserFile object delete failed: ${res.status} ${res.statusText} (bucket=${bucket})`);
328
333
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "5.8.0",
3
+ "version": "5.9.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",