@sema-agent/server 7.39.0 → 7.40.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.
@@ -11,7 +11,7 @@ import type { ServiceConfig } from "../config.js";
11
11
  import { ElicitationCoordinator } from "../elicitation.js";
12
12
  import type { Logger } from "../observability/logger.js";
13
13
  import { QuestionCoordinator } from "../question.js";
14
- import { ToolApprovalCoordinator } from "../tool-approval.js";
14
+ import { ToolApprovalCoordinator, type ParkedAskRedeem } from "../tool-approval.js";
15
15
  import { SendUserFileEmitter } from "../capabilities/send-user-file-tool.js";
16
16
  import { TaskEnvRegistry } from "../capabilities/sandbox-file-send.js";
17
17
  import type { StoreBackend } from "../plugins/store-backend.js";
@@ -27,6 +27,10 @@ export interface LiveCoordinatorsCtx {
27
27
  /** #295(F-1):卡批规则的 project root 解析器(语义单点=task-cwd.ts `cardRuleScopeRoot`;main.ts
28
28
  * 用 per-session cwd 登记簿 + config 铸)。缺席 ⇒ 不铸 scope,卡批规则落 core 的 global 缺省。 */
29
29
  ruleScopeRootFor: ((sessionId: string | undefined) => string | undefined) | undefined;
30
+ /** #329:PARKED 行迟到决议的赎回席**取值口**(晚绑 —— 席本体是 `createHttpServer` 的产物,而本段
31
+ * 必须排在它之前;同款 holder 先例 = reaper 的 `getRunDenySweep`)。取到 undefined ⇒ 那条臂逐字回落
32
+ * 修前 404(partial 部署安全)。缺席(不传本键)⇒ 协调器整条迟到腿的 PARKED 臂恒 404。 */
33
+ getParkedRedeem?: () => ParkedAskRedeem | undefined;
30
34
  }
31
35
  export declare function createLiveCoordinators(ctx: LiveCoordinatorsCtx): {
32
36
  elicitation: ElicitationCoordinator | undefined;
@@ -8,7 +8,7 @@ import { withLedgerRecording } from "../plugins/send-file-ledger.js";
8
8
  import { basename, resolve } from "node:path";
9
9
  import { stat as fsStat, readFile as fsReadFile } from "node:fs/promises";
10
10
  export function createLiveCoordinators(ctx) {
11
- const { config, logger, backend, sendUserFileTaskEnvs, ruleConsent, ruleScopeRootFor } = ctx;
11
+ const { config, logger, backend, sendUserFileTaskEnvs, ruleConsent, ruleScopeRootFor, getParkedRedeem } = ctx;
12
12
  // E23 (shell-host contract): inbound MCP elicitation coordinator (live-only HITL). Present ONLY when MCP_ELICITATION_ENABLED
13
13
  // — absent ⇒ onElicit is not wired ⇒ core advertises no elicitation capability to any server (fail-closed). Shared
14
14
  // by the runner (the onElicit seam) and the HTTP layer (the respond route + the per-run ALS context wraps).
@@ -83,6 +83,8 @@ export function createLiveCoordinators(ctx) {
83
83
  // 部署(park 的承载是 core checkpoint,不是 ask 行)。把政策挂到协议开关上会把它整个切掉。
84
84
  // 协调器本身缺席时(`TOOL_APPROVAL_ENABLED` 关)本旋钮无处施加 —— 成文事实,不是推断腿。
85
85
  unattendedPolicy: config.unattendedApprovalPolicy,
86
+ // #329:PARKED 行迟到决议的赎回席(晚绑取值口,见 LiveCoordinatorsCtx.getParkedRedeem)。
87
+ ...(getParkedRedeem !== undefined ? { parkedRedeem: getParkedRedeem } : {}),
86
88
  })
87
89
  : undefined;
88
90
  // SendUserFile(真 CC 契约,clay 2026-07-14)两种 lane 形态,其余缺席=诚实(工具不进 roster):
@@ -1663,7 +1663,9 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1663
1663
  }
1664
1664
  // #151 车2:respond() 的返回型是 union(store 缺席时同步、在场时 Promise——src/tool-approval.ts 顶注);
1665
1665
  // await 对两者都是恒等/透传操作,这里统一 await 以兼容两条路径。
1666
- const { status, body: respBody } = await deps.toolApproval.respond(id, principal, body);
1666
+ // #329:末位透传**本次请求**。它只沿「行已 PARKED 的迟到决议」那条臂流到赎回席(那条腿要真请求做
1667
+ // 准入解析与舰队 scope,与 `/decide` 腿同姿势);live 命中路径一个字节都不看它。
1668
+ const { status, body: respBody } = await deps.toolApproval.respond(id, principal, body, req);
1667
1669
  sendJson(res, status, respBody);
1668
1670
  return;
1669
1671
  }
@@ -17,7 +17,7 @@ import type { FleetLeaseManager } from "../fleet-lease.js";
17
17
  import { type FleetEventBus } from "../fleet/fleet-bus.js";
18
18
  import type { ElicitationCoordinator } from "../elicitation.js";
19
19
  import type { QuestionCoordinator } from "../question.js";
20
- import { type ToolApprovalCoordinator } from "../tool-approval.js";
20
+ import { type ParkedAskRedeem, type ToolApprovalCoordinator } from "../tool-approval.js";
21
21
  import type { PlanCacheProbe } from "../plan-cache-probe.js";
22
22
  import type { Logger } from "../observability/logger.js";
23
23
  import type { Metrics } from "../observability/metrics.js";
@@ -576,6 +576,8 @@ export declare function validateUserSkills(skills: unknown): string | null;
576
576
  export declare function createHttpServer(rawDeps: ServiceDeps): http.Server & {
577
577
  denyExpiredApprovals: (now: number) => Promise<void>;
578
578
  isRunLiveOnThisReplica: (taskId: string) => boolean;
579
+ /** #329:live 回决腿的 PARKED 赎回席(装配层把它接进协调器的晚绑座,见 {@link redeemParkedAsk})。 */
580
+ redeemParkedAsk: ParkedAskRedeem;
579
581
  };
580
582
  /** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
581
583
  * guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
@@ -606,7 +606,7 @@ export function createHttpServer(rawDeps) {
606
606
  // Service-credential gate + SOURCE derivation: which system's token authenticated this request.
607
607
  // `source` is set ONLY here, from the credential — a caller cannot claim another system's identity.
608
608
  let source = null;
609
- const anyServiceAuth = Boolean(deps.config.authToken) || Object.keys(deps.config.authTokens ?? {}).length > 0;
609
+ const anyServiceAuth = hasServiceAuth(deps.config); // 单一属主(#329 parked 赎回席读同一句)
610
610
  if (anyServiceAuth) {
611
611
  const sys = systemFor(req, deps.config);
612
612
  if (sys === undefined) {
@@ -3305,6 +3305,112 @@ export function createHttpServer(rawDeps) {
3305
3305
  if (parkedSkipped > 0)
3306
3306
  deps.logger?.info?.("deny_sweep_parked_skipped", { count: parkedSkipped });
3307
3307
  }
3308
+ /**
3309
+ * #329 —— live 回决腿(`POST /v1/tool-approvals/:id/respond`)对一条**已 park** 的 ask 的赎回席。
3310
+ *
3311
+ * 语义 = 「壳本来要人手做的那件事,server 替它做完」:PARKED 行上的 gate 三坐标就是
3312
+ * `/v1/approvals/:sessionId/decide` 里操作员回显的那三件,于是本席把它们原样喂回**同一条**
3313
+ * `resumeCheckpoint` 腿(parked 子代赎回 / 任务级 resume 由那条腿自己 parked-first 分流)。
3314
+ * [4872] 锚①「同腿新调用方,零新终局语义」:这里没有第二个终局写点,单赢者仍是那条腿的 CAS
3315
+ * (`markResuming` / `claimParkedAgent`),本席只是又一个调用方。
3316
+ *
3317
+ * 三件刻意的姿势:
3318
+ * ① **binding 逐字回显、绝不重算** —— `checkpointToken` 给的是行上的 `gateToken`(= park 那一刻从
3319
+ * checkpoint 抄回的那一把):它同时是 D-1 的 stale 判据(gate 已被重铸/已解决 ⇒ 那条腿自己回
3320
+ * `approval_stale` 409),所以「决议投给了一张过期的卡」在这条链上是被**拒**的,不是被猜的;
3321
+ * ② **sessionId 取 checkpoint 自己的那一个**,不是 ask 行的 —— 委派子代的 park 落在子代会话上,
3322
+ * 而 ask 行的 `sessionId` 是**投递上下文**(宿主流)的会话,拿后者去 resume 会打到别人的门上;
3323
+ * ③ **acceptEarly=true**(#316):有人正卡在 respond 的 HTTP 上等答复,受理语义是这条腿既有的形。
3324
+ */
3325
+ async function redeemParkedAsk(req) {
3326
+ const cs = deps.checkpointStore;
3327
+ if (!cs) {
3328
+ // 结构上到不了(协调器的 askStore 与 park 设施同在同不在);诚实拒,绝不假装受理。
3329
+ return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
3330
+ }
3331
+ // 🔴 **计费提交三道门**(codex 对抗复审 R1-[high] 二,验真后修)。`POST /v1/tool-approvals/:id/respond`
3332
+ // 刻意**不在** `isBillableSubmitPath` 里,而且必须留在外面:活卡回决是「让在飞的那一轮跑完」,排空期
3333
+ // 恰恰要放行(那道门的自注写着 read/cancel/steer/respond 全开)。但**本席不是活卡回决** —— 它开一条新的
3334
+ // resume 腿(烧模型),与 `/v1/approvals/:sessionId/decide` 同一件事;那条 url 在名单里、吃这三道门。
3335
+ // 于是门贴在**这条臂**上,而不是整条路由上:文案与 errorCode 逐字取 `handle()` 的同三处(同一件事在
3336
+ // wire 上只该有一种说法),真值也取同三个 dep(不另立判据)。⚠️ 唯一的形差如实记:那三处是
3337
+ // `sendError` + `retry-after` 头,本席只产生**响应体**(路由用 `sendJson` 送),所以没有那个头 ——
3338
+ // 体里的码/文案足以让消费端分型,退避节奏由它自己的通用 503 处置管。
3339
+ if (!hasServiceAuth(deps.config) && !deps.config.allowUnauthedWrites) {
3340
+ return { status: 503, body: { error: "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions", errorCode: "auth.service_token_required" } };
3341
+ }
3342
+ if (deps.drainState?.draining) {
3343
+ return {
3344
+ status: 503,
3345
+ body: {
3346
+ error: "draining",
3347
+ errorCode: "draining",
3348
+ message: "this instance is draining for shutdown/upgrade — retry against the replacement instance",
3349
+ ...(deps.drainState.reason ? { reason: deps.drainState.reason } : {}),
3350
+ },
3351
+ };
3352
+ }
3353
+ if (deps.modelReady && !deps.modelReady()) {
3354
+ return {
3355
+ status: 503,
3356
+ body: {
3357
+ error: "model_roster_pending",
3358
+ errorCode: "state.model_roster_pending",
3359
+ message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly",
3360
+ },
3361
+ };
3362
+ }
3363
+ // 🔴 A-058 F0(7.40 合并窗重扫,high,opus 证伪 CONFIRMED):第四道 = **成本配额门**。`/decide` 对
3364
+ // 同一动作吃 `quotaExceeded`(approvals-assistant 路由挂);respond 路由刻意只有 rateLimited(活卡
3365
+ // 回决=resolve 在飞 promise,确实无新计费,路由注释如实),但本席开的是一条新的烧模型 resume 腿——
3366
+ // 修前超窗租户可以「攒一张会 park 的卡、park 后再赎」绕开成本上限。判据与文案逐字取 `quotaExceeded`
3367
+ // 的同三件(gatedPrincipal 验证身份 / costQuota.check / 429 同码同文案);无 principal = not gated
3368
+ // 是那道门自己的成文语义(单用户 turnkey 形),此处同判不另立。
3369
+ if (deps.costQuota && req.httpReq) {
3370
+ const quotaPrincipal = gatedPrincipal(req.httpReq, deps.config);
3371
+ if (quotaPrincipal !== undefined) {
3372
+ const d = deps.costQuota.check(quotaPrincipal);
3373
+ if (!d.allowed) {
3374
+ deps.metrics?.inc("cost_quota_rejected_total");
3375
+ return {
3376
+ status: 429,
3377
+ body: { error: "cost quota exceeded", errorCode: "limit.cost_quota_exceeded", usedMicroUsd: d.usedMicroUsd, limitMicroUsd: d.limitMicroUsd, retryAfterSec: d.retryAfterSec },
3378
+ };
3379
+ }
3380
+ }
3381
+ }
3382
+ let cp;
3383
+ try {
3384
+ cp = await cs.get(req.gateToken);
3385
+ }
3386
+ catch (e) {
3387
+ return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
3388
+ }
3389
+ if (!cp) {
3390
+ // 行说 PARKED,checkpoint 却没了(过期清算 / 已被消费)——answer 无处兑现。用与「本会话没有 pending」
3391
+ // 同一句同一码(不给存在性谕示,与 decide 腿的 404 口径一致)。
3392
+ return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
3393
+ }
3394
+ // 🔴 属主门第三道:**checkpoint 当下的 scope**(codex R1-[high] 一的另一半)。判据逐字取 `/decide` 路由
3395
+ // 的非 operator 支(`"_"`/null = 匿名单用户形,开放;否则必须与已验证调用方逐字相等)—— 本席绝不比那条
3396
+ // 腿宽。收编把行与 cp 的身份一起改写之后,持旧 approvalId 的前属主到这里被挡下(协调器那边还有一道
3397
+ // 行属主门,两道各判各的持久事实)。**不加 operator 越权臂**:活卡是终端用户自己的。
3398
+ const cpPrincipal = decodeCheckpointScope(cp.scope);
3399
+ if (cpPrincipal !== undefined && cpPrincipal !== req.principal) {
3400
+ deps.logger?.warn?.("parked_late_decision_scope_mismatch", { askId: req.askId });
3401
+ return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
3402
+ }
3403
+ const binding = {
3404
+ checkpointToken: req.gateToken,
3405
+ ...(req.gateBoundCallId !== null ? { boundCallId: req.gateBoundCallId } : {}),
3406
+ ...(req.gateBoundInputHash !== null ? { boundInputHash: req.gateBoundInputHash } : {}),
3407
+ ...(req.updatedInput !== undefined ? { updatedInput: req.updatedInput } : {}),
3408
+ };
3409
+ // #204 件6③:出处 = `"human"` —— 走到这里的恒是壳上那张卡被**人**按下的那一次 respond
3410
+ // (窗到期/取消/SLA sweep 都不经本席)。
3411
+ const out = await resumeCheckpoint(cp.sessionId, req.decision, req.note, "human", req.httpReq, undefined, binding, undefined, true);
3412
+ return { status: out.status, body: out.body };
3413
+ }
3308
3414
  /**
3309
3415
  * #261 §1(a):**副本本地活体谓词** —— 这条 taskId 现在是不是本副本手上的活腿。
3310
3416
  *
@@ -3316,7 +3422,7 @@ export function createHttpServer(rawDeps) {
3316
3422
  const isRunLiveOnThisReplica = (taskId) => inflightRuns.has(taskId) || steerableRuns.has(taskId);
3317
3423
  // Augment the http.Server with the D-D deny-sweep handle (back-compat: all 12 callers keep using the return
3318
3424
  // value AS an http.Server — listen/close/etc. — while main.ts's reaper reads server.denyExpiredApprovals).
3319
- return Object.assign(server, { denyExpiredApprovals, isRunLiveOnThisReplica });
3425
+ return Object.assign(server, { denyExpiredApprovals, isRunLiveOnThisReplica, redeemParkedAsk });
3320
3426
  }
3321
3427
  const WORKER_SWAP_REDEEMABLE = {
3322
3428
  version_newer: true,
@@ -3359,6 +3465,12 @@ function checkpointRowRedeemableElsewhere(reason) {
3359
3465
  export function isMethodDispatchedSubmitPath(url) {
3360
3466
  return url === "/v1/a2a";
3361
3467
  }
3468
+ /** 「这台部署配了任何一把 service 凭证吗」—— fail-closed 写门族的真值源。**单一属主**:`handle()` 的
3469
+ * 凭证门与 #329 的 parked 赎回席读同一句(两处手抄迟早只改一处,而这条判据决定的是「无凭证部署上写口
3470
+ * 开不开」)。 */
3471
+ function hasServiceAuth(config) {
3472
+ return Boolean(config.authToken) || Object.keys(config.authTokens ?? {}).length > 0;
3473
+ }
3362
3474
  export function isBillableSubmitPath(url) {
3363
3475
  return (url === "/v1/side-query" || // [1469] one-shot brain call — runs the model, so it rides every billable-submit gate
3364
3476
  url === "/v1/tasks" ||
package/dist/main.js CHANGED
@@ -248,10 +248,15 @@ async function main() {
248
248
  const deploymentHooks = toolTracer ? composeHooks(createPermissionDeniedMeter(metrics), toolTracer) : createPermissionDeniedMeter(metrics);
249
249
  // design/158 A10:workflow 编排面搬到 src/boot/workflow-orchestration.ts(逐字;构造次序契约见该文件头注)。
250
250
  const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, workflowRecoverOpts, workflowAgentRegistry, subagentSteerRegistry, } = createWorkflowOrchestration({ config, logger, metrics, localRoot, backend, getRunStore: () => runStore });
251
+ // #329:PARKED 行迟到决议的赎回席 —— **晚绑** holder(席本体是 `createHttpServer` 的产物,而协调器段
252
+ // 按其自身的次序契约必须排在它之前;同款先例 = 下方 reaper 的 `runDenySweep`)。接线在 server 造出来
253
+ // 之后一行,那里同时是**直连门**的 fail-closed 判定点(理由写在赋值处)。
254
+ let parkedAskRedeem;
251
255
  // design/158 A10:活体协调器 + SendUserFile 工具面搬到 src/boot/coordinators.ts(逐字;durableEnabled 的
252
256
  // 「必须早于 runnerDeps 求值」次序契约见该文件头注)。
253
257
  const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({
254
258
  config, logger, backend, sendUserFileTaskEnvs, ruleConsent,
259
+ getParkedRedeem: () => parkedAskRedeem,
255
260
  // #295(F-1,[4512] 修向 (b')):卡批「不再询问」的 project root——语义单点见 task-cwd.ts
256
261
  // `cardRuleScopeRoot` 顶注(session 注册 cwd ?? in-process 单用户形 process.cwd();其余 undefined)。
257
262
  ruleScopeRootFor: (sessionId) => cardRuleScopeRoot(sessionId !== undefined ? perSessionCwd.get(sessionId) : undefined, config),
@@ -1261,6 +1266,17 @@ async function main() {
1261
1266
  // D-D SLA-timer: wire the server's deny-sweep into the reaper holder declared above (the reaper is defined
1262
1267
  // before the server, so it calls through this late-bound reference).
1263
1268
  runDenySweep = server.denyExpiredApprovals;
1269
+ // #329:PARKED 迟到决议的赎回席接线(晚绑 holder,见其声明处)。
1270
+ // 🔴 **直连门部署上刻意不接**(fail-closed,#157 无静默 fail-open):`directDoorActive` 的形下
1271
+ // `/v1/approvals/:sessionId/decide` 要求每条决议携密码学信封(签名 principal JWT + cnf.bnd + HMAC over
1272
+ // 决议内容),而 live 回决腿 `POST /v1/tool-approvals/:id/respond` 的门只有 principal —— 把赎回席接上去
1273
+ // 等于给这类部署开一条**免信封**的 gate 决议口(信封正是为「BFF 被攻陷也不能替人批」而立的)。缺席 ⇒
1274
+ // 那条臂逐字回落修前 404,壳按既有 durable gate 流程走(行的 410 体已带 sessionId 指路)。
1275
+ // 收紧方向、不改任何已有腿:非直连门部署(绝大多数)照常受益。
1276
+ if (!config.directDoorActive)
1277
+ parkedAskRedeem = server.redeemParkedAsk;
1278
+ else
1279
+ logger.info("parked_late_decision_disabled", { reason: "direct_door_requires_signed_decision_envelope" });
1264
1280
  // #261:fleet 总线存活对账腿。⚠️ 位置即契约 —— 必须在 `createHttpServer` **之后**:活体谓词
1265
1281
  // (`inflightRuns` ∪ `steerableRuns` 的探针)是 server 造出来的,而 reaper 段按其自身的位置契约必须在
1266
1282
  // server 之前起,所以本腿独立起表(60s 档,unref;判据/五条界全在 fleet-reconciler.ts)。
@@ -1,3 +1,4 @@
1
+ import type { IncomingMessage } from "node:http";
1
2
  import { type AskRequest, type AskOutcome } from "@sema-agent/core";
2
3
  import type { CardRulePersisted, RuleConsentLane } from "./rules-consent.js";
3
4
  import { type ApprovalRequestFrame, type ApprovalRevokeFrame, type RuleEvidenceProjection } from "./approval-card.js";
@@ -12,6 +13,39 @@ export type ApprovalGateKind = (typeof APPROVAL_GATE_KINDS)[number];
12
13
  export declare function isApprovalGateKind(k: string | undefined): k is ApprovalGateKind;
13
14
  /** 两方言同形的 SQL IN 片段(值为闭集常量字面,无注入面)。 */
14
15
  export declare const APPROVAL_GATE_KINDS_SQL_IN: string;
16
+ /**
17
+ * #329 §三 PARKED 臂:把一条已 park 的 ask 的决议交给**既有赎回腿**所需的全部材料。
18
+ *
19
+ * 🔴 契约锚([4872] 锚①):本席是「同腿新调用方,零新终局语义」—— 决议必须走 checkpoint 的 decide
20
+ * CAS(单赢者),席的实现**绝不**在 ask 行上另开第二个终局写点。gate 三坐标是 `bindBatch` 从
21
+ * checkpoint 抄回行上的那一份,server 永不重算(绑定校验归 core 的 resume 侧,与 `/decide` 腿同姿势)。
22
+ */
23
+ export interface ParkedAskRedeemRequest {
24
+ /** 店内主键(日志/回执锚;赎回判定用的是下面三坐标)。 */
25
+ askId: string;
26
+ /** PARKED 坐标三件(`AskRow.gateToken/gateBoundCallId/gateBoundInputHash`)。 */
27
+ gateToken: string;
28
+ gateBoundCallId: string | null;
29
+ gateBoundInputHash: string | null;
30
+ /** wire 三选一映行上的二值(`allow`/`allow_session` ⇒ `approve`)。 */
31
+ decision: "approve" | "deny";
32
+ /** #229 同源的人写理由(deny 时 core 把它交给模型;allow 只进审计面)。 */
33
+ note?: string;
34
+ /** [1458] ctrl+g 编辑放行的实参整体替换(approve 才有意义)。 */
35
+ updatedInput?: unknown;
36
+ /** 发起本次 respond 的**已验证**调用方(live 腿 `gatedPrincipal` 的那一把)。席在赎回之前拿它与
37
+ * **checkpoint 当下的 scope** 对一次(codex R1-[high] 一:收编改写身份之后,任何缓存的属主判据都
38
+ * 过期;门必须贴在当下的持久事实上)。 */
39
+ principal?: string;
40
+ /** 发起本次 respond 的真实 HTTP 请求。赎回腿据它做准入解析与舰队 scope(与 `/decide` 腿同姿势);
41
+ * 缺席 ⇒ 那条腿按「无请求」形处置(它自己的成文语义,本席不替它决定)。 */
42
+ httpReq?: IncomingMessage;
43
+ }
44
+ /** #329:赎回席(装配层注入;缺席 ⇒ PARKED 臂逐字回落修前 404)。回的是赎回腿的原始 HTTP 结果面。 */
45
+ export type ParkedAskRedeem = (req: ParkedAskRedeemRequest) => Promise<{
46
+ status: number;
47
+ body: Record<string, unknown>;
48
+ }>;
15
49
  /** A live approval frame delivered to whoever tails this run's stream. `type` IS the SSE event name (named-event
16
50
  * convention, same as question). The shell renders `tool_approval` as the CC three-choice card and dismisses on
17
51
  * `tool_approval_complete`. */
@@ -182,6 +216,14 @@ export interface ToolApprovalFrame {
182
216
  * `argsOmitted: true`) when over the byte cap or unserializable. */
183
217
  args?: unknown;
184
218
  argsOmitted?: boolean;
219
+ /** #329 随批小件([4845]-5 视觉真空的 server 半场;**ADDITIVE**,`"tool_approval_complete"` only,
220
+ * 真才带)——`outcome:"expired"` 一词三义(park / 当场 deny / 无设施 deny,见 outcome 注)里 **park
221
+ * 那一义的显式判别位**:在场 ⇔ **这条 ask 本身**按 park 路由收尾且墓碑已落(⇔ 同一把 `approvalId`
222
+ * 仍可打 `respond` 走迟到受理兑现,见 wire 契约迟到受理段)。判别与墓碑读**同一次判定**(A-058
223
+ * F1/F3:首版读政策口〔unattendedPolicy+askStore 在场〕,对批内被连坐 VOID 的兄弟、取消/断连、
224
+ * D5 fail-open 等非 park 终局全撒谎)。壳据此把「卡失效」改渲「已转后台候批」。缺席**禁**读作
225
+ * 「真 deny」:无店部署 / deny 政策 / VOID 兄弟都发不出这个键,缺席只是「无 park 证据」。 */
226
+ parked?: true;
185
227
  /**
186
228
  * "tool_approval_complete" only: how the ask settled. `allowed`/`denied` = a human decision.
187
229
  *
@@ -491,6 +533,15 @@ export declare class ToolApprovalCoordinator {
491
533
  * core 的 global 缺省(= 现行为,恒不更宽;也绝不错铸一个坐标系不对的 root)。装配见 main.ts:
492
534
  * registry cwd(host lane 显式注册的 launch dir)?? in-process 单用户形的 `process.cwd()`。 */
493
535
  private readonly ruleScopeRootFor;
536
+ /** #329:park 路由墓碑表(键 = wire `approvalId`,插入序 = 逐出序)。语义与有界理由见
537
+ * {@link ParkTombstone} 顶注。 */
538
+ private readonly parkTombstones;
539
+ private readonly parkTombstoneMax;
540
+ private readonly parkTombstoneTtlMs;
541
+ /** #329:PARKED 臂的赎回席**取值口**(晚绑 —— 赎回腿要 checkpoint/bg 店与裸 Agent 工具,它们在协调器
542
+ * 构造之后才装配;同款 holder 先例 = main.ts 的 `getRunDenySweep`)。取到 undefined ⇒ 该臂逐字回落
543
+ * 修前 404,partial 部署安全。 */
544
+ private readonly parkedRedeem;
494
545
  constructor(opts?: {
495
546
  ttlMs?: number;
496
547
  askStore?: ApprovalAskStore;
@@ -505,6 +556,12 @@ export declare class ToolApprovalCoordinator {
505
556
  unattendedPolicy?: UnattendedApprovalPolicy;
506
557
  /** #295:卡批规则的 project root 解析器(见 {@link ToolApprovalCoordinator.ruleScopeRootFor})。 */
507
558
  ruleScopeRootFor?: (sessionId: string | undefined) => string | undefined;
559
+ /** #329:PARKED 臂赎回席的**晚绑取值口**(见 {@link ToolApprovalCoordinator.parkedRedeem})。 */
560
+ parkedRedeem?: () => ParkedAskRedeem | undefined;
561
+ /** #329:墓碑表两道界(缺省见 {@link DEFAULT_PARK_TOMBSTONE_MAX}/{@link DEFAULT_PARK_TOMBSTONE_TTL_MS})。
562
+ * 不是部署旋钮(不接 env),可注入只为让「有界」这件事被判据观测得到。 */
563
+ parkTombstoneMax?: number;
564
+ parkTombstoneTtlMs?: number;
508
565
  });
509
566
  /**
510
567
  * #280 R-13 C —— 「**无人可答**」这一类终局的**唯一**成形口(park 路由 vs deny 政策)。
@@ -673,6 +730,12 @@ export declare class ToolApprovalCoordinator {
673
730
  * 由各自的闭包读同一个口,deny 部署上一起是 deny)。赢家自己此刻已 settle 过、已从集合里摘除,
674
731
  * 故对它重复调用天然无操作。 */
675
732
  private settleSameAskIdParkRoute;
733
+ /** #329:落一条 park 墓碑(容量帽按**插入序**逐出最旧,与 `allowAllSessions` 同款有界纪律)。
734
+ * 调用点唯一 = `settle` 里的 park 路由支(见那处注);真终局绝不调它。 */
735
+ private recordParkTombstone;
736
+ /** #329:取墓碑(**惰性清扫**:过期条目读到即删,不另起定时器 —— 这张表只在迟到回决那一刻被读)。
737
+ * 属主门在调用方(与 live 腿同一句判据:`owner === null` 或逐字相等,否则按不存在处置)。 */
738
+ private lookupParkTombstone;
676
739
  /**
677
740
  * #151 车4 §12-E(F29/F30 裁定形):外部回决(车4 端点或本类 respond 腿)**赢下持久 CAS 之后**,把同
678
741
  * askId 下全部本地悬挂条目按**真实决议**结算——端点已是唯一权威(CAS 已落),本地只是同步终局;查无
@@ -767,13 +830,61 @@ export declare class ToolApprovalCoordinator {
767
830
  * 完成(D1 现行为不变;现存量测试对它的同步断言/`void` 调用零改动)。在场时才真的变成 Promise(D2 的
768
831
  * 「settle 前先赢一把 decideAsk CAS」离不开 await,同步函数做不到)。唯一生产调用点(`routes/runs.ts`)
769
832
  * 统一 `await`——`await` 对非 Promise 值是恒等操作,两条路径对它透明。 */
770
- respond(id: string, principal: string | undefined, body: unknown): {
833
+ respond(id: string, principal: string | undefined, body: unknown,
834
+ /** #329:发起本次回决的真实 HTTP 请求。**只**沿 PARKED 臂透传给赎回席(那条腿要它做准入解析与
835
+ * 舰队 scope,与 `/decide` 腿同姿势);live 命中路径一个字节都不看它。缺席 = 非 HTTP 调用点
836
+ * (测试/内部),赎回席按「无请求」形处置。 */
837
+ httpReq?: IncomingMessage): {
771
838
  status: number;
772
839
  body: unknown;
773
840
  } | Promise<{
774
841
  status: number;
775
842
  body: unknown;
776
843
  }>;
844
+ /** #329:`tool_approval.not_pending` 的**唯一**成形口。文案逐字不变(存量壳/SDK 按它认这条 404),
845
+ * 只 additive 加判别位;`cause` 缺席 = 这台部署没有行可判(D1 无店)或行形不可信(见各调用点)。 */
846
+ private notPending;
847
+ /**
848
+ * #329 —— live 未命中之后的**状态感知分派**(设计 §三的那张表)。
849
+ *
850
+ * 门两道,都在读行之前:①持久店在场(协议上场才有行可读;缺席 ⇒ D1 体逐字不变);②本副本留过
851
+ * park 墓碑且属主门过(墓碑是 wire id ↔ 店内 askId 的唯一桥,见 {@link ParkTombstone})。任一不过 ⇒
852
+ * 与修前逐字同形的 404(店在场时带 `unknown_or_other_replica` 判别位 —— 那正是「换副本/换端点重试才
853
+ * 有意义」的那一因)。
854
+ */
855
+ private respondLate;
856
+ /** {@link respondLate} 的读行 + 六态穷举分派(闭集:新增 `AskState` 而不在此表态 = **编译错**)。 */
857
+ private dispatchLateByRow;
858
+ /** #329:迟到腿上的 `persistRule` 兑付面 —— 本地条目(规则车道素材的载体)早已随 park 路由散场,
859
+ * 没有任何可对的候选表 ⇒ 如实拒(裁决本身照常受理,与 {@link persistRuleAfterDecision} 的
860
+ * 「失败不翻转裁决」同判)。用既有词 `rule_lane_unavailable`,不为这一支新造词。 */
861
+ private lateRuleRefusal;
862
+ /**
863
+ * #329 G2 —— DECIDED 行的**幂等回放**([4872] 锚③)。
864
+ *
865
+ * 同向迟到答与**反向**迟到答同判:都是 200 回放**首决**。反向不是 409、不是报错、更不改判 —— 那是
866
+ * 一个 CAS 败者的诚实答:决议早已由首决落定,这次请求只是晚到,响应体把首决交回去,消费端自判向。
867
+ *
868
+ * 权威性四合取与 durable 回决腿的 `decidedRowIsAuthoritative` **同判据**(那处顶注是唯一真源):
869
+ * 决议二值 ∧ 非 provisional ∧ 有 `decidedAtMs` ∧ 态确为 DECIDED。不合形 ⇒ 退回修前 404(绝不把一条
870
+ * 坏行包装成「人的首决」交出去);此处比那条腿更保守地不铸 500,理由是本腿的客户端是壳上那张卡,
871
+ * 它对 404 有成文处置(消卡),对 500 没有。
872
+ */
873
+ private replayDecidedAsk;
874
+ /**
875
+ * #329 G1 —— PARKED 行的迟到决议:**桥接既有赎回腿**([4872] 锚①「同腿新调用方,零新终局语义」)。
876
+ *
877
+ * 为什么桥接而不是在这里 revive-直续:PARKED 的语义是「候下一 run 重呈或 operator 决议」,而
878
+ * operator 决议早有成文入口(`/decide` 那条链:checkpoint decide CAS → resume)。桥接 = 那条链多一个
879
+ * 调用方,单赢者仍是它的 CAS;直续则要发明第三种赎回形,与 core 的 park/revive 契约重叠。
880
+ *
881
+ * 200 是**受理**语义(#316 先例):resume 的驱动是 core 内的 fire-and-forget,结果面走 run/events。
882
+ * 非 2xx 原样透出(gate 被别人收走 ⇒ 409 等),绝不粉饰成受理。
883
+ */
884
+ private redeemParkedAsk;
885
+ /** #329:回执/单飞胜者的**回放形** —— 与首次受理同键集,多一位 `idempotent`,`decision` 恒是**受理时**
886
+ * 那一向(反向重试不改判,与 DECIDED 臂同一条纪律)。 */
887
+ private replayRedeemReceipt;
777
888
  /**
778
889
  * #154 车二:回决携规则确认 ⇒ prepare→confirm→redeem(实现在 `rules-consent.ts`,本方法只做门与回显)。
779
890
  *
@@ -795,5 +906,7 @@ export declare class ToolApprovalCoordinator {
795
906
  /** Test/observability hooks. */
796
907
  pendingCount(): number;
797
908
  sessionAllowedCount(): number;
909
+ /** #329:当前在场的 park 墓碑数(有界性的可观测面 —— 判据据它证「帽是硬的、过期会被清」)。 */
910
+ parkTombstoneCount(): number;
798
911
  }
799
912
  //# sourceMappingURL=tool-approval.d.ts.map
@@ -88,6 +88,13 @@ const MAX_ALLOW_SESSIONS = 4096;
88
88
  * 这两个常量只服务「手搓协调器」的测试与防御性缺省。 */
89
89
  const DEFAULT_ADMIT_MAX_PER_TASK = 32;
90
90
  const DEFAULT_ADMIT_MAX_PER_OWNER = 256;
91
+ /** #329:墓碑表的容量帽/条目 TTL **代码默认**。不接 env —— 它不是部署旋钮,而是一张内部有界表的
92
+ * 尺寸(判据可观测性由构造 opts 提供)。1h 的取值理由:比任何一条 run 的赎回窗都长(壳看到卡到人
93
+ * 回来按键的真实间隔),又短到不会把一台长命 worker 的内存钉在过期指路条上。 */
94
+ const DEFAULT_PARK_TOMBSTONE_MAX = 2000;
95
+ const DEFAULT_PARK_TOMBSTONE_TTL_MS = 60 * 60_000;
96
+ /** #329 G3:PARKING 窄窗(单 UPDATE 到 gate 铸完,毫秒级)的重试提示。客户端一次重试即落 PARKED 臂。 */
97
+ const PARKING_RETRY_AFTER_MS = 250;
91
98
  /** #151 车5(X-1 状态感知分派):CAS 干净地输之后重读持久态的**有界**退避表(ms,一次退避一格)。
92
99
  * 长度 = 重试次数上限(6 次,累计 ≈ 3.1s)。为什么必须有界:重读是为了「不挂死」,一个无界重试循环
93
100
  * 只是把挂死从 promise 挪到 store —— 用尽仍读不出 ⇒ D5 fail-open 退回纯进程内语义。期间任何一步发现
@@ -417,6 +424,15 @@ export class ToolApprovalCoordinator {
417
424
  * core 的 global 缺省(= 现行为,恒不更宽;也绝不错铸一个坐标系不对的 root)。装配见 main.ts:
418
425
  * registry cwd(host lane 显式注册的 launch dir)?? in-process 单用户形的 `process.cwd()`。 */
419
426
  ruleScopeRootFor;
427
+ /** #329:park 路由墓碑表(键 = wire `approvalId`,插入序 = 逐出序)。语义与有界理由见
428
+ * {@link ParkTombstone} 顶注。 */
429
+ parkTombstones = new Map();
430
+ parkTombstoneMax;
431
+ parkTombstoneTtlMs;
432
+ /** #329:PARKED 臂的赎回席**取值口**(晚绑 —— 赎回腿要 checkpoint/bg 店与裸 Agent 工具,它们在协调器
433
+ * 构造之后才装配;同款 holder 先例 = main.ts 的 `getRunDenySweep`)。取到 undefined ⇒ 该臂逐字回落
434
+ * 修前 404,partial 部署安全。 */
435
+ parkedRedeem;
420
436
  constructor(opts) {
421
437
  this.ruleConsent = opts?.ruleConsent;
422
438
  this.ruleScopeRootFor = opts?.ruleScopeRootFor;
@@ -427,6 +443,9 @@ export class ToolApprovalCoordinator {
427
443
  this.admitMaxPerTask = opts?.admitMaxPerTask ?? DEFAULT_ADMIT_MAX_PER_TASK;
428
444
  this.admitMaxPerOwner = opts?.admitMaxPerOwner ?? DEFAULT_ADMIT_MAX_PER_OWNER;
429
445
  this.unattendedPolicy = opts?.unattendedPolicy ?? "park";
446
+ this.parkedRedeem = opts?.parkedRedeem;
447
+ this.parkTombstoneMax = opts?.parkTombstoneMax ?? DEFAULT_PARK_TOMBSTONE_MAX;
448
+ this.parkTombstoneTtlMs = opts?.parkTombstoneTtlMs ?? DEFAULT_PARK_TOMBSTONE_TTL_MS;
430
449
  }
431
450
  /**
432
451
  * #280 R-13 C —— 「**无人可答**」这一类终局的**唯一**成形口(park 路由 vs deny 政策)。
@@ -799,6 +818,28 @@ export class ToolApprovalCoordinator {
799
818
  for (const entry of [...entries])
800
819
  entry.settleParkRoute(); // 拷贝快照:结算会修改原集合
801
820
  }
821
+ /** #329:落一条 park 墓碑(容量帽按**插入序**逐出最旧,与 `allowAllSessions` 同款有界纪律)。
822
+ * 调用点唯一 = `settle` 里的 park 路由支(见那处注);真终局绝不调它。 */
823
+ recordParkTombstone(approvalId, tomb) {
824
+ if (this.parkTombstones.size >= this.parkTombstoneMax) {
825
+ const oldest = this.parkTombstones.keys().next().value;
826
+ if (oldest !== undefined)
827
+ this.parkTombstones.delete(oldest);
828
+ }
829
+ this.parkTombstones.set(approvalId, tomb);
830
+ }
831
+ /** #329:取墓碑(**惰性清扫**:过期条目读到即删,不另起定时器 —— 这张表只在迟到回决那一刻被读)。
832
+ * 属主门在调用方(与 live 腿同一句判据:`owner === null` 或逐字相等,否则按不存在处置)。 */
833
+ lookupParkTombstone(approvalId) {
834
+ const tomb = this.parkTombstones.get(approvalId);
835
+ if (tomb === undefined)
836
+ return undefined;
837
+ if (Date.now() - tomb.parkedAtMs > this.parkTombstoneTtlMs) {
838
+ this.parkTombstones.delete(approvalId);
839
+ return undefined;
840
+ }
841
+ return tomb;
842
+ }
802
843
  /**
803
844
  * #151 车4 §12-E(F29/F30 裁定形):外部回决(车4 端点或本类 respond 腿)**赢下持久 CAS 之后**,把同
804
845
  * askId 下全部本地悬挂条目按**真实决议**结算——端点已是唯一权威(CAS 已落),本地只是同步终局;查无
@@ -1423,6 +1464,8 @@ export class ToolApprovalCoordinator {
1423
1464
  // ⚠️ #280 R-13 A1/A2 起,**askStore 缺席的 D1 窗到期臂与店报错臂也置位它**(park 路由是三条窗臂
1424
1465
  // 共用的终局形),所以「缺席时恒 false」那句话已作废;三臂的差别只剩宿主自报(见各臂注)。
1425
1466
  let windowRouteUnavailable = false;
1467
+ // A-058 F1/F3:`parked` 帧键的**唯一**事实源——真落了墓碑(⇔ 迟到 decide 腿对这把 approvalId 可达)。
1468
+ let parkTombstoned = false;
1426
1469
  let timer;
1427
1470
  let resolveAllowed;
1428
1471
  const allowedP = new Promise((resolve) => { resolveAllowed = resolve; });
@@ -1489,6 +1532,21 @@ export class ToolApprovalCoordinator {
1489
1532
  for (const [sig, fn] of abortListeners)
1490
1533
  sig.removeEventListener("abort", fn);
1491
1534
  this.pending.delete(id);
1535
+ // 🔴 #329:live 条目按 **park 路由**收尾 ⇒ 留墓碑(wire approvalId → 店内坐标),否则壳稍后按 Yes
1536
+ // 时两条身份轴接不上、只能得到一句 404(设计 §一 的病灶)。判据取 `windowRouteUnavailable` 而不是
1537
+ // 各臂各记一个标志:三条 park 臂(窗到期赢 CAS / 向持久真源收敛读出 PARKING|PARKED / 单赢者
1538
+ // `settleParkRouteIfUnsettled`)**都**是「先置位、后 settle」,所以这里是它们唯一的公共咽喉。
1539
+ // `deny` 政策下不留:那台部署显式声明了「不积压 park」,core 拿到的是 deny(无 checkpoint、无处可赎),
1540
+ // 行随后由收敛器收成 DENIED/VOID —— 留墓碑只会把一次 404 换成一次误导性的 202。
1541
+ if (windowRouteUnavailable && askId !== undefined && batchId !== undefined && this.unattendedOutcome() !== false) {
1542
+ this.recordParkTombstone(id, { askId, batchId, owner: primary.owner, parkedAtMs: Date.now() });
1543
+ // A-058 F1/F3(7.40 合并窗重扫,opus 证伪 CONFIRMED):闭合帧的 `parked` 判别位与墓碑必须是
1544
+ // **同一次判定**。首版读的是政策口(unattendedPolicy+askStore 在场)——三项全是协调器级事实,
1545
+ // 与「这条 ask 本身走没走 park 路由」无关,于是批内被连坐 VOID 的兄弟(settleVoidedSiblings
1546
+ // 裸 expired,无墓碑、迟到答恒 404)、取消/断连/D5 fail-open 等非 park 终局也被打上
1547
+ // `parked:true` —— 壳渲「已转后台候批」而答案无处兑现,正是 [4845] 病灶的新皮。
1548
+ parkTombstoned = true;
1549
+ }
1492
1550
  // #151 车2(round3 兄弟撤卡索引 + round5 精化成 Set,顶注):把自己从同 askId 的集合里摘除——集合
1493
1551
  // 可能还有其他条目(批内兄弟、或同 askId 的重复本地注册),它们的存亡与自己的结算无关。
1494
1552
  if (askId !== undefined) {
@@ -2007,7 +2065,18 @@ export class ToolApprovalCoordinator {
2007
2065
  .get(c)
2008
2066
  // F1 修:闭合帧的门仍**只看旧帧**(`legacy`)—— 它按 `approvalId` 消卡,只有见过开卡帧的连接才有
2009
2067
  // 卡可消;拿新帧的成功去放行闭合帧会给只收到 `approval_request` 的连接发一个孤儿 close。
2010
- .then((delivered) => (delivered.legacy ? c.emit({ type: "tool_approval_complete", approvalId: wireApprovalId, outcome: lastOutcome }) : undefined))
2068
+ // #329 小件:本条 ask 真按 park 路由收尾(墓碑已落 迟到 decide 腿对这把 approvalId 可达)时,
2069
+ // 闭合帧带 `parked:true`——把 outcome:"expired" 三义中 park 那一义显式化(壳渲「已转后台候批」
2070
+ // 而非「卡失效」)。判别位与墓碑读**同一次判定**(`parkTombstoned`,settle 咽喉里置位)——A-058
2071
+ // F1/F3:政策口判别(unattendedPolicy+askStore 在场)对 VOID 兄弟/取消/D5 fail-open 臂撒谎。
2072
+ .then((delivered) => delivered.legacy
2073
+ ? c.emit({
2074
+ type: "tool_approval_complete",
2075
+ approvalId: wireApprovalId,
2076
+ outcome: lastOutcome,
2077
+ ...(parkTombstoned ? { parked: true } : {}),
2078
+ })
2079
+ : undefined)
2011
2080
  .catch(() => undefined);
2012
2081
  }
2013
2082
  // emit 全灭 = 卡从未送达任何人(≠人拒绝/TTL 走人)——同属「无活人可达」类 ⇒ "unavailable" 交 durable
@@ -2043,13 +2112,20 @@ export class ToolApprovalCoordinator {
2043
2112
  * 完成(D1 现行为不变;现存量测试对它的同步断言/`void` 调用零改动)。在场时才真的变成 Promise(D2 的
2044
2113
  * 「settle 前先赢一把 decideAsk CAS」离不开 await,同步函数做不到)。唯一生产调用点(`routes/runs.ts`)
2045
2114
  * 统一 `await`——`await` 对非 Promise 值是恒等操作,两条路径对它透明。 */
2046
- respond(id, principal, body) {
2115
+ respond(id, principal, body,
2116
+ /** #329:发起本次回决的真实 HTTP 请求。**只**沿 PARKED 臂透传给赎回席(那条腿要它做准入解析与
2117
+ * 舰队 scope,与 `/decide` 腿同姿势);live 命中路径一个字节都不看它。缺席 = 非 HTTP 调用点
2118
+ * (测试/内部),赎回席按「无请求」形处置。 */
2119
+ httpReq) {
2047
2120
  const parsed = parseToolApprovalResponse(body);
2048
2121
  if (!parsed.ok)
2049
2122
  return { status: 400, body: { error: parsed.error, errorCode: "request.body_shape" } };
2050
2123
  const entry = this.pending.get(id);
2051
2124
  if (!entry || (entry.owner !== null && entry.owner !== principal)) {
2052
- return { status: 404, body: { error: "no pending tool approval for this id (settled, expired, or not on this replica)", errorCode: "tool_approval.not_pending" } };
2125
+ // #329:live 未命中(或属主门不过)⇒ **不再盲 404**,按持久行的状态分派(设计 §三)
2126
+ // 属主门不过的那一支同样落这里:它在墓碑上会**再判一次同一句**,不过则与未知 id 同形同码
2127
+ // (无存在性谕示,与本方法头注的承诺一致)。
2128
+ return this.respondLate(id, principal, parsed, httpReq);
2053
2129
  }
2054
2130
  // #151 车2 D2(回决竞争者):askStore 缺席 ⇒ finishRespond 同步收尾,逐字不变(D1)。在场 ⇒ 先打一把
2055
2131
  // decideAsk CAS,赢了才 finishRespond;输(某个其他竞争者已经决定了这只 ask 的终局)⇒ 不 settle,照旧
@@ -2064,6 +2140,261 @@ export class ToolApprovalCoordinator {
2064
2140
  return settled;
2065
2141
  return this.persistRuleAfterDecision(settled, entry, parsed.persistRule, parsed.updatedInput !== undefined);
2066
2142
  }
2143
+ /** #329:`tool_approval.not_pending` 的**唯一**成形口。文案逐字不变(存量壳/SDK 按它认这条 404),
2144
+ * 只 additive 加判别位;`cause` 缺席 = 这台部署没有行可判(D1 无店)或行形不可信(见各调用点)。 */
2145
+ notPending(cause) {
2146
+ return {
2147
+ status: 404,
2148
+ body: {
2149
+ error: "no pending tool approval for this id (settled, expired, or not on this replica)",
2150
+ errorCode: "tool_approval.not_pending",
2151
+ ...(cause !== undefined ? { cause } : {}),
2152
+ },
2153
+ };
2154
+ }
2155
+ /**
2156
+ * #329 —— live 未命中之后的**状态感知分派**(设计 §三的那张表)。
2157
+ *
2158
+ * 门两道,都在读行之前:①持久店在场(协议上场才有行可读;缺席 ⇒ D1 体逐字不变);②本副本留过
2159
+ * park 墓碑且属主门过(墓碑是 wire id ↔ 店内 askId 的唯一桥,见 {@link ParkTombstone})。任一不过 ⇒
2160
+ * 与修前逐字同形的 404(店在场时带 `unknown_or_other_replica` 判别位 —— 那正是「换副本/换端点重试才
2161
+ * 有意义」的那一因)。
2162
+ */
2163
+ respondLate(id, principal, parsed, httpReq) {
2164
+ const store = this.askStore;
2165
+ if (!store)
2166
+ return this.notPending(); // D1:无行可判,编一个 cause 是撒谎
2167
+ const tomb = this.lookupParkTombstone(id);
2168
+ if (tomb === undefined || (tomb.owner !== null && tomb.owner !== principal)) {
2169
+ return this.notPending("unknown_or_other_replica");
2170
+ }
2171
+ return this.dispatchLateByRow(store, tomb, id, parsed, httpReq, principal);
2172
+ }
2173
+ /** {@link respondLate} 的读行 + 六态穷举分派(闭集:新增 `AskState` 而不在此表态 = **编译错**)。 */
2174
+ async dispatchLateByRow(store, tomb, id, parsed, httpReq, principal) {
2175
+ let row;
2176
+ try {
2177
+ row = await this.withStoreDeadline(store.getAsk(tomb.askId), "getAsk(respond-late)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
2178
+ }
2179
+ catch (err) {
2180
+ // D5 的 fail-open 只覆盖「不挡人的真实回决」那一面。这条腿反过来:读不出行 = **不知道**这只 ask
2181
+ // 落在哪一态,而三条受理臂(200/200/202)全都在对客户端做承诺。不知道 ⇒ 回修前那句 404,绝不把
2182
+ // 未知说成受理。
2183
+ this.noteStoreError(err, "getAsk(respond-late)");
2184
+ return this.notPending("unknown_or_other_replica");
2185
+ }
2186
+ if (row === null)
2187
+ return this.notPending("unknown_or_other_replica"); // 行被清过/从未落盘
2188
+ // 🔴 属主门第二道:**行上当下的** owner(codex R1-[high] 一,验真后修 —— `/v1/adoption` 会把
2189
+ // `approval_ask.owner` 改写到新身份,而墓碑里那份是 park 那一刻的快照)。缓存值只是预筛,权威是这一行。
2190
+ // 判据与 durable 回决腿逐字同源(`row0.owner === null || principal === row0.owner`),但**不带** operator
2191
+ // 越权臂:这张卡是终端用户自己的(本方法头注「no operator bypass」),迟到不改变归属。
2192
+ if (row.owner !== null && row.owner !== principal) {
2193
+ defaultLogger.warn("tool approval late decision rejected: the ask row changed owner since it parked", { askId: row.askId });
2194
+ return this.notPending("unknown_or_other_replica");
2195
+ }
2196
+ // 已受理过一次赎回 ⇒ 回放本副本那张回执(见 {@link ParkTombstone.redeemed})。位置在**属主复核之后**
2197
+ // (codex R2-[medium] 二,验真后修:回执体里带着 run/session 坐标,归属改写之后前属主不该再拿到它;
2198
+ // 「他自己早就见过」不构成继续发的理由 —— 门的语义是「按当下的持久事实判」,不留例外臂)。行此刻仍是
2199
+ // PARKED(赎回不改 ask 行),所以这一支必须排在下面 switch 之前,否则重试会去动一张已被消费的 checkpoint。
2200
+ if (tomb.redeemed !== undefined)
2201
+ return this.replayRedeemReceipt(id, tomb.askId, tomb.redeemed, parsed);
2202
+ switch (row.state) {
2203
+ case "DECIDED":
2204
+ return this.replayDecidedAsk(id, row, parsed);
2205
+ case "PARKED":
2206
+ return this.redeemParkedAsk(id, tomb, row, parsed, httpReq, principal);
2207
+ case "PARKING":
2208
+ // 极窄转移窗(`bindBatch` 的单事务:PARKING→PARKED + gate 落行,毫秒级)。**不预存决议** ——
2209
+ // 预存 = 在 gate CAS 之外开第二个写点,正是 #290 禁的双赢者形([4872] 锚②)。客户端一次重试
2210
+ // 就落 PARKED 臂;这里一个字节都不动行。
2211
+ return {
2212
+ status: 202,
2213
+ body: {
2214
+ approvalId: id,
2215
+ askId: row.askId,
2216
+ reason: "parking_in_progress",
2217
+ retryAfterMs: PARKING_RETRY_AFTER_MS,
2218
+ ...this.lateRuleRefusal(parsed),
2219
+ },
2220
+ };
2221
+ case "DENIED":
2222
+ return this.notPending("denied");
2223
+ case "VOID":
2224
+ return this.notPending("voided");
2225
+ case "STREAM_PENDING":
2226
+ // 墓碑在、行却仍活着:本副本这条注册按 park 路由收了尾而那次 `expireAsk` 其实没提交(店报错臂),
2227
+ // 或同 askId 的另一条注册还在别处等着。本副本已经没有闭包可以兑现这次决议 ⇒ 与「不在这台机器上」
2228
+ // 同码(壳换端点/重试才有意义),绝不在这里替别人打 CAS。
2229
+ return this.notPending("unknown_or_other_replica");
2230
+ default: {
2231
+ // 闭集穷举(#157 词表纪律):`AskState` 加员而不在上面表态,这一行**编译期**先红。
2232
+ const unhandled = row.state;
2233
+ defaultLogger.error("tool approval respond hit an unknown ask state", { askId: tomb.askId, state: unhandled });
2234
+ return this.notPending();
2235
+ }
2236
+ }
2237
+ }
2238
+ /** #329:迟到腿上的 `persistRule` 兑付面 —— 本地条目(规则车道素材的载体)早已随 park 路由散场,
2239
+ * 没有任何可对的候选表 ⇒ 如实拒(裁决本身照常受理,与 {@link persistRuleAfterDecision} 的
2240
+ * 「失败不翻转裁决」同判)。用既有词 `rule_lane_unavailable`,不为这一支新造词。 */
2241
+ lateRuleRefusal(parsed) {
2242
+ if (parsed.persistRule === undefined)
2243
+ return {};
2244
+ return { rulePersisted: false, ruleRefusal: "rule_lane_unavailable" };
2245
+ }
2246
+ /**
2247
+ * #329 G2 —— DECIDED 行的**幂等回放**([4872] 锚③)。
2248
+ *
2249
+ * 同向迟到答与**反向**迟到答同判:都是 200 回放**首决**。反向不是 409、不是报错、更不改判 —— 那是
2250
+ * 一个 CAS 败者的诚实答:决议早已由首决落定,这次请求只是晚到,响应体把首决交回去,消费端自判向。
2251
+ *
2252
+ * 权威性四合取与 durable 回决腿的 `decidedRowIsAuthoritative` **同判据**(那处顶注是唯一真源):
2253
+ * 决议二值 ∧ 非 provisional ∧ 有 `decidedAtMs` ∧ 态确为 DECIDED。不合形 ⇒ 退回修前 404(绝不把一条
2254
+ * 坏行包装成「人的首决」交出去);此处比那条腿更保守地不铸 500,理由是本腿的客户端是壳上那张卡,
2255
+ * 它对 404 有成文处置(消卡),对 500 没有。
2256
+ */
2257
+ replayDecidedAsk(id, row, parsed) {
2258
+ if ((row.decision !== "approve" && row.decision !== "deny") || row.provisional || row.decidedAtMs === null) {
2259
+ defaultLogger.error("tool approval late replay saw a DECIDED row that is not a usable human decision", {
2260
+ askId: row.askId,
2261
+ decision: row.decision,
2262
+ provisional: row.provisional,
2263
+ decidedAtMs: row.decidedAtMs,
2264
+ });
2265
+ return this.notPending();
2266
+ }
2267
+ // 见到一条权威 DECIDED 行 ⇒ **先把本副本活体窗同步到它**(durable 腿 `syncLiveFromDecidedRow` 的同一
2268
+ // 条纪律:响应分类不许决定本地是否结算)。同 askId 下的其余本地注册可能还挂着,`notifyExternalDecision`
2269
+ // 对已结算条目天然幂等。
2270
+ const settled = this.notifyExternalDecision(row.askId, row.decision === "approve");
2271
+ // A-054.15:行派生的 approve —— 行上没有 `updated_input` 列,交出去的裸 `true` 会让 core 落回原始实参。
2272
+ // 与全仓同形站点计同一个 tag,且只在真结算了本地闭包时记(记了没发生的事 = 遥测撒谎)。
2273
+ if (row.decision === "approve" && settled.settled > 0)
2274
+ recordRowDerivedApproveReplay("respond-late-replay");
2275
+ return {
2276
+ status: 200,
2277
+ body: {
2278
+ approvalId: id,
2279
+ askId: row.askId,
2280
+ idempotent: true,
2281
+ // 回显的是**首决**,不是这次请求带来的那个词。行上是二值(`allow_session` 落行时已折成
2282
+ // `approve`)⇒ 回放恒是 `allow`/`deny` 两词,不假装分得出当初点的是哪一格。
2283
+ decision: row.decision === "approve" ? "allow" : "deny",
2284
+ decidedAtMs: row.decidedAtMs,
2285
+ ...(row.decisionNote !== null ? { decisionNote: row.decisionNote } : {}),
2286
+ ...(parsed.value === "allow_session" ? { rememberApplied: false } : {}),
2287
+ ...this.lateRuleRefusal(parsed),
2288
+ },
2289
+ };
2290
+ }
2291
+ /**
2292
+ * #329 G1 —— PARKED 行的迟到决议:**桥接既有赎回腿**([4872] 锚①「同腿新调用方,零新终局语义」)。
2293
+ *
2294
+ * 为什么桥接而不是在这里 revive-直续:PARKED 的语义是「候下一 run 重呈或 operator 决议」,而
2295
+ * operator 决议早有成文入口(`/decide` 那条链:checkpoint decide CAS → resume)。桥接 = 那条链多一个
2296
+ * 调用方,单赢者仍是它的 CAS;直续则要发明第三种赎回形,与 core 的 park/revive 契约重叠。
2297
+ *
2298
+ * 200 是**受理**语义(#316 先例):resume 的驱动是 core 内的 fire-and-forget,结果面走 run/events。
2299
+ * 非 2xx 原样透出(gate 被别人收走 ⇒ 409 等),绝不粉饰成受理。
2300
+ */
2301
+ async redeemParkedAsk(id, tomb, row, parsed, httpReq, principal) {
2302
+ const redeem = this.parkedRedeem?.();
2303
+ // 席缺席 = 这台部署没接赎回腿(无 checkpoint/bg 店,或装配层显式不接)⇒ 逐字回落修前 404。
2304
+ if (redeem === undefined)
2305
+ return this.notPending();
2306
+ if (row.gateToken === null) {
2307
+ // PARKED 恒带 gate 三坐标(`bindBatch` 一次事务同写)。缺 = 行受损,没有坐标可赎 ⇒ 诚实 404 + 一行
2308
+ // error(fail-closed:绝不拿一个空 token 去打赎回腿的 CAS)。
2309
+ defaultLogger.error("tool approval parked row carries no gate token — nothing to redeem", { askId: row.askId });
2310
+ return this.notPending();
2311
+ }
2312
+ // 已受理 ⇒ 直接回放(与 {@link dispatchLateByRow} 那道同源;两处各守一个时点:那里守的是「上一次
2313
+ // 请求早已落定」,这里守的是「本次请求在读行期间被另一路落定」)。
2314
+ if (tomb.redeemed !== undefined)
2315
+ return this.replayRedeemReceipt(id, row.askId, tomb.redeemed, parsed);
2316
+ // 🔴 **单飞**(codex 对抗复审 R2-[medium] 三,验真后修):两路**重叠**的重试都会读到「无回执」并各自
2317
+ // 进赎回腿 —— 一路赢下 CAS,另一路拿 409/404。对「200 丢过一次、客户端并发重发」的真实客户端来说,
2318
+ // 那等于被告知失败而其实决议已被受理。所以按墓碑挂一枚在飞句柄:后到者不再打第二次赎回,而是**等**
2319
+ // 赢家并回放它的结果。非 2xx 时句柄随即清掉(下一次重试是真重试);2xx 落回执,由上面那道接。
2320
+ // ⚠️ 单飞不是第二个决定者:它只合并**同一把 approvalId 的重复请求**,任何终局仍由赎回腿的 CAS 定。
2321
+ if (tomb.inflight !== undefined) {
2322
+ const shared = await tomb.inflight;
2323
+ if (shared.status < 200 || shared.status >= 300)
2324
+ return shared;
2325
+ return this.replayRedeemReceipt(id, row.askId, { decision: shared.decision, body: shared.body }, parsed);
2326
+ }
2327
+ const decision = parsed.value === "deny" ? "deny" : "approve";
2328
+ const gateToken = row.gateToken;
2329
+ const flight = (async () => {
2330
+ try {
2331
+ const res = await redeem({
2332
+ askId: row.askId,
2333
+ gateToken,
2334
+ gateBoundCallId: row.gateBoundCallId,
2335
+ gateBoundInputHash: row.gateBoundInputHash,
2336
+ decision,
2337
+ ...(parsed.note !== undefined ? { note: parsed.note } : {}),
2338
+ ...(parsed.updatedInput !== undefined && parsed.value !== "deny" ? { updatedInput: parsed.updatedInput } : {}),
2339
+ ...(principal !== undefined ? { principal } : {}),
2340
+ ...(httpReq !== undefined ? { httpReq } : {}),
2341
+ });
2342
+ return { ...res, decision };
2343
+ }
2344
+ catch (err) {
2345
+ // 赎回腿自己抛 = 内部故障,不是「这只 ask 不在」。404 会让壳消掉一张其实仍可赎的卡,所以如实 500。
2346
+ // 码用**既有**的通用 `internal.error`(durable 回决腿的同类臂用的也是它),不为一条内部故障臂新造
2347
+ // wire 词表成员;归因走日志(部署方有,调用方没有)。**不 rethrow**:句柄要以 resolve 收场,
2348
+ // 等在它上面的那一路才不会拿到一个逃逸的 rejection。
2349
+ defaultLogger.error("tool approval parked redemption threw", { askId: row.askId, error: err instanceof Error ? err.message : String(err) });
2350
+ return { status: 500, body: { error: "the parked approval could not be redeemed right now — retry", errorCode: "internal.error" }, decision };
2351
+ }
2352
+ })();
2353
+ tomb.inflight = flight;
2354
+ let out;
2355
+ try {
2356
+ out = await flight;
2357
+ }
2358
+ finally {
2359
+ tomb.inflight = undefined; // 清与落回执之间无 await ⇒ 后到者要么看见句柄、要么看见回执,没有空窗
2360
+ }
2361
+ if (out.status < 200 || out.status >= 300)
2362
+ return { status: out.status, body: out.body };
2363
+ // 受理成功 ⇒ 留回执(**只在 2xx 之后**;失败绝不留,否则一次没生效的决议会被后来的重试当成已受理)。
2364
+ // 它接住的是「200 丢包后客户端重试」那一形,见 {@link ParkTombstone.redeemed}。
2365
+ tomb.redeemed = { decision, body: out.body };
2366
+ return {
2367
+ status: out.status,
2368
+ body: {
2369
+ ...out.body,
2370
+ approvalId: id,
2371
+ askId: row.askId,
2372
+ accepted: "parked_redeemed",
2373
+ // 🔴 `allow_session` 在这条腿上**不铸** grant:墓碑刻意不携安全类/治理档两位(#287 / A-054.20 的
2374
+ // 写侧配对臂读的是本地条目上的它们),没有那两位就无从判断这只 ask 有没有资格换一条常驻放行 ——
2375
+ // fail-closed 不铸,并如实回显,壳据它不渲「本会话全放行」。
2376
+ ...(parsed.value === "allow_session" ? { rememberApplied: false } : {}),
2377
+ ...this.lateRuleRefusal(parsed),
2378
+ },
2379
+ };
2380
+ }
2381
+ /** #329:回执/单飞胜者的**回放形** —— 与首次受理同键集,多一位 `idempotent`,`decision` 恒是**受理时**
2382
+ * 那一向(反向重试不改判,与 DECIDED 臂同一条纪律)。 */
2383
+ replayRedeemReceipt(id, askId, receipt, parsed) {
2384
+ return {
2385
+ status: 200,
2386
+ body: {
2387
+ ...receipt.body,
2388
+ approvalId: id,
2389
+ askId,
2390
+ accepted: "parked_redeemed",
2391
+ idempotent: true,
2392
+ decision: receipt.decision === "approve" ? "allow" : "deny",
2393
+ ...(parsed.value === "allow_session" ? { rememberApplied: false } : {}),
2394
+ ...this.lateRuleRefusal(parsed),
2395
+ },
2396
+ };
2397
+ }
2067
2398
  /**
2068
2399
  * #154 车二:回决携规则确认 ⇒ prepare→confirm→redeem(实现在 `rules-consent.ts`,本方法只做门与回显)。
2069
2400
  *
@@ -2159,7 +2490,7 @@ export class ToolApprovalCoordinator {
2159
2490
  noteLanded = true;
2160
2491
  if (!decided.ok) {
2161
2492
  // CAS 输——respond 绝不覆盖赢家(D2)。逐字复用现行 404 形(409/410 分化留给车4)。
2162
- return { status: 404, body: { error: "no pending tool approval for this id (settled, expired, or not on this replica)", errorCode: "tool_approval.not_pending" } };
2493
+ return this.notPending(); // 同一句 wire 文案的唯一成形口(#329 起收口;CAS 输者不带判别位——它不是「行的状态」问题)
2163
2494
  }
2164
2495
  }
2165
2496
  catch (err) {
@@ -2215,7 +2546,7 @@ export class ToolApprovalCoordinator {
2215
2546
  this.notifyExternalDecision(askId, wantAllowed, parsed.updatedInput);
2216
2547
  const converged = decidedWon && entry.settledOutcome === (wantAllowed ? "allowed" : "denied");
2217
2548
  if (!converged) {
2218
- return { status: 404, body: { error: "no pending tool approval for this id (settled, expired, or not on this replica)", errorCode: "tool_approval.not_pending" } };
2549
+ return this.notPending(); // 同一句 wire 文案的唯一成形口(#329 起收口;CAS 输者不带判别位——它不是「行的状态」问题)
2219
2550
  }
2220
2551
  // 一致 ⇒ 走与正常路径**同一段**收尾:`finishRespond` 对已结算 entry 调 `settle` 天然无操作(幂等),
2221
2552
  // session 记忆照记(grant 谈的是**将来**的 ask,与这次投递是否由我完成无关)。
@@ -2313,5 +2644,9 @@ export class ToolApprovalCoordinator {
2313
2644
  sessionAllowedCount() {
2314
2645
  return this.allowAllSessions.size;
2315
2646
  }
2647
+ /** #329:当前在场的 park 墓碑数(有界性的可观测面 —— 判据据它证「帽是硬的、过期会被清」)。 */
2648
+ parkTombstoneCount() {
2649
+ return this.parkTombstones.size;
2650
+ }
2316
2651
  }
2317
2652
  //# sourceMappingURL=tool-approval.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.39.0",
3
+ "version": "7.40.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",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^5.52.0",
57
+ "@sema-agent/core": "^5.53.0",
58
58
  "@sema-agent/registry-core": "^0.19.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",