@sema-agent/server 6.2.0 → 6.3.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.
@@ -191,6 +191,17 @@ export class BakeRunner {
191
191
  enqueueIngest(dockerBuildTickFrame(nextOrd(ord), Math.floor(elapsed / 1000)));
192
192
  }
193
193
  })();
194
+ // #131-6:创建即挂接——rejection 若等到 stopSupervisor(child 退出后)才有人接,会在整个构建
195
+ // 时长内悬置,Node 默认 unhandled-rejections=throw 直接终结 runner 进程。挂接后 supervisor 腿
196
+ // 静默死掉的后果有界:lease 心跳停 → image-api reaper 兜底(与 heartbeat error continue 同口径)。
197
+ void supervisor.catch((err) => {
198
+ try {
199
+ this.o.log.warn("bake_supervisor_error", { bakeId: bake.bakeId, err: String(err) });
200
+ }
201
+ catch {
202
+ /* 日志面故障不再外抛 */
203
+ }
204
+ });
194
205
  const exit = await child.exited;
195
206
  childExited = true;
196
207
  this.stopSupervisor(supervisor);
@@ -67,12 +67,13 @@ export interface ProfileDeps {
67
67
  */
68
68
  runCounterfactualOracle?: () => Promise<OracleVerdicts>;
69
69
  }
70
- /** A leaf impl spec — budget already stamped by the harness (the §3.2 red line). */
70
+ /** A leaf impl spec — budget already stamped by the harness (the §3.2 red line). All three budget keys live in
71
+ * `limits` (core ≥5.8 reads them ONLY there — a top-level maxTokens/maxCostUsd would be a silently-dead key). */
71
72
  export interface SoloImplSpec {
72
73
  objective: string;
73
- maxTokens: number;
74
- maxCostUsd: number;
75
74
  limits: {
75
+ maxTokens: number;
76
+ maxCostUsd: number;
76
77
  maxTurns: number;
77
78
  };
78
79
  }
@@ -34,7 +34,8 @@
34
34
  * same anti-reward-hack transfer path SOLO/SUP use). If the integrated tree can't be imported, the cell is
35
35
  * `infraFailed` (EXCLUDED) — NEVER a fabricated uniform delivered=false loss for the whole TEAM column.
36
36
  */
37
- import type { ProfileDeps } from "./arms.js";
37
+ import { type TaskSpec } from "@sema-agent/core";
38
+ import type { ProfileDeps, SoloImplSpec, SupImplSpec } from "./arms.js";
38
39
  import type { TrapSpec } from "./tasks.js";
39
40
  /** Live runtime config — the SAME base model + budget every arm runs on (the §3.2 fairness root). */
40
41
  export interface LiveRuntimeConfig {
@@ -88,4 +89,11 @@ export interface LiveCell {
88
89
  * @param cellId a stable id for the cell (used as sandbox metadata for fleet observability).
89
90
  */
90
91
  export declare function buildLiveDeps(rt: LiveRuntimeConfig, trap: TrapSpec, seed: number | string, cellId: string): LiveCell;
92
+ /** The per-cell base TaskSpec every arm's leaf projects from. EXPORTED so the shape test measures the REAL spec
93
+ * handed to the runner (the BUDGET-MATCH anchor is spec.limits, not the harness's own RowBudget echo). */
94
+ export declare function benchBaseSpec(rt: LiveRuntimeConfig, cellId: string): (objective: string) => TaskSpec;
95
+ /** Project a `SoloImplSpec` (budget already stamped by the harness, all keys in `limits`) onto the bench base TaskSpec. */
96
+ export declare function toTaskSpec(base: (objective: string) => TaskSpec, impl: SoloImplSpec): TaskSpec;
97
+ /** Project a `SupImplSpec` onto the bench base TaskSpec PLUS the durable wiring (checkpointStore + ask policy). */
98
+ export declare function toSupTaskSpec(base: (objective: string) => TaskSpec, impl: SupImplSpec, trap: TrapSpec): TaskSpec;
91
99
  //# sourceMappingURL=live-deps.d.ts.map
@@ -272,13 +272,7 @@ function graderTransport(grader) {
272
272
  */
273
273
  export function buildLiveDeps(rt, trap, seed, cellId) {
274
274
  const { brain, models, roles, pricing } = buildBrainIngredients(rt);
275
- const baseSpec = (objective) => ({
276
- objective,
277
- sessionId: `s1-${cellId}`,
278
- maxTokens: rt.maxTokens ?? 8000,
279
- maxCostUsd: rt.maxCostUsd ?? 1.0,
280
- limits: { maxTurns: rt.maxTurns ?? 12 },
281
- });
275
+ const baseSpec = benchBaseSpec(rt, cellId);
282
276
  // Lazily-provisioned, memoized per-cell sandboxes (so a cell that never runs a seam pays zero VM cost; a seam
283
277
  // that throws still leaves them on `state` so dispose() reaps them).
284
278
  const state = {};
@@ -458,12 +452,23 @@ export function buildLiveDeps(rt, trap, seed, cellId) {
458
452
  };
459
453
  return { deps, dispose };
460
454
  }
461
- /** Project a `SoloImplSpec` (budget already stamped by the harness) onto the bench base TaskSpec. */
462
- function toTaskSpec(base, impl) {
463
- return { ...base(impl.objective), maxTokens: impl.maxTokens, maxCostUsd: impl.maxCostUsd, limits: { ...impl.limits } };
455
+ /** The per-cell base TaskSpec every arm's leaf projects from. EXPORTED so the shape test measures the REAL spec
456
+ * handed to the runner (the BUDGET-MATCH anchor is spec.limits, not the harness's own RowBudget echo). */
457
+ export function benchBaseSpec(rt, cellId) {
458
+ // 🔴 core ≥5.8: maxTokens/maxCostUsd are read ONLY from `limits` — a top-level key is silently dead. No `as
459
+ // TaskSpec` here or in the projections below: the compiler is the guard against re-introducing dead keys.
460
+ return (objective) => ({
461
+ objective,
462
+ sessionId: `s1-${cellId}`,
463
+ limits: { maxTokens: rt.maxTokens ?? 8000, maxCostUsd: rt.maxCostUsd ?? 1.0, maxTurns: rt.maxTurns ?? 12 },
464
+ });
465
+ }
466
+ /** Project a `SoloImplSpec` (budget already stamped by the harness, all keys in `limits`) onto the bench base TaskSpec. */
467
+ export function toTaskSpec(base, impl) {
468
+ return { ...base(impl.objective), limits: { ...impl.limits } };
464
469
  }
465
470
  /** Project a `SupImplSpec` onto the bench base TaskSpec PLUS the durable wiring (checkpointStore + ask policy). */
466
- function toSupTaskSpec(base, impl, trap) {
471
+ export function toSupTaskSpec(base, impl, trap) {
467
472
  const requireApproval = trap.gatedTools ?? ["Bash"];
468
473
  const neverAuto = trap.neverAuto ?? [];
469
474
  return {
@@ -4,7 +4,8 @@
4
4
  * clock that makes C2 (human-review wall-time) reproducible.
5
5
  *
6
6
  * 🔴 THE §3.2 RED LINE (design/89's #1 confound): every leaf TaskSpec across all 3 arms carries the SAME
7
- * `{ maxTokens, maxCostUsd, limits.maxTurns }`, and TEAM's per-worker budgets sum to the solo budget so no
7
+ * `limits.{ maxTokens, maxCostUsd, maxTurns }` (core ≥5.8: budget keys live ONLY in `limits`a top-level
8
+ * `maxTokens`/`maxCostUsd` is a silently-unread dead key), and TEAM's per-worker budgets sum to ≤ the solo budget — so no
8
9
  * arm can win on a bigger pie. `assertBudgetMatch` (below) is the build-time guard the shape test runs.
9
10
  *
10
11
  * 🔴 BUDGET-MATCH for TEAM is NEW WIRING the live conflict test does NOT do: `leader-conflict-resolver-live.test.ts`
@@ -37,9 +38,9 @@ export declare function budgetDescriptor(budget: BenchBudget, arm: string, teamW
37
38
  export interface BenchBudget {
38
39
  /** SAME base model all arms (design/89 §3.2 — decorrelation is a separate axis, not the value axis). */
39
40
  modelId: string;
40
- /** `TaskSpec.maxTokens` — SAME all arms. */
41
+ /** `TaskSpec.limits.maxTokens` — SAME all arms. */
41
42
  maxTokens: number;
42
- /** `TaskSpec.maxCostUsd` — SAME all arms (also the per-run spend cap). For TEAM, Σworker ≤ this. */
43
+ /** `TaskSpec.limits.maxCostUsd` — SAME all arms (also the per-run spend cap). For TEAM, Σworker ≤ this. */
43
44
  maxCostUsd: number;
44
45
  /** `TaskSpec.limits.maxTurns` — SAME all arms. */
45
46
  maxTurns: number;
@@ -89,11 +90,14 @@ export interface RunnerCtx {
89
90
  * Returns the budget fields to spread onto the spec — kept as a helper so EVERY leaf is stamped identically
90
91
  * (the live tests forget this for TEAM workers — the confound). `maxCostUsd` is overridable for the TEAM
91
92
  * per-worker split (Σ ≤ solo, enforced by `assertBudgetMatch`).
93
+ *
94
+ * 🔴 ALL THREE keys live under `limits` — core ≥5.8 reads `maxTokens`/`maxCostUsd` ONLY from `TaskSpec.limits`;
95
+ * a top-level key is DEAD (silently unread — the cost/token gates never bind).
92
96
  */
93
97
  export declare function leafBudgetFields(budget: BenchBudget, overrideMaxCostUsd?: number): {
94
- maxTokens: number;
95
- maxCostUsd: number;
96
98
  limits: {
99
+ maxTokens: number;
100
+ maxCostUsd: number;
97
101
  maxTurns: number;
98
102
  };
99
103
  };
@@ -4,7 +4,8 @@
4
4
  * clock that makes C2 (human-review wall-time) reproducible.
5
5
  *
6
6
  * 🔴 THE §3.2 RED LINE (design/89's #1 confound): every leaf TaskSpec across all 3 arms carries the SAME
7
- * `{ maxTokens, maxCostUsd, limits.maxTurns }`, and TEAM's per-worker budgets sum to the solo budget so no
7
+ * `limits.{ maxTokens, maxCostUsd, maxTurns }` (core ≥5.8: budget keys live ONLY in `limits`a top-level
8
+ * `maxTokens`/`maxCostUsd` is a silently-unread dead key), and TEAM's per-worker budgets sum to ≤ the solo budget — so no
8
9
  * arm can win on a bigger pie. `assertBudgetMatch` (below) is the build-time guard the shape test runs.
9
10
  *
10
11
  * 🔴 BUDGET-MATCH for TEAM is NEW WIRING the live conflict test does NOT do: `leader-conflict-resolver-live.test.ts`
@@ -44,12 +45,17 @@ export function makeBenchClock(startEpochMs = Date.now()) {
44
45
  * Returns the budget fields to spread onto the spec — kept as a helper so EVERY leaf is stamped identically
45
46
  * (the live tests forget this for TEAM workers — the confound). `maxCostUsd` is overridable for the TEAM
46
47
  * per-worker split (Σ ≤ solo, enforced by `assertBudgetMatch`).
48
+ *
49
+ * 🔴 ALL THREE keys live under `limits` — core ≥5.8 reads `maxTokens`/`maxCostUsd` ONLY from `TaskSpec.limits`;
50
+ * a top-level key is DEAD (silently unread — the cost/token gates never bind).
47
51
  */
48
52
  export function leafBudgetFields(budget, overrideMaxCostUsd) {
49
53
  return {
50
- maxTokens: budget.maxTokens,
51
- maxCostUsd: overrideMaxCostUsd ?? budget.maxCostUsd,
52
- limits: { maxTurns: budget.maxTurns },
54
+ limits: {
55
+ maxTokens: budget.maxTokens,
56
+ maxCostUsd: overrideMaxCostUsd ?? budget.maxCostUsd,
57
+ maxTurns: budget.maxTurns,
58
+ },
53
59
  };
54
60
  }
55
61
  /**
@@ -42,6 +42,8 @@ export interface ConfigCenterRuntime {
42
42
  runnerTierFrozen: boolean;
43
43
  pricing: ReturnType<typeof buildPricing>;
44
44
  }): void;
45
+ /** #131-2:停刷新环(hardShutdown 收尾链;幂等,未起环时 no-op)。 */
46
+ stopRefreshLoop(): void;
45
47
  /** 晚绑取值:refresh 热应用会整个换引用(hook LLM / resolveSpec 每次现取)。 */
46
48
  getKeyResolver(): ((model: ModelKeyRef) => Promise<{
47
49
  apiKey: string;
@@ -485,6 +485,8 @@ export async function createConfigCenterRuntime(ctx) {
485
485
  // 函数而非常量:`config.degrade` 今天由 env 独占(applyEffective 不写它),但若哪天中心接管这面,这里
486
486
  // 自动跟着走,不会退化成 boot 期快照。车道关(默认)⇒ ctx 空 ⇒ 该片恒 null ⇒ 零行为变化。
487
487
  const restartCtx = () => (config.degrade?.reactive ? { reactiveDegradeTo: config.degrade.to } : {});
488
+ // #131-2:60s 刷新环的句柄提到 runtime 闭包层——stopRefreshLoop(hardShutdown 收尾链)要能清它。
489
+ let ccTimer;
488
490
  return {
489
491
  providerKind: configProvider?.kind,
490
492
  promptSource,
@@ -913,7 +915,7 @@ export async function createConfigCenterRuntime(ctx) {
913
915
  refreshInFlight = false;
914
916
  }
915
917
  };
916
- const ccTimer = setInterval(() => void refreshTick(), 60_000);
918
+ ccTimer = setInterval(() => void refreshTick(), 60_000);
917
919
  ccTimer.unref?.();
918
920
  // Boot-deferred continuation(二轮复审 F5 改形):到货结果按「迟到的 boot」处理,而不是转普通 tick——
919
921
  // 普通 tick 的 restartReasons(undefined, r) 会把 prompts/skills 面全判为差异 → restart → 中心持续慢时
@@ -1020,6 +1022,13 @@ export async function createConfigCenterRuntime(ctx) {
1020
1022
  void bootConfigPending.then((r) => (lkgBooted ? refreshTick(r) : deferredBootApply(r)), () => { });
1021
1023
  }
1022
1024
  },
1025
+ stopRefreshLoop() {
1026
+ // #131-2:hardShutdown 收尾链——停机中途不再热应用配置(幂等;env 部署没起环时是 no-op)。
1027
+ if (ccTimer !== undefined) {
1028
+ clearInterval(ccTimer);
1029
+ ccTimer = undefined;
1030
+ }
1031
+ },
1023
1032
  };
1024
1033
  }
1025
1034
  //# sourceMappingURL=config-center.js.map
@@ -589,21 +589,24 @@ export function createResolveSpec(ctx) {
589
589
  // model uses its own upstream key; a model without one falls back to the gateway key. undefined
590
590
  // when no per-model keys are configured → unchanged single-key behavior.
591
591
  getApiKeyAndHeaders: getKeyResolver(), // A10 搬运改写②:活引用取值(原 `keyResolver`)
592
- // [854]④ per-request 配速:body.limits.{timeoutSec,maxOutputTokens,maxTurns} 现在被收下(核对上游
592
+ // [854]④ per-request 配速:body.limits.{maxWalltimeMs,maxOutputTokens,maxTurns} 现在被收下(核对上游
593
593
  // TB2.0 实测诉求;旧姿势「body limits 一律忽略」作废)。合成规则在 resolveTaskLimits(spec-fields.ts):
594
- // - body.timeoutSec 给了就用 body(caller 显式配速,可低于内建墙;已被可选 TASK_TIMEOUT_MAX_SEC 封顶);
594
+ // - body.maxWalltimeMs 给了就用 body(caller 显式配速,可低于内建墙;已被可选 TASK_TIMEOUT_MAX_SEC
595
+ // 封顶——env 保秒义,对毫秒键封顶时 ×1000);
595
596
  // - body 缺席保持既有姿势 = tenancy 墙钟(单用户 turnkey 无墙 / 多租 2400s、大任务 3600s,
596
597
  // clay 2026-07-04 make-real 教训①)+ env TASK_TIMEOUT_SEC 只抬不降(taskWallClockSec 内 Math.max);
597
598
  // - maxOutputTokens/maxTurns 直透传(可选 TASK_MAX_OUTPUT_TOKENS_MAX / TASK_MAX_TURNS_MAX 同款封顶)。
598
- // resume 重放持久化 body 不过 HTTP 400 门 → normalizeLimits defensive(0/负/垃圾按键 DROP,不 throw)
599
+ // resume 重放持久化 body 不过 HTTP 400 门 → normalizeLimits defensive(0/负/垃圾按键 DROP,不 throw);
600
+ // 退役键(timeoutSec/deadline 族)DROP 必须留痕:task_limits_legacy_key_dropped 带被丢键与最终生效墙钟
601
+ // (null=整任务无墙——单用户 turnkey 无 env 墙时的真后果;多租=被延到租户墙)。
599
602
  ...(() => {
600
- const limits = resolveTaskLimits(body.limits, taskLimitCaps, taskTimeoutSec, config.requirePrincipal, body.council === true || body.debate === true || scenarioName === "team");
603
+ const limits = resolveTaskLimits(body.limits, taskLimitCaps, taskTimeoutSec, config.requirePrincipal, body.council === true || body.debate === true || scenarioName === "team", (event, fields) => logger.warn(event, { ...fields, sessionId: auth?.sessionId ?? null }));
601
604
  // [1301]③ config catalog server 半场:env 封顶不再是「五层五值互不知情」的暗手——每个真在场
602
605
  // 的运营方旋钮以 configOverrides 声明进 spec(advisory,core 折进 config.assembled 的
603
606
  // overrideReasons;「谁设的顶」变成读帧不考古)。只声明 SET 了的键(缺省不设=不污染帧)。
604
607
  const declarations = [];
605
608
  if (taskLimitCaps.timeoutSec !== undefined)
606
- declarations.push({ key: "server.limits.timeoutSecCap", value: String(taskLimitCaps.timeoutSec), reason: "env TASK_TIMEOUT_MAX_SEC (operator ceiling on caller limits.timeoutSec)" });
609
+ declarations.push({ key: "server.limits.timeoutSecCap", value: String(taskLimitCaps.timeoutSec), reason: "env TASK_TIMEOUT_MAX_SEC (seconds; operator ceiling applied to caller limits.maxWalltimeMs as cap×1000)" });
607
610
  if (taskLimitCaps.maxOutputTokens !== undefined)
608
611
  declarations.push({ key: "server.limits.maxOutputTokensCap", value: String(taskLimitCaps.maxOutputTokens), reason: "env TASK_MAX_OUTPUT_TOKENS_MAX (operator ceiling)" });
609
612
  if (taskLimitCaps.maxTurns !== undefined)
@@ -620,9 +623,9 @@ export function createResolveSpec(ctx) {
620
623
  // 「server 设的顶」,声明其来源;single-user 无墙=不声明)。
621
624
  if (config.requirePrincipal === true)
622
625
  declarations.push({ key: "server.limits.wallClockBaseSec", value: "2400/3600", reason: "tenancy wall-clock base (multi-tenant; big tasks 3600) — TASK_TIMEOUT_SEC raises, never shrinks" });
623
- // codex F1:core 目录自有键(limits.timeoutSec 等)**绝不重复声明**——core 已按真 provenance 发
626
+ // codex F1:core 目录自有键(limits.maxWalltimeMs 等)**绝不重复声明**——core 已按真 provenance 发
624
627
  // 该字段,叠一条 host-declared 字符串值=同键双条矛盾帧(by-key 投影抹掉权威来源)。server 只
625
- // 声明自己命名空间的键;来源语义(caller vs 墙钟)由 timeoutSec 的 spec provenance + 上面的
628
+ // 声明自己命名空间的键;来源语义(caller vs 墙钟)由 maxWalltimeMs 的 spec provenance + 上面的
626
629
  // Cap 声明组合可读。
627
630
  // core 5.8.0:预算族(maxCostUsd/maxTokens/degrade)从 TaskSpec 顶层迁入 limits——顶层键已删,
628
631
  // 继续写顶层=经宽类型静默死键(E-HIGH-1 病族)。wire 面不变:body 顶层 maxCostUsd/maxTokens 照收
@@ -44,6 +44,16 @@ export interface ShutdownCtx {
44
44
  inflight?: () => number;
45
45
  lastActivityAt?: () => number;
46
46
  };
47
+ /** #131-2:store 活体探针(main 建)——不停的话 hardShutdown 后仍对正在关闭的池发探针,刷假
48
+ * store_probe_dead 告警(文件头契约 3 的同族漏网)。缺席 = 该部署形没建探针。 */
49
+ storeLiveProbe: {
50
+ stop(): void;
51
+ } | undefined;
52
+ /** #131-2:config 60s 刷新环(config-center startRefreshLoop)——不停的话停机中途还可能热应用
53
+ * 一份新配置。缺席 = 纯 env 部署没起环。 */
54
+ configCenter: {
55
+ stopRefreshLoop(): void;
56
+ } | undefined;
47
57
  }
48
58
  /** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。 */
49
59
  export declare function installShutdownHandlers(ctx: ShutdownCtx): void;
@@ -17,13 +17,15 @@ import { Runner, defaultTaskRegistry } from "@sema-agent/core";
17
17
  import { createSighupIdleHandler } from "../sighup-idle.js";
18
18
  /** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。 */
19
19
  export function installShutdownHandlers(ctx) {
20
- const { config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, } = ctx;
20
+ const { config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, } = ctx;
21
21
  let closing = false;
22
22
  const hardShutdown = () => {
23
23
  if (closing)
24
24
  return;
25
25
  closing = true;
26
26
  clearInterval(reaper);
27
+ storeLiveProbe?.stop(); // #131-2:契约 3 同族——收尾期不再有探针 tick 打向正在关闭的池
28
+ configCenter?.stopRefreshLoop(); // #131-2:停机中途不再热应用配置
27
29
  otelExporter?.stop();
28
30
  breakerState?.stop();
29
31
  if (costQuota && "stop" in costQuota)
@@ -43,7 +43,16 @@ export class IdempotencyCache {
43
43
  // cacheable success for the TTL. Handlers also avert an unhandled-rejection on the cached copy; the returned
44
44
  // promise is the same object the caller awaits, so errors still surface to the caller.
45
45
  promise.then((value) => {
46
- if (!shouldCache(value))
46
+ // #131-7:谓词抛=当不可缓存(重试重跑)。裸调时谓词一抛,这条**派生** promise 就成了无人接的
47
+ // rejection(调用方拿的是原 promise,接不到它),且条目滞留缓存——双错方向。
48
+ let cacheable = false;
49
+ try {
50
+ cacheable = shouldCache(value);
51
+ }
52
+ catch {
53
+ /* fall through: cacheable=false */
54
+ }
55
+ if (!cacheable)
47
56
  this.entries.delete(key);
48
57
  }, () => this.entries.delete(key));
49
58
  return promise;
@@ -45,6 +45,10 @@ async function handleTasksBody(req, res, url, ctx, miss) {
45
45
  if (!res.writableEnded)
46
46
  res.write(`event: heartbeat\ndata: {}\n\n`); // real frame (not an SSE comment) — a per-frame-parsing BFF drops comments, so downstream saw a zero-frame window; EventSource clients without a heartbeat listener ignore it (zero break)
47
47
  }, 15_000);
48
+ hb.unref?.(); // #131-5①:同文件 durableHeartbeat 口径——停机时心跳不拖事件循环
49
+ // #131-5②:重试客户端断连即清——`cached` 可悬到原始流 settle(deadline 级时长),此前这个
50
+ // timer 一直空转到那时(写有 writableEnded 守卫,烧的是 tick 本身);finally 仍是兜底清。
51
+ res.on("close", () => clearInterval(hb));
48
52
  try {
49
53
  const resp = await cached;
50
54
  if (!res.writableEnded)
@@ -160,6 +164,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
160
164
  if (!res.writableEnded && !res.destroyed)
161
165
  res.write(`event: heartbeat\ndata: {}\n\n`); // real frame (not an SSE comment) — a per-frame-parsing BFF drops comments, so downstream saw a zero-frame window; EventSource clients without a heartbeat listener ignore it (zero break)
162
166
  }, 15_000);
167
+ hb.unref?.(); // #131-5①:同文件 durableHeartbeat 口径——停机时心跳不拖事件循环
163
168
  // Wrap the whole stream in idemCache.run so the IN-FLIGHT promise is stored BEFORE work begins — a retry
164
169
  // that arrives WHILE this stream is still running (relay timeout → re-send same key) is then caught by the
165
170
  // peek above and replays this result instead of starting a second billable stream (council). `ranLive`
@@ -942,8 +942,9 @@ export function createHttpServer(rawDeps) {
942
942
  // [854]④: fresh-submit shape validation for per-request 配速 —— 已知三键必须是正整数,否则 400 fail-loud
943
943
  // (body.model silent-drop 同类教训:静默 normalize 成「没配速」正是本件要防的假成功)。0 也拒绝:core 侧
944
944
  // `timeoutSec > 0` 才生效(0=不设墙,会绕过 TASK_TIMEOUT_MAX_SEC 封顶),maxTurns/maxOutputTokens 的 0 无意义。
945
- // 枚举外键与 resilience/attachments 同口径:容忍(只校验已知键)。resolveSpec 的 normalizeLimits 对 RESUME
946
- // 重放路径保持 defensive(按键 DROP,不 throw)。
945
+ // 枚举外键与 resilience/attachments 同口径:容忍(只校验已知键)——但退役键与预算族错位键**点名 400**
946
+ // (它们是「caller 有明确意图、本层却不会采纳」的已知键形,容忍=静默吞意图)。resolveSpec 的
947
+ // normalizeLimits 对 RESUME 重放路径保持 defensive(按键 DROP,不 throw)。
947
948
  if (body.limits !== undefined) {
948
949
  const l = body.limits;
949
950
  if (typeof l !== "object" || l === null || Array.isArray(l)) {
@@ -953,7 +954,8 @@ export function createHttpServer(rawDeps) {
953
954
  // core 5.8.0 时限重构:旧键响亮拒并点名替代(core 同姿势 config.limit_unknown_key;「勿自行兼容」是
954
955
  // core 的公开指令)。timeoutSec(秒)→ maxWalltimeMs(毫秒,注意单位);deadline 族三 opt-out 随
955
956
  // 机制整族退役(nudge/call-cap/graceful-finalize 已从引擎移除),无替代键。fresh fail-loud;resume
956
- // 重放不过此门,normalizeLimits 对旧键 defensive DROP(存量 suspended 体降级为默认配速,方向安全)。
957
+ // 重放不过此门,resolveTaskLimits 对旧键 defensive DROP + 具名留痕(task_limits_legacy_key_dropped:
958
+ // 存量 suspended 体降级为默认配速——单用户无 env 墙时=整任务无墙,多租=延到租户墙,非「方向安全」)。
957
959
  if (l.timeoutSec !== undefined) {
958
960
  sendError(res, 400, "request.field_invalid", "limits.timeoutSec was retired in core 5.8.0 — use limits.maxWalltimeMs (milliseconds; opt-in wall clock)");
959
961
  return null;
@@ -969,6 +971,25 @@ export function createHttpServer(rawDeps) {
969
971
  return null;
970
972
  }
971
973
  }
974
+ // 预算族错位键(core 5.8.0「预算入 limits」的单向缺口):caller 按 core 键形写 limits.maxCostUsd/
975
+ // maxTokens/degrade/budgetStreamCancel 时,本服务的采纳面在 body **顶层**(resolve-spec.ts
976
+ // cappedCeiling)/运营方 env(MODEL_DEGRADE_*)/core 默认(budgetStreamCancel=maxCostUsd 在场即
977
+ // true)——不点名拒则被静默吞掉,方向「更松」(caller 以为封了 0.5 刀,实际无成本上限)。
978
+ // 退役键同姿势 fail-loud;resume 重放不过此门,normalizeLimits 对这四键维持 defensive DROP。
979
+ for (const k of ["maxCostUsd", "maxTokens"]) {
980
+ if (l[k] !== undefined) {
981
+ sendError(res, 400, "request.field_invalid", `limits.${k} is not read from limits here — send it at the request body top level (${k}); it is capped by the operator ceiling ${k === "maxCostUsd" ? "MAX_TASK_COST_USD" : "MAX_TASK_TOKENS"}`);
982
+ return null;
983
+ }
984
+ }
985
+ if (l.degrade !== undefined) {
986
+ sendError(res, 400, "request.field_invalid", "limits.degrade is operator-controlled (env MODEL_DEGRADE_TO / MODEL_DEGRADE_AT_COST_FRACTION), not caller-settable — remove it");
987
+ return null;
988
+ }
989
+ if (l.budgetStreamCancel !== undefined) {
990
+ sendError(res, 400, "request.field_invalid", "limits.budgetStreamCancel is not caller-settable here — the engine default applies (armed whenever a cost budget is present); remove it");
991
+ return null;
992
+ }
972
993
  for (const k of ["maxWalltimeMs", "maxOutputTokens", "maxTurns"]) {
973
994
  const v = l[k];
974
995
  if (v !== undefined && (typeof v !== "number" || !Number.isInteger(v) || v < 1)) {
package/dist/main.js CHANGED
@@ -999,6 +999,7 @@ async function main() {
999
999
  installShutdownHandlers({
1000
1000
  config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter,
1001
1001
  runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState,
1002
+ storeLiveProbe, configCenter, // #131-2:两个漏网的进程级后台环进收尾链
1002
1003
  });
1003
1004
  }
1004
1005
  void main().catch((err) => {
@@ -10,6 +10,7 @@
10
10
  // ③ 串行化 + 节流:syncOnce 按 promise 链串行(两轮并发会在同一文件面上交错写);trigger =
11
11
  // fire-and-forget,上一轮在飞(排队中/执行中)则跳过(设计:简单 inflight 布尔)。
12
12
  // conflicts = logger.warn 逐条上报(不自动 ladder——败者铸 sibling 的解决动作留给 operator/后续单)。
13
+ import { randomBytes } from "node:crypto";
13
14
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
14
15
  import { dirname, join } from "node:path";
15
16
  import { encodeScopeSegment, syncMemoryScope, } from "@sema-agent/core";
@@ -85,7 +86,10 @@ async function loadCursor(path, scope, peer, log) {
85
86
  /** 写 cursor:tmp + rename 原子(半写的 cursor = 下轮坏 JSON 当首轮,重收敛而非错基线)。 */
86
87
  async function saveCursor(path, cursor) {
87
88
  await mkdir(dirname(path), { recursive: true });
88
- const tmp = `${path}.tmp`;
89
+ // #131-3:staging 名带熵(pid+random)——共享 memoryRoot 的跨进程形(server + run-local 同 data 根)
90
+ // 用固定 `.tmp` 会互抢 staging(一侧 rename 走对方的 tmp ⇒ 对方 ENOENT / 写出对方字节);同仓其余
91
+ // 原子写(config-lkg / local-session-store / skills-mcp / workflow-completion-inbox)全带熵,此处对齐。
92
+ const tmp = `${path}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
89
93
  await writeFile(tmp, JSON.stringify(cursor), "utf8");
90
94
  await rename(tmp, path);
91
95
  }
@@ -143,6 +143,10 @@ export declare class BackgroundShellManager<S> {
143
143
  * all-or-nothing: put every operation that can throw BEFORE the first side effect, or clean up on the throw
144
144
  * path itself. Today's three lane builders are entirely non-throwing (listener attach + object construction).
145
145
  */
146
+ /** #131-T0:两条注册腿共用的 BG 超时钳制。非有限值(NaN/±Infinity)当缺席回退 default——
147
+ * Math.max/min 对 NaN 全塌 NaN,setTimeout(NaN) 被 Node 折成 1ms = 后台 shell 秒杀
148
+ * (方向反转:想给超时变成即杀;同族判例 TASK_TIMEOUT_SEC / leader 旋钮非法值)。 */
149
+ private boundedBgTimeoutSec;
146
150
  adoptSync(builder: (ctx: LaunchCtx) => S, timeoutSec?: number): {
147
151
  shellId: BackgroundShellId;
148
152
  } | undefined;
@@ -146,11 +146,22 @@ export class BackgroundShellManager {
146
146
  * all-or-nothing: put every operation that can throw BEFORE the first side effect, or clean up on the throw
147
147
  * path itself. Today's three lane builders are entirely non-throwing (listener attach + object construction).
148
148
  */
149
+ /** #131-T0:两条注册腿共用的 BG 超时钳制。非有限值(NaN/±Infinity)当缺席回退 default——
150
+ * Math.max/min 对 NaN 全塌 NaN,setTimeout(NaN) 被 Node 折成 1ms = 后台 shell 秒杀
151
+ * (方向反转:想给超时变成即杀;同族判例 TASK_TIMEOUT_SEC / leader 旋钮非法值)。 */
152
+ boundedBgTimeoutSec(timeoutSec) {
153
+ const wanted = timeoutSec !== undefined && Number.isFinite(timeoutSec) ? timeoutSec : undefined;
154
+ // caps 同座防御:default/max 由各 lane 从常数×cfg 推导(k8s 腿含 cfg.timeoutMs 除法),坏输入会把
155
+ // NaN 带进 caps——任何一格非有限即回退安全常数(30min/24h,方向:保「有界但不即杀」,§3.6 不破)。
156
+ const dflt = Number.isFinite(this.caps.defaultBgTimeoutSec) ? this.caps.defaultBgTimeoutSec : 1_800;
157
+ const max = Number.isFinite(this.caps.maxBgTimeoutSec) ? this.caps.maxBgTimeoutSec : 86_400;
158
+ // Bounded BG timeout — never unbounded (design/103 §3.6). `maxBgTimeoutSec` is the fail-closed ceiling.
159
+ return Math.min(Math.max(1, wanted ?? dflt), max);
160
+ }
149
161
  adoptSync(builder, timeoutSec) {
150
162
  if (this.liveCount() >= this.caps.maxConcurrent)
151
163
  return undefined; // refused → exec keeps the foreground
152
- // Bounded BG timeout — never unbounded (design/103 §3.6). `maxBgTimeoutSec` is the fail-closed ceiling.
153
- const bgTimeoutSec = Math.min(Math.max(1, timeoutSec ?? this.caps.defaultBgTimeoutSec), this.caps.maxBgTimeoutSec);
164
+ const bgTimeoutSec = this.boundedBgTimeoutSec(timeoutSec);
154
165
  const shellId = `bg_${++this.counter}_${randomUUID()}`; // opaque — never the provider pid (§3.8)
155
166
  const applyTerminal = (e, failed, exitCode) => {
156
167
  if (e.status !== "running")
@@ -198,8 +209,7 @@ export class BackgroundShellManager {
198
209
  if (this.liveCount() >= this.caps.maxConcurrent) {
199
210
  return fail(new BackgroundShellError("limit_exceeded", `Too many running background shells (max ${this.caps.maxConcurrent}); KillShell one first.`));
200
211
  }
201
- // Bounded BG timeout never unbounded (design/103 §3.6). `maxBgTimeoutSec` is the fail-closed ceiling.
202
- const bgTimeoutSec = Math.min(Math.max(1, timeoutSec ?? this.caps.defaultBgTimeoutSec), this.caps.maxBgTimeoutSec);
212
+ const bgTimeoutSec = this.boundedBgTimeoutSec(timeoutSec); // #131-T0:与 adoptSync 同座(NaN 当缺席)
203
213
  // Opaque brand — NOT derived from the provider job/pid (design/103 §3.8 越权红线).
204
214
  const shellId = `bg_${++this.counter}_${randomUUID()}`;
205
215
  const applyTerminal = (e, failed, exitCode) => {
@@ -2,6 +2,7 @@ import type { TaskAttachmentStore, TaskAttachmentRecord } from "./task-attachmen
2
2
  export declare class LocalTaskAttachmentStore implements TaskAttachmentStore {
3
3
  private readonly dir;
4
4
  private metas;
5
+ private hydration;
5
6
  constructor(rootDir: string);
6
7
  private hydrate;
7
8
  put(rec: TaskAttachmentRecord & {
@@ -13,35 +13,39 @@ const ID_RE = /^[0-9a-f-]{16,64}$/i; // uuid 形(server 铸);水合时非此形
13
13
  export class LocalTaskAttachmentStore {
14
14
  dir;
15
15
  metas;
16
+ hydration;
16
17
  constructor(rootDir) {
17
18
  this.dir = join(rootDir, "attachments");
18
19
  }
19
- async hydrate() {
20
- if (this.metas)
21
- return this.metas;
22
- const m = new Map();
23
- try {
24
- for (const f of await fsp.readdir(this.dir)) {
25
- if (!f.endsWith(".json"))
26
- continue;
27
- const id = f.slice(0, -5);
28
- if (!ID_RE.test(id))
29
- continue;
30
- try {
31
- const meta = JSON.parse(await fsp.readFile(join(this.dir, f), "utf8"));
32
- if (meta && meta.id === id)
33
- m.set(id, meta);
34
- }
35
- catch {
36
- /* 半写/损坏 meta:跳过(bin 无 meta 即孤儿,reap 收) */
20
+ hydrate() {
21
+ // #131-1:记「在飞的 promise」而非「结果」(同仓正确形=local-session-store.loaded)。记结果时,
22
+ // 同进程两条并发首触各自 readdir,晚到的空快照会覆盖已被 put() 写入的索引——盘上有档、内存答 null。
23
+ this.hydration ??= (async () => {
24
+ const m = new Map();
25
+ try {
26
+ for (const f of await fsp.readdir(this.dir)) {
27
+ if (!f.endsWith(".json"))
28
+ continue;
29
+ const id = f.slice(0, -5);
30
+ if (!ID_RE.test(id))
31
+ continue;
32
+ try {
33
+ const meta = JSON.parse(await fsp.readFile(join(this.dir, f), "utf8"));
34
+ if (meta && meta.id === id)
35
+ m.set(id, meta);
36
+ }
37
+ catch {
38
+ /* 半写/损坏 meta:跳过(bin 无 meta 即孤儿,reap 收) */
39
+ }
37
40
  }
38
41
  }
39
- }
40
- catch {
41
- /* 目录不存在 = 空店 */
42
- }
43
- this.metas = m;
44
- return m;
42
+ catch {
43
+ /* 目录不存在 = 空店 */
44
+ }
45
+ this.metas = m;
46
+ return m;
47
+ })();
48
+ return this.hydration;
45
49
  }
46
50
  async put(rec) {
47
51
  const metas = await this.hydrate();
@@ -14,7 +14,28 @@ function parseRecord(raw, key) {
14
14
  if (typeof v !== "object" || v === null || !Array.isArray(r.slots) || !Array.isArray(r.buckets)) {
15
15
  throw new Error(`usage_window record for key "${key}" has an invalid shape (expected {slots[],buckets[]}) — refusing to mis-count a governance window`);
16
16
  }
17
- return v;
17
+ // 深度对齐 core FileUsageWindowStore(validateSlots/validateBuckets):内层坏形若放过,非数值
18
+ // tokens 参与累加得 "0oops" ⇒ exhausted:false,超额账户被判未耗尽。逐条校验,任一不合即 fail-loud。
19
+ const badShape = (detail) => new Error(`usage_window record for key "${key}" has an invalid shape (${detail}) — refusing to mis-count a governance window`);
20
+ const finite = (x) => typeof x === "number" && Number.isFinite(x);
21
+ const slots = r.slots.map((s) => {
22
+ if (s === null || typeof s !== "object")
23
+ throw badShape("`slots` entry is not an object");
24
+ const { at, tokens } = s;
25
+ if (!finite(at) || !finite(tokens))
26
+ throw badShape("`slots` entry has a non-finite-numeric `at`/`tokens`");
27
+ return { at, tokens };
28
+ });
29
+ const buckets = r.buckets.map((b) => {
30
+ if (b === null || typeof b !== "object")
31
+ throw badShape("`buckets` entry is not an object");
32
+ const { windowMs, openedAt, tokens } = b;
33
+ if (!finite(windowMs) || windowMs <= 0 || !finite(openedAt) || !finite(tokens)) {
34
+ throw badShape("`buckets` entry has a non-finite-numeric `windowMs`/`openedAt`/`tokens` (windowMs must be > 0)");
35
+ }
36
+ return { windowMs, openedAt, tokens };
37
+ });
38
+ return { slots, buckets };
18
39
  }
19
40
  export async function ensureTiDBUsageWindowSchema(pool) {
20
41
  await pool.query(`CREATE TABLE IF NOT EXISTS ${USAGE_WINDOW_TABLE} (
@@ -93,12 +93,12 @@ export interface TaskLimitCaps {
93
93
  /** TASK_MAX_TURNS_MAX */ maxTurns?: number;
94
94
  }
95
95
  /**
96
- * [854]④ — normalize `body.limits` → 请求方配速意图 {timeoutSec?, maxOutputTokens?, maxTurns?}。DEFENSIVE
96
+ * [854]④ — normalize `body.limits` → 请求方配速意图 {maxWalltimeMs?, maxOutputTokens?, maxTurns?}。DEFENSIVE
97
97
  * (resume 重放持久化 body 不过 HTTP 400 门):仅正的有限数字存活(floor 取整),0/负/垃圾按键 DROP —— core 侧
98
- * `timeoutSec > 0` 才生效(0=不设墙,会绕过运营方封顶)、maxTurns/maxOutputTokens 的 0 无意义。每键各自被
98
+ * 墙钟 > 0 才生效(0=不设墙,会绕过运营方封顶)、maxTurns/maxOutputTokens 的 0 无意义。每键各自被
99
99
  * 运营方上限旋钮 clamp(Math.min;旋钮缺省不设=不封顶)。全空 ⇒ undefined。
100
100
  */
101
- export declare function normalizeLimits(v: unknown, caps: TaskLimitCaps): {
101
+ export declare function normalizeLimits(v: unknown, caps: TaskLimitCaps, warn?: (event: string, fields: Record<string, unknown>) => void): {
102
102
  maxWalltimeMs?: number;
103
103
  maxOutputTokens?: number;
104
104
  maxTurns?: number;
@@ -120,5 +120,5 @@ export declare function normalizeApproachNotice(raw: unknown): false | {
120
120
  * - `maxOutputTokens` / `maxTurns`:直透传(各自可选 env 上限已 clamp)。
121
121
  * 全空 ⇒ undefined(spec 不挂 limits 键,byte-compat)。
122
122
  */
123
- export declare function resolveTaskLimits(bodyLimits: unknown, caps: TaskLimitCaps, envTimeoutSec: number, requirePrincipal: boolean | undefined, big: boolean): TaskSpec["limits"];
123
+ export declare function resolveTaskLimits(bodyLimits: unknown, caps: TaskLimitCaps, envTimeoutSec: number, requirePrincipal: boolean | undefined, big: boolean, warn?: (event: string, fields: Record<string, unknown>) => void): TaskSpec["limits"];
124
124
  //# sourceMappingURL=spec-fields.d.ts.map
@@ -350,18 +350,32 @@ export function toolNameListFromBody(raw) {
350
350
  export function promptProfileFromBody(raw) {
351
351
  return raw === "simple" || raw === "classic" ? raw : undefined;
352
352
  }
353
+ /** core 5.8.0 时限重构的退役键(fresh-submit 在 HTTP 门 400 点名;resume 重放在 normalize 层 DROP)。 */
354
+ const RETIRED_LIMIT_KEYS = ["timeoutSec", "deadlineNudge", "callCapByDeadline", "gracefulFinalize"];
355
+ /** 在场的退役键(normalizeLimits/resolveTaskLimits 共用判据源:留痕事件的 droppedKeys 字段)。 */
356
+ function retiredLimitKeysPresent(v) {
357
+ if (v == null || typeof v !== "object" || Array.isArray(v))
358
+ return [];
359
+ const o = v;
360
+ return RETIRED_LIMIT_KEYS.filter((k) => o[k] !== undefined);
361
+ }
353
362
  /**
354
- * [854]④ — normalize `body.limits` → 请求方配速意图 {timeoutSec?, maxOutputTokens?, maxTurns?}。DEFENSIVE
363
+ * [854]④ — normalize `body.limits` → 请求方配速意图 {maxWalltimeMs?, maxOutputTokens?, maxTurns?}。DEFENSIVE
355
364
  * (resume 重放持久化 body 不过 HTTP 400 门):仅正的有限数字存活(floor 取整),0/负/垃圾按键 DROP —— core 侧
356
- * `timeoutSec > 0` 才生效(0=不设墙,会绕过运营方封顶)、maxTurns/maxOutputTokens 的 0 无意义。每键各自被
365
+ * 墙钟 > 0 才生效(0=不设墙,会绕过运营方封顶)、maxTurns/maxOutputTokens 的 0 无意义。每键各自被
357
366
  * 运营方上限旋钮 clamp(Math.min;旋钮缺省不设=不封顶)。全空 ⇒ undefined。
358
367
  */
359
- export function normalizeLimits(v, caps) {
368
+ export function normalizeLimits(v, caps, warn) {
360
369
  if (v == null || typeof v !== "object" || Array.isArray(v))
361
370
  return undefined;
362
371
  // core 5.8.0:键集换 maxWalltimeMs(毫秒)。旧键(timeoutSec/deadline 族三 opt-out)fresh-submit 已在
363
- // HTTP 门响亮 400 点名替代;本层只走 resume 重放的持久化 body——旧键 defensive DROP(存量 suspended
364
- // 体降级为默认配速,env 墙钟兜底仍在,方向安全;绝不静默换算=那是「自行兼容」,core 明令禁止)。
372
+ // HTTP 门响亮 400 点名替代;本层只走 resume 重放的持久化 body——旧键 defensive DROP 但**必须留痕**
373
+ // (task_limits_legacy_key_dropped):丢 caller 显式时限=收窄契约,存量 suspended 体降级为默认配速时,
374
+ // 单用户 turnkey 无 env 墙 ⇒ 整任务无墙钟、多租 ⇒ 被延到租户墙(2400/3600s)——两向都可能长于原意图。
375
+ // 绝不静默换算=那是「自行兼容」,core 明令禁止。
376
+ const dropped = retiredLimitKeysPresent(v);
377
+ if (dropped.length > 0)
378
+ warn?.("task_limits_legacy_key_dropped", { droppedKeys: dropped });
365
379
  const o = v;
366
380
  const pick = (raw, cap) => {
367
381
  if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 1)
@@ -405,12 +419,17 @@ export function normalizeApproachNotice(raw) {
405
419
  * - `maxOutputTokens` / `maxTurns`:直透传(各自可选 env 上限已 clamp)。
406
420
  * 全空 ⇒ undefined(spec 不挂 limits 键,byte-compat)。
407
421
  */
408
- export function resolveTaskLimits(bodyLimits, caps, envTimeoutSec, requirePrincipal, big) {
422
+ export function resolveTaskLimits(bodyLimits, caps, envTimeoutSec, requirePrincipal, big, warn) {
423
+ // 留痕事件在本层发(不透传给 normalizeLimits,免双发):只有装配点知道最终生效墙钟——
424
+ // effectiveMaxWalltimeMs: null = 整任务无墙(单用户 turnkey 无 env 墙时丢弃旧 timeoutSec 的真后果)。
425
+ const dropped = retiredLimitKeysPresent(bodyLimits);
409
426
  const requested = normalizeLimits(bodyLimits, caps);
410
427
  // core 5.8.0:墙钟键=maxWalltimeMs(毫秒)。taskWallClockSec 仍以秒推导(tenancy 基值+env 只抬不降,
411
428
  // 语义原样),装配处换算一次;body 显式给了 maxWalltimeMs 则直接生效(caller 意图,可低于 env 墙)。
412
429
  const wallSec = taskWallClockSec(envTimeoutSec, requirePrincipal, big);
413
430
  const maxWalltimeMs = requested?.maxWalltimeMs ?? (wallSec !== undefined ? wallSec * 1000 : undefined);
431
+ if (dropped.length > 0)
432
+ warn?.("task_limits_legacy_key_dropped", { droppedKeys: dropped, effectiveMaxWalltimeMs: maxWalltimeMs ?? null });
414
433
  const out = { ...(requested ?? {}), ...(maxWalltimeMs !== undefined ? { maxWalltimeMs } : {}) };
415
434
  return Object.keys(out).length > 0 ? out : undefined;
416
435
  }
@@ -25,8 +25,8 @@ type BgNotifExcluded = "kind" | "sessionScoped" | "owner" | "scope" | "descripti
25
25
  type _GuardBgNotif = AssertAllKeysHandled<Exclude<keyof BackgroundChildEvent, BgNotifProjected | BgNotifExcluded>>;
26
26
  type RosterProjected = "name" | "agentId" | "sessionId" | "toolUseId" | "owner" | "scope" | "sessionScoped" | "rootSessionId" | "model" | "createdAt";
27
27
  type _GuardRoster = AssertAllKeysHandled<Exclude<keyof RosterEntry, RosterProjected>>;
28
- type AskProjected = "toolName" | "args" | "message" | "sourceTaskId" | "fromSubagent" | "sourceAgentName" | "delegation";
29
- type AskExcluded = "toolCallId" | "preview" | "principal" | "requiresRealApproval";
28
+ type AskProjected = "toolName" | "toolCallId" | "args" | "message" | "sourceTaskId" | "fromSubagent" | "sourceAgentName" | "delegation";
29
+ type AskExcluded = "preview" | "principal" | "requiresRealApproval";
30
30
  type _GuardAsk = AssertAllKeysHandled<Exclude<keyof AskRequest, AskProjected | AskExcluded>>;
31
31
  type TaskEventHandled = "text_delta" | "reasoning_delta" | "tool_start" | "tool_end" | "turn_end" | "compacted" | "diagnostics" | "message_committed" | "status" | "task_notification" | "task_progress" | "steering_injected" | "workspace_changed" | "done" | "context_usage" | "compaction_outcome";
32
32
  type _GuardTaskEvent = AssertAllKeysHandled<Exclude<TaskEvent["type"], TaskEventHandled>>;
@@ -89,6 +89,7 @@ export type TraceBlock = {
89
89
  truncated?: boolean;
90
90
  totalChars?: number;
91
91
  structured?: unknown;
92
+ errorCode?: string;
92
93
  eventId?: string;
93
94
  parentToolCallId?: string;
94
95
  } | {
@@ -156,9 +156,13 @@ export function toolEndEventData(ev) {
156
156
  // core 1.442([1898] RB-210):截断诚实体量(各 block 真实大小之和,不含 JSON 包装开销)。
157
157
  ...(Number.isFinite(ev.totalChars) ? { totalChars: ev.totalChars } : {}),
158
158
  ...(ev.structured !== undefined ? { structured: redactDeep(ev.structured) } : {}),
159
- // core 5.9.0 W3([2535]):park/死投影结构化标记(引擎闭词表 `gate.parked`/`tool.not_found`,只在
160
- // isError 时在场)——verbatim 透传,壳按它判别 park 毒化帧而不锚 abort 文案字面。
161
- ...(typeof ev.errorCode === "string" ? { errorCode: ev.errorCode } : {}),
159
+ // core 5.9.0 W3([2535];2026-08-04 复审纠偏):errorCode = core 在 isError 时对**任意工具 ToolResult
160
+ // `details.code`** 的透传 —— 开集,无引擎白名单(runtask 直读 result.details.code,structuredFrom
161
+ // CC_DETAIL_TYPES 白名单只管 structured 键)。引擎码 `gate.parked`/`tool.not_found` 只是两例,
162
+ // 工具层现役即有 `path_not_in_root`/`readonly_out_of_root` 等,自注册工具可加新码 —— 消费端必须
163
+ // 带 default 臂,禁按两值穷举。verbatim 依据:码是机器短串标识符、非用户内容(不脱敏),边界靠
164
+ // 显式上限而非词表 —— >128 字符=非法形,整键丢弃(不截断:截断会铸出 core 从未发过的码)。
165
+ ...(typeof ev.errorCode === "string" && ev.errorCode.length <= 128 ? { errorCode: ev.errorCode } : {}),
162
166
  ...identityFields(ev),
163
167
  };
164
168
  }
@@ -486,6 +490,9 @@ export function toolResultFieldsOf(d) {
486
490
  ...(d.truncated ? { truncated: true } : {}),
487
491
  ...(Number.isFinite(d.totalChars) ? { totalChars: d.totalChars } : {}),
488
492
  ...(d.structured !== undefined ? { structured: d.structured } : {}),
493
+ // core 5.9.0 W3([2535]):机器可判别的 park/死投影码(`gate.parked`/`tool.not_found`)——写侧
494
+ // toolEndEventData 已带,读腿必须同键 verbatim(absent⇒absent),否则冷回放退回锚 abort 文案字面。
495
+ ...(typeof d.errorCode === "string" ? { errorCode: d.errorCode } : {}),
489
496
  ...identityFields(d),
490
497
  };
491
498
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "6.2.0",
3
+ "version": "6.3.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",
@@ -68,7 +68,7 @@
68
68
  "sharp": "^0.35.3"
69
69
  },
70
70
  "devDependencies": {
71
- "@sema-agent/sdk": "^6.1.0",
71
+ "@sema-agent/sdk": "^6.2.0",
72
72
  "@types/libsodium-wrappers": "^0.7.14",
73
73
  "@types/node": "22.10.2",
74
74
  "@types/pg": "^8.20.0",