@sema-agent/server 2.0.0 → 3.0.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.
Files changed (63) hide show
  1. package/MIGRATION.md +74 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +391 -0
  5. package/dist/boot/budget-tracing.d.ts +48 -0
  6. package/dist/boot/budget-tracing.js +86 -0
  7. package/dist/boot/config-center.d.ts +62 -0
  8. package/dist/boot/config-center.js +1002 -0
  9. package/dist/boot/coordinators.d.ts +33 -0
  10. package/dist/boot/coordinators.js +97 -0
  11. package/dist/boot/execution-env.d.ts +26 -0
  12. package/dist/boot/execution-env.js +370 -0
  13. package/dist/boot/leader.d.ts +27 -0
  14. package/dist/boot/leader.js +81 -0
  15. package/dist/boot/reapers.d.ts +53 -0
  16. package/dist/boot/reapers.js +252 -0
  17. package/dist/boot/resolve-spec.d.ts +70 -0
  18. package/dist/boot/resolve-spec.js +1072 -0
  19. package/dist/boot/runner-deps.d.ts +101 -0
  20. package/dist/boot/runner-deps.js +343 -0
  21. package/dist/boot/runtime-caps.d.ts +21 -0
  22. package/dist/boot/runtime-caps.js +62 -0
  23. package/dist/boot/session-faces.d.ts +57 -0
  24. package/dist/boot/session-faces.js +157 -0
  25. package/dist/boot/shutdown.d.ts +50 -0
  26. package/dist/boot/shutdown.js +129 -0
  27. package/dist/boot/stores.d.ts +32 -0
  28. package/dist/boot/stores.js +361 -0
  29. package/dist/boot/workflow-orchestration.d.ts +46 -0
  30. package/dist/boot/workflow-orchestration.js +150 -0
  31. package/dist/capabilities/scenarios.d.ts +5 -3
  32. package/dist/capabilities/scenarios.js +5 -3
  33. package/dist/config-center/apply-effective.js +4 -3
  34. package/dist/config-lkg.d.ts +2 -1
  35. package/dist/config-lkg.js +2 -1
  36. package/dist/config-types.d.ts +47 -7
  37. package/dist/config.d.ts +30 -13
  38. package/dist/config.js +562 -387
  39. package/dist/hooks/hook-llm.js +9 -0
  40. package/dist/http/routes/approvals-assistant.js +1 -1
  41. package/dist/http/routes/attachments.js +2 -2
  42. package/dist/http/routes/memory-policy.js +3 -3
  43. package/dist/http/routes/runs.js +1 -1
  44. package/dist/http/routes/session-sync.js +2 -2
  45. package/dist/http/routes/sessions.js +2 -2
  46. package/dist/http/routes/tasks.js +2 -2
  47. package/dist/http/routes/trace-usage.js +2 -2
  48. package/dist/http/routes/workflows.js +3 -1
  49. package/dist/http/server.d.ts +1 -1
  50. package/dist/http/server.js +25 -5
  51. package/dist/http/sse-log.js +1 -1
  52. package/dist/main.js +164 -3799
  53. package/dist/model-select.d.ts +1 -1
  54. package/dist/model-select.js +1 -1
  55. package/dist/plugins/checkpoint-store-sql.d.ts +13 -5
  56. package/dist/plugins/checkpoint-store-sql.js +10 -3
  57. package/dist/plugins/local-checkpoint-store.js +8 -2
  58. package/dist/plugins/remote-env-host.d.ts +2 -1
  59. package/dist/run-local.js +2 -1
  60. package/dist/session-titler.d.ts +3 -1
  61. package/dist/session-titler.js +2 -2
  62. package/dist/trace/project.js +4 -1
  63. package/package.json +5 -3
@@ -1,3 +1,4 @@
1
+ import { matchCatalogModel } from "../model-select.js";
1
2
  export function createHookLlm(deps) {
2
3
  const { config, metrics } = deps;
3
4
  // anthropic 路由自己的 baseUrl(与 core createAnthropicBrain 同一解析链:model.baseUrl || config.baseUrl
@@ -40,6 +41,14 @@ export function createHookLlm(deps) {
40
41
  }
41
42
  if (config.models[requested])
42
43
  return shape(requested);
44
+ // 复审 2026-07-29 D1:目录键是 **name** 形,而调用点会传 **provider id** 形——`http/routes/tasks.ts`
45
+ // 的 titler 腿([1992]②「标题跟 turn」)对对象形 spec 传的就是 `spec.model.id`。registry 热应用后
46
+ // name 与 id 常态分叉(目录键是运维起的短名,`Model.id` 是 provider 侧的真名),单键索引下这类请求全落到下面那条
47
+ // 「不在目录里」⇒ 用户每选一次非主模型,标题就 llm_failed(对话本身正常),与 [1992] 同族的病形。
48
+ // 双键索引沿用同仓先例 [865]② `matchCatalogModel`:name 优先、id 扫目录值、跳过 `default` 别名键。
49
+ const byId = matchCatalogModel(requested, config.models);
50
+ if (byId !== undefined)
51
+ return shape(byId);
43
52
  if (requested === config.model.id)
44
53
  return shapeFrom(requested, config.model, requested);
45
54
  return { ok: false, error: `hook model "${requested}" is not in this deployment's catalog` };
@@ -746,7 +746,7 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
746
746
  }
747
747
  prev = cur;
748
748
  if (Date.now() - start > MAX_MS) {
749
- res.write(`event: error\ndata: ${JSON.stringify({ type: "error", code: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
749
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
750
750
  break;
751
751
  }
752
752
  if (Date.now() - lastBeat > 15_000) {
@@ -61,7 +61,7 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
61
61
  }
62
62
  const allow = deps.config.attachmentMimeAllowlist;
63
63
  if (allow && !allow.some((a) => a === mime || (a.endsWith("/*") && mime.startsWith(a.slice(0, -1))))) {
64
- sendError(res, 415, "attachment.mime_not_allowed", `mime '${mime}' is not in this deployment's allowlist`, { code: "attachment.mime_not_allowed", allowed: allow });
64
+ sendError(res, 415, "attachment.mime_not_allowed", `mime '${mime}' is not in this deployment's allowlist`, { allowed: allow });
65
65
  return;
66
66
  }
67
67
  const max = deps.config.attachmentMaxBytes ?? 32 * 1024 * 1024;
@@ -71,7 +71,7 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
71
71
  }
72
72
  catch (err) {
73
73
  if (err instanceof HttpError && err.status === 413) {
74
- sendError(res, 413, "attachment.too_large", `attachment exceeds the per-file limit (${max} bytes)`, { code: "attachment.too_large", maxBytes: max });
74
+ sendError(res, 413, "attachment.too_large", `attachment exceeds the per-file limit (${max} bytes)`, { maxBytes: max });
75
75
  return;
76
76
  }
77
77
  throw err;
@@ -76,11 +76,11 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
76
76
  scope = decodeURIComponent(url.slice("/v1/memory/sync/".length));
77
77
  }
78
78
  catch {
79
- sendError(res, 400, "memory_sync_invalid_scope", "malformed percent-encoding in :scope", { code: "memory_sync_invalid_scope" });
79
+ sendError(res, 400, "memory_sync_invalid_scope", "malformed percent-encoding in :scope");
80
80
  return;
81
81
  }
82
82
  if (scope.length === 0) {
83
- sendError(res, 400, "memory_sync_invalid_scope", "missing :scope", { code: "memory_sync_invalid_scope" });
83
+ sendError(res, 400, "memory_sync_invalid_scope", "missing :scope");
84
84
  return;
85
85
  }
86
86
  if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
@@ -90,7 +90,7 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
90
90
  const body = await readJson(req); // 413/400 (oversize / bad JSON) via the typed top-level catch — auth already done
91
91
  const parsed = parseMemorySyncRequest(body, scope);
92
92
  if (!parsed.ok) {
93
- sendError(res, 422, "memory_sync_invalid_body", parsed.error, { code: "memory_sync_invalid_body" });
93
+ sendError(res, 422, "memory_sync_invalid_body", parsed.error);
94
94
  return;
95
95
  }
96
96
  try {
@@ -154,7 +154,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
154
154
  const taskId = clientTaskId ?? uuidv7();
155
155
  const created = await runStore.createRun(taskId, sessionId, prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
156
156
  if (created.ok)
157
- deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective); // fire-and-forget session auto-title
157
+ deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model turn([1992]②)
158
158
  if (!created.ok) {
159
159
  // race twin: two concurrent same-taskId submits (gateway retry racing the original) — the loser's
160
160
  // session claim collides with ITS OWN taskId → idempotent 202 replay, not a 409 (the run exists, once).
@@ -234,7 +234,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
234
234
  if (deps.snapshotBlobSqlCapBytes !== undefined && body.byteLength > deps.snapshotBlobSqlCapBytes) {
235
235
  // 复审 D-F1/B-F2:归一漏网(多行体,旧门正则扫不到)——A6 迁移到 sendError 后此站点由
236
236
  // errorCode 必填位置参数保结构(不再靠源级反射门兜底)。
237
- sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`, { code: "blob_too_large_for_sql" });
237
+ sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`);
238
238
  return;
239
239
  }
240
240
  if (createHash("sha256").update(body).digest("hex") !== hash) {
@@ -246,7 +246,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
246
246
  // blob as durably stored when it isn't.
247
247
  const put = await fs.putBlob(hash, new Uint8Array(body));
248
248
  if (!put.ok) {
249
- sendError(res, 502, put.error.code, "blob store write failed", { code: put.error.code });
249
+ sendError(res, 502, put.error.code, "blob store write failed");
250
250
  return;
251
251
  }
252
252
  res.writeHead(204).end(); // no body
@@ -796,7 +796,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
796
796
  if (typeof fs.blobSizes === "function") {
797
797
  const known = (await fs.blobSizes([hash])).get(hash);
798
798
  if (known !== undefined && known > cap) {
799
- sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: known, limit: cap });
799
+ sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { sizeBytes: known, limit: cap });
800
800
  return;
801
801
  }
802
802
  }
@@ -806,7 +806,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
806
806
  return;
807
807
  }
808
808
  if (bytes.byteLength > cap) {
809
- sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: bytes.byteLength, limit: cap });
809
+ sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { sizeBytes: bytes.byteLength, limit: cap });
810
810
  return;
811
811
  }
812
812
  // [1894]③:content-type 按扩展名(保守表,未知=octet-stream)+ 二进制判别头(首 8KiB NUL/控制
@@ -214,7 +214,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
214
214
  const tid = earlyDurableTid ?? uuidv7(); // reuse the id already emitted as the X-Task-Id header (defensive mint if absent)
215
215
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
216
216
  if (created.ok)
217
- deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective); // fire-and-forget; in-titler dedupe
217
+ deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget; in-titler dedupe;model 跟 turn([1992]②)
218
218
  if (!created.ok) {
219
219
  const conflict = { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId };
220
220
  // Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
@@ -966,7 +966,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
966
966
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
967
967
  if (!created.ok)
968
968
  return { status: 409, body: { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId } };
969
- deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective); // fire-and-forget session auto-title
969
+ deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model turn([1992]②)
970
970
  durableTaskId = tid;
971
971
  }
972
972
  if (deps.checkpointStore && prepared.spec.sessionId) {
@@ -269,13 +269,13 @@ async function streamTaskTrace(req, res, runStore, taskId, staleMs) {
269
269
  }
270
270
  const stale = Date.now() - new Date(run.updatedAt).getTime() > staleMs;
271
271
  if (stale && events.length === 0) {
272
- res.write(`event: error\ndata: ${JSON.stringify({ code: "WORKER_DOWN", message: "run stalled (instance lost?)" })}\n\n`);
272
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "WORKER_DOWN", message: "run stalled (instance lost?)" })}\n\n`);
273
273
  break;
274
274
  }
275
275
  if (Date.now() - start > MAX_MS) {
276
276
  // BL-16: the stream hit its 15-min cap but the RUN is still going — emit a terminal event so the client
277
277
  // can tell "reconnect to continue" from "task completed" (a silent close looked like completion).
278
- res.write(`event: error\ndata: ${JSON.stringify({ code: "STREAM_MAX_DURATION", message: "trace stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
278
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "trace stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
279
279
  break;
280
280
  }
281
281
  if (events.length === 0) {
@@ -485,8 +485,10 @@ async function streamWorkflowRun(req, res, runId, scope) {
485
485
  }
486
486
  }
487
487
  catch {
488
+ // A1/A6/B:归一 error 帧形(`type` + 机器码)。此前只发人话 message ⇒ 消费端要么锚文案、要么把
489
+ // "流断了"和"流正常结束"混为一谈;`workflow.stream_error` 是本帧的铸码(附录 A `*.stream_error` 族)。
488
490
  if (!closed && !res.writableEnded)
489
- res.write(`event: error\ndata: ${JSON.stringify({ message: "workflow stream error" })}\n\n`);
491
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "workflow.stream_error", message: "workflow stream error" })}\n\n`);
490
492
  }
491
493
  finally {
492
494
  clearInterval(hb);
@@ -197,7 +197,7 @@ export interface ServiceCoordinatorDeps {
197
197
  leaderEndpoint?: LeaderEndpoint;
198
198
  /** Fire-and-forget session auto-titler (first-submit hook; absent = feature dark). */
199
199
  sessionTitler?: {
200
- maybeTitle(sessionId: string, objective: string): void;
200
+ maybeTitle(sessionId: string, objective: string, model?: string): void;
201
201
  };
202
202
  }
203
203
  /** **函数/构件缝**:main.ts 把跨多个子系统的动作编译成一个可调用面交给路由(路由不该自己
@@ -281,7 +281,7 @@ export function createHttpServer(rawDeps) {
281
281
  // 4xx, not a 500. Everything else is a genuine internal error: log the cause, never echo it (info disclosure).
282
282
  if (err instanceof HttpError) {
283
283
  if (!res.headersSent)
284
- sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.code ? { code: err.code } : {}), ...(err.extra ?? {}) });
284
+ sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.extra ?? {}) });
285
285
  else
286
286
  res.end();
287
287
  return;
@@ -1091,7 +1091,7 @@ export function createHttpServer(rawDeps) {
1091
1091
  if (err instanceof HttpError) {
1092
1092
  // Typed rejection: echo the stable `code` + any structured extras (e.g. the scenario
1093
1093
  // allowlist) beside the human message — machine code on the wire, prose stays with the shell/web.
1094
- sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.code ? { code: err.code } : {}), ...(err.extra ?? {}) });
1094
+ sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.extra ?? {}) });
1095
1095
  return null;
1096
1096
  }
1097
1097
  throw err;
@@ -1181,6 +1181,27 @@ export function createHttpServer(rawDeps) {
1181
1181
  }
1182
1182
  if (!cp)
1183
1183
  return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
1184
+ // 🔴 GATE-KIND GUARD ([1995]①,`gate_not_resumable` / `gate_not_plan_review` 的**反方向镜像**):这条腿
1185
+ // 只解 TOOL 审批门(core gateMatch:`human` / `irreversible_ask` ↔ outcome.gate "policy_ask")。此前它无
1186
+ // 条件铸 outcome{gate:"policy_ask"} 交给 core —— 一个 plan_review / resource_limit / task_done park 被误
1187
+ // 路由到 /decide(而 /v1/approvals 把它们与工具审批**混在同一个队列**里、toolName 同为 null)就会深入到
1188
+ // core 的 `checkpoint.gate_mismatch`。那是 core 的 **pre-CAS** 拒绝(checkpoint 仍 pending),但 server 的
1189
+ // CheckpointError 分类表把 gate_mismatch 归为 TERMINAL ⇒ 行被 setTerminal 成 failed + 释放 task_active,
1190
+ // 于是「行说这活死了、park 却还活着」:claim 泄漏(同 session 可并发起第二个 run)、事后用正确的腿救能
1191
+ // 200 真跑完却拿不到 taskId、run 行永停 failed(账本与现实分叉)。这里 pre-CAS 纯拒绝:不动 checkpoint、
1192
+ // 不动 run 行、不烧模型腿,与另外两条腿的守卫同姿势(core 的 gate-match 仍是 fail-closed 兜底)。
1193
+ // ⚠️ 门放在 parked 赎回腿**之前**:赎回腿同样只铸 policy_ask outcome,错门 cp 在那条链上只会更深地炸。
1194
+ // D-D SLA deny-sweep 不受影响:它的取行查询本就只选 gate_kind IN ('human','irreversible_ask')。
1195
+ const decideGateKind = cp.gate?.kind;
1196
+ if (decideGateKind !== "human" && decideGateKind !== "irreversible_ask") {
1197
+ return {
1198
+ status: 409,
1199
+ body: {
1200
+ error: `task is suspended on a '${decideGateKind ?? "unknown"}' gate, not a tool approval — a plan_review gate is resolved via POST /v1/assistant/tasks/:id/plan_review, a resource_limit suspension via POST /v1/assistant/tasks/:id/resume`,
1201
+ errorCode: "gate_not_tool_approval",
1202
+ },
1203
+ };
1204
+ }
1184
1205
  // design/153 件3d parked 赎回腿([1574]/[1584]/[1588] core 裁定;src/parked-decide.ts):该 pending
1185
1206
  // checkpoint 若属于一个 parked 后台子代,decide 必须走 claim→裸 execute(reviveClaim) 链——legacy 腿的
1186
1207
  // resumeStream 会绕开 bg registry(无 consumeParkedFlip、行永 parked、生命周期分叉),故 parked-first。
@@ -1243,7 +1264,7 @@ export function createHttpServer(rawDeps) {
1243
1264
  if (e instanceof HttpError) {
1244
1265
  return {
1245
1266
  status: 409,
1246
- body: { error: `resume blocked by an admission policy change: ${e.message}`, code: "resume_blocked_by_policy", errorCode: "resume_blocked_by_policy", ...(e.code ? { blockedBy: e.code } : {}), ...(e.extra ?? {}) },
1267
+ body: { error: `resume blocked by an admission policy change: ${e.message}`, errorCode: "resume_blocked_by_policy", ...(e.code ? { blockedBy: e.code } : {}), ...(e.extra ?? {}) },
1247
1268
  };
1248
1269
  }
1249
1270
  throw e;
@@ -1871,7 +1892,6 @@ export function createHttpServer(rawDeps) {
1871
1892
  status,
1872
1893
  body: {
1873
1894
  error: e.message,
1874
- code: e.code,
1875
1895
  // invalid_outcome means DIFFERENT things per gate: for policy_ask it IS the D-1 action-binding
1876
1896
  // mismatch; for plan_review/dry_run_review it is a content-sanitize reject (editedPlan/reason carried a
1877
1897
  // forbidden tag) — give those a gate-appropriate code so the UI routes to the right recovery, not the
@@ -2210,7 +2230,7 @@ export function createHttpServer(rawDeps) {
2210
2230
  res.setHeader("retry-after", String(adm.retryAfterSec));
2211
2231
  // E4 定稿:429 带 typed code + center 结构化字段 verbatim 透传(windowType/remaining/resetAt/
2212
2232
  // isEstimate/pool)——人话文案归壳/web(契约如此),error 串保留旧消费者兼容。
2213
- sendError(res, 429, "quota_exhausted", "budget exhausted (quota lease)", { code: "quota_exhausted", retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
2233
+ sendError(res, 429, "quota_exhausted", "budget exhausted (quota lease)", { retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
2214
2234
  return true;
2215
2235
  }
2216
2236
  /** Apply the per-principal rate limit; responds 429 and returns true when the caller is over. */
@@ -55,7 +55,7 @@ export async function streamSseLog(req, res, provider, id, staleMs) {
55
55
  if (Date.now() - start > MAX_MS) {
56
56
  // BL-16: cap reached but the run continues — emit a terminal reconnect signal (not a silent close, which
57
57
  // a client couldn't distinguish from a completed run). Distinct from the `failed`/`done` run events.
58
- res.write(`event: error\ndata: ${JSON.stringify({ type: "error", code: "STREAM_MAX_DURATION", message: "stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
58
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
59
59
  break;
60
60
  }
61
61
  if (events.length === 0) {