@sema-agent/server 7.40.0 → 7.41.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.
package/USAGE.md CHANGED
@@ -297,7 +297,16 @@ FLEET_ADVERTISE_ADDRESS=http://<本机可达IP>:8090 # 可选:设了才启 flee
297
297
  ```
298
298
  - 中心**空/未发布** → `applyEffective` 回落 env + 内建 teams 并 warn `config_center_unpublished`,**不影响在跑的服务**(接了也安全)。
299
299
  - **灰度姿势**(配置控制面 AI 建议):先 `SEMA_REGISTRY_DRY_RUN=true` 起一轮,看日志 `sema_registry_dry_run`(中心给的 models/roles/teams + 会否覆盖 default、per-model apiKeyEnv)对得上 env 再去掉该 flag 真正 apply。
300
- - 拉取**只读、只取逻辑配置**(模型名册/角色/团队);密钥/网关仍在本服务 env(中心只发 env-**名** 引用,不发密钥值)。models/roles 改动需重启生效,teams 60s 热刷。回滚=去掉 `SEMA_REGISTRY_URL` 即纯 env。
300
+ - 拉取**只读、只取逻辑配置**(模型名册/角色/团队);密钥/网关仍在本服务 env(中心只发 env-**名** 引用,不发密钥值)。回滚=去掉 `SEMA_REGISTRY_URL` 即纯 env。
301
+ - **配置热更新真表(7.38+ 现行,#322 战役/[4728]③)**——refresh 拍(60s 轮询,或 `POST /v1/admin/config/refresh` 手动触发,见 API 表)对各域的生效方式:
302
+
303
+ | 域 | 生效 | 说明 |
304
+ |---|---|---|
305
+ | models / roles / default 模型 | **热**(下一 refresh 拍) | 全 boot Runner **原子换代**(swap 失败=候选整拒,活配置零触碰);此前「改 models 需重启」的时代已随 Runner swap 腿落地终结 |
306
+ | pricing / keys(env-名引用)/ prompts / teams | **热** | 值换代即生效;cost 族限额座(rate limit / cost quota)#322 批1 起同热(限额=纯比较参数,窗内累计不动;窗长换代=记账周期重开) |
307
+ | **tier 变更**(models-tiers plane 与 tier-frozen 基线不一致) | **defer 到重启**(唯一 defer 臂) | 候选 durable 落地(LKG,`CONFIG_LKG_DURABLE`)后 `/health` 报 `restartRequired`;无 durable handoff ⇒ `/health` 报 `modelPlaneDeferred{version,since,blockedReasons}` 候运维(不强制重启,plane 保持未应用) |
308
+
309
+ 生效与否的观测口=`/health` 世代账键(`configTargetVersion`/`configAppliedVersion`+两 ordinal+`configApplyStaleMs`,见 API 表 `/health` 行)——target≠applied 持续=「改了没生效」的机读信号。
301
310
  - **仅 `/v1/tasks`(同步)+ `/v1/runs`(异步)**——`/v1/tasks/stream` 不支持(多次尝试非单流,400)。与 `verify` **互斥**(同时给 → 400)。
302
311
  - 成本上界 = 任务的 `maxCostUsd`(防冷重跑税)。**三条 core 警示**:① 每档**冷重跑**重付输入成本(便宜档常过才划算);② 门收到**未脱敏**输出(自定义门转发外部 verifier 要脱敏);③ **写工具会跑 N 次**——**只用于只读/幂等任务**(每次升级整任务重跑)。开放式任务(找全 bug/文笔)没有可判定 oracle、级联会空转,那种用 `discuss`(广度对抗)而非级联(深度阶梯)。
303
312
 
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ /** 遗言文件的读回 schema(宪法 [2704] §2:边界读一律 safeParse,不裸铸)。写侧五键同形。 */
3
+ declare const CrashLastSchema: z.ZodObject<{
4
+ at: z.ZodString;
5
+ version: z.ZodString;
6
+ pid: z.ZodNumber;
7
+ uptimeMs: z.ZodNumber;
8
+ inflight: z.ZodOptional<z.ZodNumber>;
9
+ err: z.ZodString;
10
+ }, z.core.$strip>;
11
+ export type CrashLastInfo = z.infer<typeof CrashLastSchema>;
12
+ export declare function crashLastPath(dataRoot: string): string;
13
+ /** 崩溃现场写遗言(同步;自身绝不抛——遗言写失败不能遮蔽原崩溃)。 */
14
+ export declare function writeCrashLast(dataRoot: string, info: {
15
+ version: string;
16
+ inflight: number | undefined;
17
+ err: unknown;
18
+ }): void;
19
+ /** 受控退收尾:删遗言(幂等,缺席零抛)。 */
20
+ export declare function clearCrashLast(dataRoot: string): void;
21
+ /** boot 提示腿:在场读回、缺席/坏字节/坏形回 `undefined`(绝不抛——遗言坏了不能挡启动)。 */
22
+ export declare function readCrashLast(dataRoot: string): CrashLastInfo | undefined;
23
+ export {};
24
+ //# sourceMappingURL=crash-last.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * #320①([4728] clay 五点令)—— 崩溃遗言面 `crash-last.json`。
3
+ *
4
+ * 契约:**文件在场 ⇔ 上一次退出=崩溃**。铸点唯一=shutdown.ts 的 uncaught 双钩(写完仍 exit(1),
5
+ * 不吞崩溃);受控退(hardShutdown 收尾链)`clearCrashLast` 删文件——两形可判别就靠这一删:不删的话
6
+ * 文件语义退化成「历史上最后一次崩溃」,运维无从判「上次到底怎么退的」。
7
+ *
8
+ * 写路径全同步(`writeFileSync`):崩溃现场没有事件循环可信赖,async 写在 exit 前不保证落盘。
9
+ * err 只取**首行**且过 `redactSecrets`——崩溃消息常含连接串/token(DSN 在 error.message 里是
10
+ * 常见形,#305 同病),遗言是**判别位**不是取证档,stack 不落盘。
11
+ */
12
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { z } from "zod";
15
+ import { redactSecrets } from "../trace/redact.js";
16
+ /** 遗言文件的读回 schema(宪法 [2704] §2:边界读一律 safeParse,不裸铸)。写侧五键同形。 */
17
+ const CrashLastSchema = z.object({
18
+ at: z.string(),
19
+ version: z.string(),
20
+ pid: z.number(),
21
+ uptimeMs: z.number(),
22
+ /** 崩溃瞬间的在飞腿数(inflight getter 可用时);拿不到=键缺席,不编 0。 */
23
+ inflight: z.number().optional(),
24
+ err: z.string(),
25
+ });
26
+ export function crashLastPath(dataRoot) {
27
+ return join(dataRoot, "crash-last.json");
28
+ }
29
+ /** 崩溃现场写遗言(同步;自身绝不抛——遗言写失败不能遮蔽原崩溃)。 */
30
+ export function writeCrashLast(dataRoot, info) {
31
+ try {
32
+ const raw = info.err instanceof Error ? info.err.message : String(info.err);
33
+ const firstLine = raw.split("\n", 1)[0] ?? "";
34
+ const body = {
35
+ at: new Date().toISOString(),
36
+ version: info.version,
37
+ pid: process.pid,
38
+ uptimeMs: Math.round(process.uptime() * 1000),
39
+ ...(info.inflight !== undefined ? { inflight: info.inflight } : {}),
40
+ err: redactSecrets(firstLine),
41
+ };
42
+ writeFileSync(crashLastPath(dataRoot), JSON.stringify(body) + "\n");
43
+ }
44
+ catch {
45
+ // 遗言面自身故障(只读 FS 等)静默——崩溃路径上没有更好的去处,原错误仍会打到 stderr。
46
+ }
47
+ }
48
+ /** 受控退收尾:删遗言(幂等,缺席零抛)。 */
49
+ export function clearCrashLast(dataRoot) {
50
+ try {
51
+ unlinkSync(crashLastPath(dataRoot));
52
+ }
53
+ catch {
54
+ // ENOENT(本就没有)与其余失败同置:清不掉只意味着下次 boot 多一行提示,绝不阻塞停机。
55
+ }
56
+ }
57
+ /** boot 提示腿:在场读回、缺席/坏字节/坏形回 `undefined`(绝不抛——遗言坏了不能挡启动)。 */
58
+ export function readCrashLast(dataRoot) {
59
+ try {
60
+ const p = crashLastPath(dataRoot);
61
+ if (!existsSync(p))
62
+ return undefined;
63
+ const parsed = CrashLastSchema.safeParse(JSON.parse(readFileSync(p, "utf8")));
64
+ return parsed.success ? parsed.data : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ //# sourceMappingURL=crash-last.js.map
@@ -26,6 +26,8 @@ import type { BreakerStateStore, CostQuotaStore, RateLimiterStore, StoreBackend
26
26
  import type { WorkflowNotifyJournalStore } from "../orchestration/workflow-notify-journal.js";
27
27
  export interface ShutdownCtx {
28
28
  config: ServiceConfig;
29
+ /** #320①:崩溃遗言的 version 键(与 /v1/capabilities.version 同源=main 的 serviceVersion())。 */
30
+ version: string;
29
31
  logger: Logger;
30
32
  server: ReturnType<typeof createHttpServer>;
31
33
  reaper: NodeJS.Timeout;
@@ -14,11 +14,40 @@
14
14
  * 收尾期还会有 reaper tick 打向正在关闭的池。
15
15
  */
16
16
  import { Runner, defaultTaskRegistry } from "@sema-agent/core";
17
+ import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
17
18
  import { createSighupIdleHandler } from "../sighup-idle.js";
18
19
  import { createParentWatch } from "../parent-watch.js";
19
20
  /** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。 */
20
21
  export function installShutdownHandlers(ctx) {
21
- const { config, logger, server, reaper, fleetReconcile, retentionLane, releaseRetentionLease, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, } = ctx;
22
+ const { config, logger, server, reaper, fleetReconcile, retentionLane, releaseRetentionLease, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, version, } = ctx;
23
+ // #320①([4728] 五点令)—— 崩溃遗言面。契约:`crash-last.json` 在场 ⇔ 上一次退出=崩溃。
24
+ // · boot 提示:上次崩溃留了遗言 ⇒ 一行 warn(读回即证据,不清——清的责任归受控退);
25
+ // · uncaught 双钩:写遗言(version/pid/uptime/inflight/err 首行消毒)后仍按崩溃语义 exit(1),不吞崩溃;
26
+ // · 受控退:hardShutdown 一进来即 clearCrashLast(这次是有意的退)——若后续收尾半途真崩,
27
+ // uncaught 钩会重写遗言,判别不失真。
28
+ // dataRoot 类型上可选(config 解析恒回退 ~/.ai-agent ⇒ 实际恒真,/health dataRoot 同注);类型面
29
+ // 诚实处理:真缺席=遗言面整体不装(uncaught 钩仍装——崩溃语义不能因遗言面缺席而变)。
30
+ const dataRoot = config.localDataRoot;
31
+ if (dataRoot) {
32
+ const prior = readCrashLast(dataRoot);
33
+ if (prior)
34
+ logger.warn("crash_last_found", { at: prior.at, version: prior.version, pid: prior.pid, uptimeMs: prior.uptimeMs, ...(prior.inflight !== undefined ? { inflight: prior.inflight } : {}), err: prior.err });
35
+ }
36
+ const writeLastWords = (err) => {
37
+ if (dataRoot)
38
+ writeCrashLast(dataRoot, { version, inflight: drainState.inflight?.(), err });
39
+ };
40
+ process.on("uncaughtException", (err) => {
41
+ writeLastWords(err);
42
+ // 与 Node 缺省崩溃姿势对齐:stack 打 stderr(遗言只留首行),随后按崩溃码退。
43
+ console.error(err);
44
+ process.exit(1);
45
+ });
46
+ process.on("unhandledRejection", (err) => {
47
+ writeLastWords(err);
48
+ console.error(err);
49
+ process.exit(1);
50
+ });
22
51
  let closing = false;
23
52
  /** #219:parent 监视腿(装配在本函数尾,`config.parentPid` 在场才建)。声明提前只为让 hardShutdown
24
53
  * 能把它一并停掉 —— 文件头契约 3 同族(收尾期不再有后台 tick)。 */
@@ -27,6 +56,8 @@ export function installShutdownHandlers(ctx) {
27
56
  if (closing)
28
57
  return;
29
58
  closing = true;
59
+ if (dataRoot)
60
+ clearCrashLast(dataRoot); // #320①:受控退不留遗言(两形可判别;半途真崩会被 uncaught 钩重写)
30
61
  clearInterval(reaper);
31
62
  clearInterval(fleetReconcile.timer); // #261:契约 3 同族——收尾期不再有对账 tick 打向正在关闭的池
32
63
  // #270 车2:契约 3 同族,且这一条最要紧——留存 tick 起飞的是跨十余张表的**破坏性**事务。
@@ -2176,7 +2176,7 @@ idemKey) {
2176
2176
  windowMs: deps.config.streamApproval?.windowMs ?? 0,
2177
2177
  windowMarginMs: deps.config.streamAskWindowMarginMs,
2178
2178
  }
2179
- : undefined);
2179
+ : undefined, deps.checkpointStore);
2180
2180
  return { status: 202, body: { taskId, sessionId, status: "running" } };
2181
2181
  }, (r) => r.status === 202); // don't cache a transient 409 (session-active, billed nothing) — let a retry re-run (council)
2182
2182
  return resp;
@@ -1,5 +1,5 @@
1
1
  import { runWithVerification, runCascade, uuidv7 } from "@sema-agent/core";
2
- import { markChildrenStoppedByUserOnAbort, resumeAtHttpStatus, stripCheckpointToken, HEARTBEAT_MS } from "../../runs.js";
2
+ import { graftParkToolCallId, markChildrenStoppedByUserOnAbort, resumeAtHttpStatus, stripCheckpointToken, HEARTBEAT_MS } from "../../runs.js";
3
3
  import { withPrincipal } from "../../observability/principal-context.js";
4
4
  import { fleetRunPublisher, fleetRunLabels, fleetRunResiduals, isFleetAgentTerminalNotification } from "../../fleet/fleet-bus.js";
5
5
  import { defaultSubagentTailBus, projectTailFrame } from "../../fleet/subagent-tail-bus.js";
@@ -710,7 +710,9 @@ async function handleTasksBody(req, res, url, ctx, miss) {
710
710
  // Never stream the durable-resume capability token to the client: a
711
711
  // durable suspend's done event carries it. Strip it from BOTH the live write and the cached
712
712
  // body (handed to a concurrent deduplicated caller below). No-op for a normal result.
713
- finalResult = stripCheckpointToken(ev.result);
713
+ // [4913]:graft 先于 strip——durable park 的 done 帧顶层带待批 toolCallId(cli 判别子 v3
714
+ // 写者② 的读点;tool-less park / 非 park / 读失败 = 键缺席,strip 语义不变)。
715
+ finalResult = stripCheckpointToken(await graftParkToolCallId(ev.result, deps.checkpointStore));
714
716
  // R8 (CC parity, interactive rewind): on the sync leg core sets result.taskId = sessionId
715
717
  // (spec.taskId is left unset so a forwarded subagent nests under the run — server.ts above), which
716
718
  // is NOT unique per prompt → it can't be a per-turn rewind handle (every turn would collide). Re-
@@ -12,7 +12,7 @@ import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SET
12
12
  import { parseHooksConfig } from "../hooks/hook-runner.js";
13
13
  import { normalizeApproachNotice, validateTaskAgents } from "../spec-fields.js";
14
14
  import { isValidCwd, MAX_ADDITIONAL_DIRS } from "../task-cwd.js";
15
- import { runInBackground, evictIfConflict, stripCheckpointToken, TurnAnchorCapture, HEARTBEAT_MS, markChildrenStoppedByUserOnAbort } from "../runs.js";
15
+ import { runInBackground, evictIfConflict, stripCheckpointToken, parkToolCallId, TurnAnchorCapture, HEARTBEAT_MS, markChildrenStoppedByUserOnAbort } from "../runs.js";
16
16
  import { readTurnActivityMs, recordTurnActivity } from "../turn-activity.js";
17
17
  import { publicStoreProbeError } from "../store-live-probe.js"; // #305:免鉴权 /health 面的探针错误闭集词表
18
18
  import { looksLikeJwt } from "@sema-agent/registry-core/api/auth-bridge";
@@ -463,6 +463,11 @@ export function createHttpServer(rawDeps) {
463
463
  // additive draining flag — k8s readiness / shell Lifecycle Manager摘流 signal. Absent when not draining.
464
464
  // #291:drainReason = POST /v1/admin/drain 声明的停机因由(additive,未声明=键缺席)。
465
465
  ...(deps.drainState?.draining ? { draining: true, ...(deps.drainState.since ? { drainingSince: deps.drainState.since } : {}), ...(deps.drainState.reason ? { drainReason: deps.drainState.reason } : {}) } : {}),
466
+ // #320②([4728] 五点令):in-flight 腿数**恒报**(不只 draining 时)——排空观测「还剩几条腿」
467
+ // 与日常容量观察同一个键;additive,getter 未接线(drainState 缺席部署)= 键缺席形状不变。
468
+ // 值取 createServer 自赋的同一只 getter(durable bg/resume inflightRuns ∪ live 流 steerableRuns
469
+ // ∪ admission 高水位,见 drainState.inflight 契约注)——不另立第二判据。
470
+ ...(deps.drainState?.inflight ? { inflight: deps.drainState.inflight() } : {}),
466
471
  // S5: additive degradation flag — present ONLY when the boot auto-probe fell back to in-memory, so a
467
472
  // healthy worker's /health shape is unchanged. durable:false = "this replica is NOT persisting although
468
473
  // a DB was configured" (the orchestrator's restart/alarm signal; an in-memory-by-choice worker omits it).
@@ -2190,7 +2195,9 @@ export function createHttpServer(rawDeps) {
2190
2195
  if (reSuspended || vrReopened) {
2191
2196
  await appendModelUsageDelta(ap, deps.modelUsage, taskId); // E8: persist pre-re-suspend per-model usage
2192
2197
  // reopen 腿恒落 `suspended`(它不是 gate 再停,是同一张卡被重开);reSuspended 腿按真 park 词落名。
2193
- await ap(reSuspended ? reParkStatus : "suspended", { gate: vr.checkpointGate ?? null, ...(vrReopened ? { reopened: vr.errorCode ?? null } : {}) });
2198
+ // [4913]:再停事件带待批 toolCallId(源=新 checkpoint pendingAction;tool-less/读失败=键缺席)
2199
+ const vrParkCallId = reSuspended ? await parkToolCallId(vr, deps.checkpointStore) : undefined;
2200
+ await ap(reSuspended ? reParkStatus : "suspended", { gate: vr.checkpointGate ?? null, ...(vrParkCallId !== undefined ? { toolCallId: vrParkCallId } : {}), ...(vrReopened ? { reopened: vr.errorCode ?? null } : {}) });
2194
2201
  }
2195
2202
  else {
2196
2203
  // E8: this verified-resume leg is the run's final done → flush the last delta + SUM model_usage into stats.modelUsage
@@ -2485,8 +2492,10 @@ export function createHttpServer(rawDeps) {
2485
2492
  const r = await stream.result();
2486
2493
  // Close the durable stream with the matching terminal event (mirrors runInBackground). Never the
2487
2494
  // capability token — only the non-secret gate on a re-suspend.
2495
+ // [4913]:再停事件带待批 toolCallId(源=新 checkpoint 的 pendingAction;tool-less/读失败=键缺席)。
2488
2496
  if (r.status === "suspended") {
2489
- await append("suspended", { gate: r.checkpointGate ?? null });
2497
+ const parkCallId = await parkToolCallId(r, deps.checkpointStore);
2498
+ await append("suspended", { gate: r.checkpointGate ?? null, ...(parkCallId !== undefined ? { toolCallId: parkCallId } : {}) });
2490
2499
  await appendModelUsageDelta(append, deps.modelUsage, taskId);
2491
2500
  return r;
2492
2501
  }
package/dist/main.js CHANGED
@@ -1421,6 +1421,7 @@ async function main() {
1421
1421
  // 理由(信号注册时点/三信号相对次序/clearInterval 先于 server.close)见该文件头注。
1422
1422
  installShutdownHandlers({
1423
1423
  config, logger, server, reaper, fleetReconcile, otelExporter, breakerState, costQuota, rateLimiter,
1424
+ version: serviceVersion(), // #320①:崩溃遗言的 version 键(与 capabilities.version 同源)
1424
1425
  runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState,
1425
1426
  storeLiveProbe, configCenter, // #131-2:两个漏网的进程级后台环进收尾链
1426
1427
  // #270 车2:留存 lane 的定时器 + 主动让租(收尾契约 3 同族,理由见 shutdown.ts 的两条注)。
@@ -117,6 +117,10 @@ export declare const FAIL_OPEN_TAGS: {
117
117
  readonly cls: "F";
118
118
  readonly note: "#310:`engine_notice` 分流器把一条白名单通告投给某条 run 腿的登记口时,那只口抛了(live 口写向已断/已撕裂的 SSE socket,或 durable 口的账本写同步抛)。放行的最坏后果 = **这一条通告的这一个终点**缺席:①日志终点在分流之前已经逐字打过(事实一条不丢);②两个终点注册成两只独立 sink ⇒ live 抛不牵连 durable(断连后仍看得见的那半保住);③同会话其余口照投。故 F 类。不放行的代价是把异常回抛给 core 的 `deliverEngineNotice` —— 它会吞掉,于是同一次失败**既没有留痕也没有第二只口**,正是本 tag 要根除的形。留痕是承重的:静默吞掉之后「wire 腿为什么总有几条通告不到」在遥测里与「core 本来就没发」同形。";
119
119
  };
120
+ readonly "server.park.toolcallid-read-failed": {
121
+ readonly cls: "F";
122
+ readonly note: "[4913]:durable park 投影腿在 strip 之前拿结果里的 checkpointToken 换 `pendingAction.toolCallId`(cs.get 一次读),那次读**抛了**(store 抖动 / 滚动升级期 checkpoint 格式 version guard / 行已被并发消费)⇒ 本次 park 的 wire 投影**缺 `toolCallId` 键**,其余键(gate/checkpointId)照旧。放行的最坏后果 = 消费方(cli 判别子 v3 写者②)退回该键到货前的行为——同族多兄弟 durable park 分不出主角、诚实全不标(cli 侧 S3 局限,成文的旧现状),纯展示/关联面,不参与任何门/CAS/resume 判定,故 F 类。绝不挡终局路径是承重的:park 投影点全在 done/suspended 事件写链上,让它抛会把一次 store 抖动升级成整条 run 终局写失败。必须留痕:键缺席与「tool-less park 本就无键」([1995]② OMITTED 契约)在 wire 上同形,不计数则「投影为什么总缺」在遥测里永不显形。";
123
+ };
120
124
  readonly "server.model-key.off-route-model-uses-gateway-key": {
121
125
  readonly cls: "P-DEBT";
122
126
  readonly note: "A-057.59:一只 **baseUrl 指向别家主机**(非本部署 `gatewayBaseUrl`)的目录模型**没有** per-model key ⇒ `createPerModelAuthSeat` 回 `undefined`(side-query 面 = core 5.46.0 的 `SideQuerySpec.getApiKeyAndHeaders` 座位;WebFetch 摘要面 = 同一只座位包在 `createPerModelKeyBrain` 里,那面 core 至今无座位)⇒ core 的 `options?.apiKey ?? config.apiKey` 回落**主网关 key**,而同一行下面的 `model.baseUrl || config.baseUrl` 仍把请求路由到那台外部主机 —— 这一次调用真把共享网关凭据发给了运维在 config-center 里指定的另一家。方向明知不对(hook 面对逐字同形的场景是 TRUE fail-closed:「refusing to send the prompt off-gateway」),之所以记债而不是当场收严:该缺席语义与**主推理链**(`dist/engine/harness/agent-harness.js` 的 `auth?.apiKey !== undefined` 臂)逐字同语义,只收严 wrapper 这两面会造成「同一只模型跑任务能用、问一句 401」,且「同账号多网关主机/多区域」是既有且正当的部署形、无旋钮可退。计数覆盖两面(side-query / WebFetch 摘要),`detail` = 模型名。⚠️ 判据需要参照系:调用方没给 `gatewayBaseUrl` ⇒ **不计**(宁可漏计不可错计),故计数是**下限**。终局收口 = 跨面收严件 #309(三面同批 + 旋钮 + 表态制),届时本 tag 同批销。";
@@ -140,6 +140,10 @@ export const FAIL_OPEN_TAGS = {
140
140
  cls: "F",
141
141
  note: "#310:`engine_notice` 分流器把一条白名单通告投给某条 run 腿的登记口时,那只口抛了(live 口写向已断/已撕裂的 SSE socket,或 durable 口的账本写同步抛)。放行的最坏后果 = **这一条通告的这一个终点**缺席:①日志终点在分流之前已经逐字打过(事实一条不丢);②两个终点注册成两只独立 sink ⇒ live 抛不牵连 durable(断连后仍看得见的那半保住);③同会话其余口照投。故 F 类。不放行的代价是把异常回抛给 core 的 `deliverEngineNotice` —— 它会吞掉,于是同一次失败**既没有留痕也没有第二只口**,正是本 tag 要根除的形。留痕是承重的:静默吞掉之后「wire 腿为什么总有几条通告不到」在遥测里与「core 本来就没发」同形。",
142
142
  },
143
+ "server.park.toolcallid-read-failed": {
144
+ cls: "F",
145
+ note: "[4913]:durable park 投影腿在 strip 之前拿结果里的 checkpointToken 换 `pendingAction.toolCallId`(cs.get 一次读),那次读**抛了**(store 抖动 / 滚动升级期 checkpoint 格式 version guard / 行已被并发消费)⇒ 本次 park 的 wire 投影**缺 `toolCallId` 键**,其余键(gate/checkpointId)照旧。放行的最坏后果 = 消费方(cli 判别子 v3 写者②)退回该键到货前的行为——同族多兄弟 durable park 分不出主角、诚实全不标(cli 侧 S3 局限,成文的旧现状),纯展示/关联面,不参与任何门/CAS/resume 判定,故 F 类。绝不挡终局路径是承重的:park 投影点全在 done/suspended 事件写链上,让它抛会把一次 store 抖动升级成整条 run 终局写失败。必须留痕:键缺席与「tool-less park 本就无键」([1995]② OMITTED 契约)在 wire 上同形,不计数则「投影为什么总缺」在遥测里永不显形。",
146
+ },
143
147
  "server.model-key.off-route-model-uses-gateway-key": {
144
148
  cls: "P-DEBT",
145
149
  note: "A-057.59:一只 **baseUrl 指向别家主机**(非本部署 `gatewayBaseUrl`)的目录模型**没有** per-model key ⇒ `createPerModelAuthSeat` 回 `undefined`(side-query 面 = core 5.46.0 的 `SideQuerySpec.getApiKeyAndHeaders` 座位;WebFetch 摘要面 = 同一只座位包在 `createPerModelKeyBrain` 里,那面 core 至今无座位)⇒ core 的 `options?.apiKey ?? config.apiKey` 回落**主网关 key**,而同一行下面的 `model.baseUrl || config.baseUrl` 仍把请求路由到那台外部主机 —— 这一次调用真把共享网关凭据发给了运维在 config-center 里指定的另一家。方向明知不对(hook 面对逐字同形的场景是 TRUE fail-closed:「refusing to send the prompt off-gateway」),之所以记债而不是当场收严:该缺席语义与**主推理链**(`dist/engine/harness/agent-harness.js` 的 `auth?.apiKey !== undefined` 臂)逐字同语义,只收严 wrapper 这两面会造成「同一只模型跑任务能用、问一句 401」,且「同账号多网关主机/多区域」是既有且正当的部署形、无旋钮可退。计数覆盖两面(side-query / WebFetch 摘要),`detail` = 模型名。⚠️ 判据需要参照系:调用方没给 `gatewayBaseUrl` ⇒ **不计**(宁可漏计不可错计),故计数是**下限**。终局收口 = 跨面收严件 #309(三面同批 + 旋钮 + 表态制),届时本 tag 同批销。",
package/dist/runs.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { defaultTaskRegistry, type CascadeConfig, type Runner, type SubagentSteerHandle, type TaskResult, type TaskSpec, type TaskStream } from "@sema-agent/core";
1
+ import { defaultTaskRegistry, type CascadeConfig, type CheckpointToken, type Runner, type SubagentSteerHandle, type TaskResult, type TaskSpec, type TaskStream } from "@sema-agent/core";
2
2
  import type { SendUserFileEmitter } from "./capabilities/send-user-file-tool.js";
3
3
  import type { RunStore } from "./plugins/store-backend.js";
4
4
  import type { Metrics } from "./observability/metrics.js";
@@ -192,6 +192,30 @@ export declare class TurnAnchorCapture {
192
192
  * non-secret and intentionally kept.
193
193
  */
194
194
  export declare function stripCheckpointToken<T>(result: T): T;
195
+ /** [4913] 投影腿的最小 checkpoint 读口(结构型——真店 `CheckpointStore.get` 直接对上)。 */
196
+ type ParkCheckpointReader = {
197
+ get(token: CheckpointToken): Promise<{
198
+ pendingAction?: {
199
+ kind?: unknown;
200
+ toolCallId?: unknown;
201
+ };
202
+ } | null>;
203
+ };
204
+ /**
205
+ * [4913](cli 判别子 v3 请托)—— park 结果的**待批工具调用身份**:suspended/needs_review 结果在
206
+ * strip 之前手握 checkpointToken,用它 `cs.get(token)` 换 `pendingAction`,`tool_approval` 臂直读
207
+ * `toolCallId`(与 tool_approval 帧 ≥1.307 同键同义)。核心 `TaskResult` 上没有这个键(CheckpointGate
208
+ * 只带 toolName),checkpoint 本体是 park 的事实源——同源判据(A-058 教训)。
209
+ *
210
+ * 缺席形照 core `CheckpointSummary` 的 [1995]② OMITTED 契约:tool-less park(resource_limit /
211
+ * plan_review / task_done)回 `undefined` ⇒ 调用方**不铸键**,绝不编 null。读失败=F 类兜底
212
+ * (展示/关联键,缺席=该键到货前的现状),`recordFailOpen` 留痕,绝不挡 done/suspended 终局写链。
213
+ */
214
+ export declare function parkToolCallId(result: unknown, cs: ParkCheckpointReader | undefined): Promise<string | undefined>;
215
+ /** [4913] 整对象形:park 结果顶层 additive 上 `toolCallId` 键(与 `checkpointId` 同位,#285 先例;
216
+ * 不塞进 `checkpointGate`——那是 core 的类型,server 不改其形)。非 park / 无 token / tool-less /
217
+ * 读失败 ⇒ **同一引用**原样返回(键缺席)。graft 不负责 strip——组合序恒为 graft → strip。 */
218
+ export declare function graftParkToolCallId<T>(result: T, cs: ParkCheckpointReader | undefined): Promise<T>;
195
219
  /** Evict a stale warm-cache entry after a cross-instance write conflict (broken-affinity backstop). */
196
220
  export declare function evictIfConflict(runner: Runner, sessionId: string | undefined, result: TaskResult): void;
197
221
  /**
@@ -275,10 +299,21 @@ approval?: {
275
299
  streamApprovalOn: boolean;
276
300
  windowMs: number;
277
301
  windowMarginMs: number;
302
+ },
303
+ /** [4913]:park 投影腿的 checkpoint 读口(`deps.checkpointStore`)。在场时,durable park 的
304
+ * `suspended` 事件带 `toolCallId`(graft 见 {@link parkToolCallId});缺席=旧字节逐字不变。 */
305
+ parkCheckpointReader?: {
306
+ get(token: CheckpointToken): Promise<{
307
+ pendingAction?: {
308
+ kind?: unknown;
309
+ toolCallId?: unknown;
310
+ };
311
+ } | null>;
278
312
  }): Promise<void>;
279
313
  /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
280
314
  * Must stay strictly below `runStaleSec` (asserted at startup AND on every hot config candidate) or the
281
315
  * reaper would race live runs. #322:数值的单源是 `config-invariants.ts`(那条不变量的属主),两个消费面
282
316
  * ——心跳环与 run-stale 判据——因此不可能各读各的。 */
283
317
  export declare const HEARTBEAT_MS = 30000;
318
+ export {};
284
319
  //# sourceMappingURL=runs.d.ts.map
package/dist/runs.js CHANGED
@@ -282,6 +282,40 @@ export function stripCheckpointToken(result) {
282
282
  }
283
283
  return result;
284
284
  }
285
+ /**
286
+ * [4913](cli 判别子 v3 请托)—— park 结果的**待批工具调用身份**:suspended/needs_review 结果在
287
+ * strip 之前手握 checkpointToken,用它 `cs.get(token)` 换 `pendingAction`,`tool_approval` 臂直读
288
+ * `toolCallId`(与 tool_approval 帧 ≥1.307 同键同义)。核心 `TaskResult` 上没有这个键(CheckpointGate
289
+ * 只带 toolName),checkpoint 本体是 park 的事实源——同源判据(A-058 教训)。
290
+ *
291
+ * 缺席形照 core `CheckpointSummary` 的 [1995]② OMITTED 契约:tool-less park(resource_limit /
292
+ * plan_review / task_done)回 `undefined` ⇒ 调用方**不铸键**,绝不编 null。读失败=F 类兜底
293
+ * (展示/关联键,缺席=该键到货前的现状),`recordFailOpen` 留痕,绝不挡 done/suspended 终局写链。
294
+ */
295
+ export async function parkToolCallId(result, cs) {
296
+ if (!cs || result === null || typeof result !== "object")
297
+ return undefined;
298
+ const r = result;
299
+ if ((r.status !== "suspended" && r.status !== "needs_review") || typeof r.checkpointToken !== "string")
300
+ return undefined;
301
+ try {
302
+ const pa = (await cs.get(r.checkpointToken))?.pendingAction;
303
+ return pa?.kind === "tool_approval" && typeof pa.toolCallId === "string" ? pa.toolCallId : undefined;
304
+ }
305
+ catch (e) {
306
+ recordFailOpen("server.park.toolcallid-read-failed", e instanceof Error ? e.message : String(e));
307
+ return undefined;
308
+ }
309
+ }
310
+ /** [4913] 整对象形:park 结果顶层 additive 上 `toolCallId` 键(与 `checkpointId` 同位,#285 先例;
311
+ * 不塞进 `checkpointGate`——那是 core 的类型,server 不改其形)。非 park / 无 token / tool-less /
312
+ * 读失败 ⇒ **同一引用**原样返回(键缺席)。graft 不负责 strip——组合序恒为 graft → strip。 */
313
+ export async function graftParkToolCallId(result, cs) {
314
+ if (result !== null && typeof result === "object" && Object.hasOwn(result, "toolCallId"))
315
+ return result; // 已带(未来 core 直供形)不覆写
316
+ const id = await parkToolCallId(result, cs);
317
+ return id === undefined ? result : { ...result, toolCallId: id };
318
+ }
285
319
  /** Evict a stale warm-cache entry after a cross-instance write conflict (broken-affinity backstop). */
286
320
  export function evictIfConflict(runner, sessionId, result) {
287
321
  if (sessionId && isSessionConflictResult(result)) {
@@ -360,7 +394,10 @@ promptManifests,
360
394
  * `streamApprovalOn` = 协议上场判据(`resolveStreamApprovalGate`,由路由层求值后传进来 —— 本函数是纯
361
395
  * 执行腿,不该自己读 backend/config)。为假 ⇒ 只接既有四键 ctx(呈卡口/腿轴都不接),本腿字节逐字不变。
362
396
  */
363
- approval) {
397
+ approval,
398
+ /** [4913]:park 投影腿的 checkpoint 读口(`deps.checkpointStore`)。在场时,durable park 的
399
+ * `suspended` 事件带 `toolCallId`(graft 见 {@link parkToolCallId});缺席=旧字节逐字不变。 */
400
+ parkCheckpointReader) {
364
401
  const startedAt = Date.now();
365
402
  metrics?.addGauge("runs_active", 1);
366
403
  fleetPublisher?.onStart(); // MF-Fleet: the row goes live (running) the moment the background run starts
@@ -809,7 +846,8 @@ approval) {
809
846
  // capability `checkpointToken` to the replayable event log / run row (search [18] Q1b) — only the
810
847
  // non-secret gate (sink.appendParked). The submitter polling GET /v1/runs/:id sees status:"suspended";
811
848
  // an operator resumes via /v1/approvals (token looked up internally). The worker is freed (runs_active -1).
812
- await sink.appendParked("suspended", ev.result);
849
+ // [4913]:graft 先于投影——事件里带待批 toolCallId(tool-less park 键缺席;读失败=F 类留痕,不挡 park)
850
+ await sink.appendParked("suspended", await graftParkToolCallId(ev.result, parkCheckpointReader));
813
851
  await flushModelUsage(); // E8: persist pre-suspend usage before parking (resume leg accumulates fresh)
814
852
  reached = { kind: "suspended" };
815
853
  await runStore.setSuspended(taskId);
@@ -221,8 +221,13 @@ export function createLedgerSink(opts) {
221
221
  // `onEvent({type:"status",…})`:那一次 `await flush()` 正是被修掉的病。
222
222
  const appendStatus = (ev) => append("status", brainStatusEventData(ev));
223
223
  const appendParked = (kind, result) =>
224
- // Never the capability checkpointToken — only the non-secret gate (search [18] Q1b).
225
- append(kind, { gate: result?.checkpointGate ?? null });
224
+ // Never the capability checkpointToken — only the non-secret gate (search [18] Q1b)
225
+ // [4913]:结果若已被 graftParkToolCallId 补过待批 toolCallId(调用方在 park 点先 graft),随行投影;
226
+ // 缺席(tool-less park / graft 未接 / 读失败)= 键不铸([1995]② OMITTED 契约,不编 null)。
227
+ append(kind, {
228
+ gate: result?.checkpointGate ?? null,
229
+ ...(typeof result?.toolCallId === "string" ? { toolCallId: result.toolCallId } : {}),
230
+ });
226
231
  const appendDone = (result) => append("done", { result });
227
232
  const onDone = async (result) => {
228
233
  await flush();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.40.0",
3
+ "version": "7.41.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",