@sema-agent/server 7.35.1 → 7.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.zh-CN.md +1 -1
  2. package/USAGE.md +10 -1
  3. package/dist/boot/config-center.js +14 -4
  4. package/dist/boot/org-memory.d.ts +21 -0
  5. package/dist/boot/org-memory.js +1 -1
  6. package/dist/boot/parked-revive-gate.d.ts +16 -1
  7. package/dist/boot/parked-revive-gate.js +33 -68
  8. package/dist/boot/runner-deps.d.ts +1 -1
  9. package/dist/boot/runner-deps.js +18 -3
  10. package/dist/boot/side-query-lane.d.ts +124 -0
  11. package/dist/boot/side-query-lane.js +158 -0
  12. package/dist/boot/webfetch-summarize-lane.d.ts +60 -0
  13. package/dist/boot/webfetch-summarize-lane.js +67 -0
  14. package/dist/brain.js +15 -1
  15. package/dist/config-types.d.ts +27 -2
  16. package/dist/config.js +13 -3
  17. package/dist/degenerate-instrument.d.ts +13 -1
  18. package/dist/degenerate-instrument.js +13 -1
  19. package/dist/http/active-run-conflict.js +16 -0
  20. package/dist/http/routes/a2a-serve.js +12 -2
  21. package/dist/http/routes/admin-drain.d.ts +17 -0
  22. package/dist/http/routes/admin-drain.js +7 -1
  23. package/dist/http/routes/approvals-assistant.js +3 -3
  24. package/dist/http/routes/capabilities.js +11 -2
  25. package/dist/http/routes/rules.js +7 -4
  26. package/dist/http/routes/runs.js +55 -8
  27. package/dist/http/routes/sessions-list.js +1 -1
  28. package/dist/http/routes/sessions.js +4 -4
  29. package/dist/http/routes/side-query.js +6 -1
  30. package/dist/http/server.d.ts +18 -5
  31. package/dist/http/server.js +5 -1
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.js +14 -0
  34. package/dist/main.js +17 -28
  35. package/dist/memory-posture.d.ts +14 -1
  36. package/dist/memory-posture.js +2 -0
  37. package/dist/observability/fail-open.d.ts +12 -0
  38. package/dist/observability/fail-open.js +12 -0
  39. package/dist/parked-decide.d.ts +3 -1
  40. package/dist/parked-decide.js +34 -11
  41. package/dist/rules-consent.d.ts +20 -0
  42. package/dist/rules-consent.js +21 -0
  43. package/dist/security.d.ts +22 -0
  44. package/dist/security.js +24 -0
  45. package/dist/shared-memory-scope-authorizer.d.ts +36 -3
  46. package/dist/shared-memory-scope-authorizer.js +19 -4
  47. package/dist/store-live-probe.d.ts +26 -0
  48. package/dist/store-live-probe.js +60 -0
  49. package/dist/task-cwd.d.ts +16 -3
  50. package/dist/task-cwd.js +16 -3
  51. package/dist/tool-approval.d.ts +61 -2
  52. package/dist/tool-approval.js +159 -17
  53. package/package.json +2 -2
@@ -47,6 +47,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
47
47
  import { uuidv7, MAX_RULE_TEXT_CHARS } from "@sema-agent/core";
48
48
  import { redactDeep, redactSecrets } from "./trace/redact.js";
49
49
  import { createLogger } from "./observability/logger.js";
50
+ import { recordFailOpen } from "./observability/fail-open.js";
50
51
  import { deriveAskId, deriveBatchId, MAX_DECISION_NOTE_CHARS } from "./approval-ask-machine.js";
51
52
  import { ApprovalCardEnvelopeSchema, MAX_RULE_SUGGESTIONS, buildApprovalCard, buildApprovalCardEnvelope, buildApprovalRequestFrame, buildRevokeFrame, readProbeCause, readRuleEvidence, } from "./approval-card.js";
52
53
  import { governanceAskMarksFor, runWithGovernanceAskScope } from "./governance-ask-marks.js";
@@ -320,6 +321,20 @@ function replayTerminalAskRow(row) {
320
321
  return "unavailable";
321
322
  return false; // DENIED | VOID(STREAM_PENDING 由调用点排除在外)
322
323
  }
324
+ /**
325
+ * 🔴 A-054.15(2026-08-19 合并重扫 confirmed;CLAUDE.md #157「P 类改不动的记 P-DEBT」)—— 一条**行派生**的
326
+ * `DECIDED=approve` 被交成裸 `true` 时的记债口。上面那条 JSDoc 早就写着「持久层不存 `updatedInput`,
327
+ * ctrl+g 编辑重放的字面丢失属已知、可接受的降级」——但那句只活在注释里,遥测里这条降级与「一切正常」
328
+ * 同形。它的方向是**错的一侧**:core 的 `d.updatedInput === undefined` 分支落回原始未改写实参执行 ⇒
329
+ * 人批的是改写后的命令、真跑的是原命令(执行面宽于人所批准),所以它不是可以静默的 F 类。
330
+ *
331
+ * **调用纪律**:只在真把 `true` 交出去的那一刻调(读到行不算——单赢者已落定时本腿并不 settle,记了就是
332
+ * 假阳性);deny 不调(没有编辑可丢)。四个同形站点一个不落,选择性登记比不登记更坏(遥测会说谎)。
333
+ * 终局收口 = 给 ask 行加 `updated_input` 列(店语义级,须本模块属主立件),届时本 tag 与本函数同批销。
334
+ */
335
+ function recordRowDerivedApproveReplay(where) {
336
+ recordFailOpen("server.hitl.row-derived-approve-drops-updated-input", where);
337
+ }
323
338
  /**
324
339
  * Coordinates the live tool-approval HITL for the singleton runner. Process-local + same-replica (the pending map is
325
340
  * in memory, like QuestionCoordinator): a respond that lands on another replica finds nothing → 404. Present (passed
@@ -692,7 +707,72 @@ export class ToolApprovalCoordinator {
692
707
  * pending 的 checkpoint —— 人再批一次就走。真正的收口 = 把窗/emit-全灭/取消三条 ambiguous-CAS catch
693
708
  * 统一到一次**持久**恢复流程(带 CAS 与终态回读),那是店语义级改动,须由本模块属主立设计件,施工车
694
709
  * 不私开(本批已试过整段复用 `convergeFromDurableState`,被自己的全量跑打回,理由见其顶注)。
710
+ *
711
+ * ⚠️ **第二条已登记的残余(A-054.15,2026-08-19 合并重扫 confirmed)**:本口读出的 `true` 交给 `settle`
712
+ * 时第三形参 `updatedInput` 恒缺席 —— 行上没有那一列,读不出「这次批准是否带过 ctrl+g 编辑」。上一条
713
+ * 残余的方向是 fail-closed(该跑的没跑),这一条**反过来**:core 的 `d.updatedInput === undefined` 分支
714
+ * 落回原始未改写实参执行 ⇒ 人批的是改写后的命令、真跑的是原命令 = 执行面**宽于**人所批准。因此它不能
715
+ * 只靠注释登记,已按 CLAUDE.md #157 记 `P-DEBT` 债({@link recordRowDerivedApproveReplay},四个同形站点
716
+ * 同一个 tag)。收口 = 给 ask 行加 `updated_input` 列(店语义级,须属主立件),届时债与 tag 同批销。
695
717
  */
718
+ /**
719
+ * 🔴 session grant 短路前的**持久出处复核**(A-054.20 的 codex R1-[high] 补丁;A-057.1 换判据)。
720
+ *
721
+ * 回 `true` = 「这只 ask 从来没有过持久出处」⇒ 一揽子放行可以短路;
722
+ * 回 `false` = 行在(任何状态),**或**读不出来(不知道 = 不放宽)⇒ 调用方必须往下走真出卡。
723
+ *
724
+ * 为什么只读不写:短路的全部价值就是「不铸卡、不落行、不占 pending」,做一次主键点读保住这个价值,
725
+ * 而 `ensureAsk` 会写行。为什么行不在就放行:确定性 askId 的行只可能由**这只 ask 自己**铸出来,
726
+ * 行不在 ⇒ 从来没有过一份能反驳本地判据的持久出处(这也是常见形:grant 命中的 ask 多半是头一次到)。
727
+ *
728
+ * ## 判据的单一原则:**短路不得产生与「没有 grant 时那条路」不同的终局**
729
+ *
730
+ * 没有 grant 时,同一条行在下面 `askBroadcast` 里都有确定去处:终局行(DECIDED/PARKED/DENIED/VOID)
731
+ * 走 `replayTerminalAskRow`(deny 回放 `false`、park 回放 `"unavailable"`);活着的 STREAM_PENDING 行走
732
+ * `ensureAsk` 的幂等命中(采信行上的卡与 deadline,等真决议)。短路一旦 return,这两条腿结构上都够不着
733
+ * —— 所以只要行在,短路就是在用一个**更宽**的答案顶替那条路。⇒ 判据 = `row === null`。
734
+ *
735
+ * 这条原则是分两次到位的,两次漏的都是「行 = 真源」的一部分:
736
+ * · **A-054.20**(codex R1-[high]):首版只查**本地** peek,行上标治理的那一半漏了 —— 本地标表是
737
+ * 进程内表,failover / 重启后幂等命中既有行时它恒空。
738
+ * · **A-057.1**(2026-08-19 三轴组复审,refuter 真运行复现三形):补丁把行读到手里,却只看
739
+ * `card.governanceForced` 一个位 —— 行处于 `DECIDED=deny`(人已明确拒过这只调用)时照样回 `true`,
740
+ * 一揽子放行把一次**已落盘的人类拒绝**在重入 / failover / deny-后到 三形里翻成放行(帧 0、行不变、
741
+ * 无 recordFailOpen ⇒ 遥测零痕迹)。
742
+ * · **codex 对抗复审**(A-057.1 同批,验真后采纳):只补终态判据仍漏「卡还在别处挂着」——
743
+ * STREAM_PENDING 行意味着已有一张卡在等人,凭 grant 抢跑会让行上的终局与真实执行长期矛盾
744
+ * (那张卡随后可被人/另一副本决成相反答案,或 TTL 到点被收敛成幻影 gate)。
745
+ * 三次都不是各自独立的 bug,是同一句话没说全,故最终判据收成一条而不是三个分支。
746
+ *
747
+ * 零额外 IO(行已经在手里),且 fail-closed **by construction**:将来 `AskState` 加词也不需要动这里。
748
+ *
749
+ * 🔴 **已登记的残余(A-057.60,PARTIAL,本批不修)**:这一步是一次**非原子的 check-then-act** ——
750
+ * 另一副本正在为同一确定性 askId 落行、而本次点读抢在其提交前完成时,本副本仍会短路。真解 = 把出处
751
+ * 判定与 grant 消费合并成**同一次**原子存储操作(带条件 upsert / CAS),属店语义级改动,须本模块属主
752
+ * 立件(与上面 R4-F1/A-054.15 两条同源残余同一处置)。「行不在 ⇒ 不短路」不是修法:grant 命中的 ask
753
+ * 多半头一次到,那等于把一揽子放行整只废掉。窗的上界由本判据定死:行**一提交**窗即关。
754
+ */
755
+ async sessionGrantUncontestedByRow(origin, req) {
756
+ if (!this.askStore)
757
+ return true; // 无持久面 ⇒ 本地 peek 就是全部真相(零行为变化)
758
+ const sourceTaskId = req.sourceTaskId ?? origin.taskId;
759
+ const askId = deriveAskId(sourceTaskId, origin.taskId, req.toolCallId, origin.legKey, req.delegation?.parentToolCallId);
760
+ try {
761
+ const row = await this.withStoreDeadline(this.askStore.getAsk(askId), "getAsk(session-grant-provenance)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
762
+ // 🔴 A-057.1 + codex 对抗复审(同函数,验真后采纳):**有行即不短路**。
763
+ // 判据不再按状态分支,理由见顶注:短路不得产生与「没有 grant 时那条路」不同的终局,而**任何**
764
+ // 状态的行在无 grant 时都有确定去处 —— 终局行走 `replayTerminalAskRow`(deny 回 false、park 回
765
+ // "unavailable"),活着的 STREAM_PENDING 行走 ensureAsk 的幂等命中(采信行上的卡、等真决议)。
766
+ // 早先那版按 `governanceForced` 一个位判,漏的是「人已落盘的 deny」;只补终态判据仍漏「卡还在
767
+ // 别处挂着」——凭 grant 抢跑会让行上的终局与真实执行长期矛盾(那张卡随后可被人决成相反答案,
768
+ // 或 TTL 到点被收敛成幻影 gate)。两处漏的是同一件事:**行是真源**。
769
+ return row === null;
770
+ }
771
+ catch (err) {
772
+ this.noteStoreError(err, "getAsk(session-grant-provenance)");
773
+ return false; // 不知道 ⇒ 不放宽(方向收紧:人会多看到一张卡,而不是被静默代答)
774
+ }
775
+ }
696
776
  async readDecidedForRecovery(askId) {
697
777
  if (!this.askStore)
698
778
  return undefined;
@@ -897,23 +977,61 @@ export class ToolApprovalCoordinator {
897
977
  * 送达某个客户端之后发生(respond 需要客户端手上有这个 id),这是 outcome==="allowed"/"denied" 与
898
978
  * "expired"(TTL/abort/run-exit-cleanup/emit 广播全灭四类共用)的天然分野。 */
899
979
  async askBroadcast(origin, ctxs, req, signal) {
980
+ // 🔴 **这一条不过政策口,是刻意的**(A-054.17 同批成文):`signal` 是 core 交给**这一只 ask** 的信号,
981
+ // 它 aborted = 这次工具调用本身正在被拆掉,不是「没人可答」。core 侧同判据排在 onAsk 之前
982
+ // (`resolveAsk` 先判 signal 再调 onAsk)与之后(拿到 `"unavailable"` 也照样覆盖成 deny)各有一道,
983
+ // 所以这里回 park 路由既不可能被 core 采纳、也无 checkpoint 可落 —— 走政策口只会造出一条说谎的遥测。
900
984
  if (signal?.aborted)
901
985
  return false;
902
- // 连接级存活过滤:单连接(ask() 的 ALS 语义,今日 100% 生产流量)沿用「这条连接已经死了 ⇒ false」的
903
- // 原判(entry 时刻检查,零行为变化)。多活集合(boundAsk 广播)下,entry 时已死的目标不计入广播——
904
- // 若全体目标 entry 时已死,同样直接 false(单连接场景的自然推广:数组退化成 1 个已死 ctx 时结果
905
- // 相同)
986
+ // 连接级存活过滤:entry 时刻已死的投递目标不计入广播。
987
+ // 🔴 A-054.17(2026-08-19 合并重扫 partial,存活断言):**全体目标 entry 时已死 ⇒ 走
988
+ // {@link unattendedOutcome} 这一个口**,不再裸 `false`。它与 {@link boundAsk} 的「查无活流」
989
+ // (`live.length === 0`,臂 (e) 的入口形)是同一件事实的两个时刻——那一条是 broker 集合已空,这一条是
990
+ // 集合里躺着的连接自己已 abort;此前两者一个走政策口、一个裸 deny,于是同一句「卡送不到任何人」的
991
+ // 终局取决于 core 收卷与子代 ask 到达谁先跑到。可达形:session-scoped bg / 委派子代经宿主 `boundAsk`
992
+ // 发 ask(core 对这类子代刻意不挂宿主 abort 联动,子代自己的信号是活的 ⇒ 上面那条 signal 门不触发),
993
+ // 落在「宿主 ctx 已 abort、`runWithContext` 的 finally 尚未摘除」的窗口里。红先钉 =
994
+ // `test/unattended-approval-policy.test.ts` 的 A-054.17 格。
906
995
  const aliveCtxs = ctxs.filter((c) => !c.abortSignal?.aborted);
907
996
  if (aliveCtxs.length === 0)
908
- return false;
997
+ return this.unattendedOutcome();
909
998
  const primary = aliveCtxs[0];
999
+ // [2942]/[2943]:治理来源标的**一次** peek(非消费式)。此前它只在下面的帧字面量里就地读 ——
1000
+ // #204 件7 起规则车道也要它(治理档的 ask 不进车道),两处读同一个值必须是**同一次** peek:
1001
+ // 分开读会让「帧说这是治理门 / 车道说不是」这种自相矛盾的卡在理论上存在。
1002
+ // 🔴 A-054.20 把这次 peek **提到 session-grant 短路之前**(此前它排在短路之后,结构上够不着)——
1003
+ // 消费点从两处变三处,但仍是**同一次** peek,上面那条不变式不变。
1004
+ const governanceForced = (this.governanceAskMarks ?? governanceAskMarksFor(this.streamKey(primary.owner, primary.sessionId, origin.taskId))).isMarked(req.toolCallId);
910
1005
  // Session allow-all (CC "Yes, allow all edits during this session"): keyed [owner, sessionId, category] — the
911
1006
  // asking tool's OWN capability category must have been granted in THIS owner's session (修2; module header).
912
1007
  // 多活集合下 owner/sessionId 对全体 ctxs 恒同(streamKey 分组保证),primary 取值代表整个集合安全。
913
1008
  // #287([4434]② P0):`requiresRealApproval` 的成文语义=「必须真人逐次批,一揽子放行不得覆盖」——
914
1009
  // 这条短路正是它要禁的 blanket-allow,安全类 ask 无视既有 grant、恒往下走真出卡(core 侧持久规则
915
1010
  // 半场已源头关死:ruleSuggestionsOf 对该位返 {};流内这半在此)。写侧配对臂见 finishRespond。
916
- if (req.requiresRealApproval !== true && primary.sessionId && this.allowAllSessions.has(sessionAllowKey(primary.owner, primary.sessionId, sessionAllowCategory(req.toolName)))) {
1011
+ // 🔴 A-054.20(2026-08-19 合并重扫 confirmed,真运行复现):**治理档的 ask 同样无视既有 grant**。
1012
+ // 本仓自己的教条早就成文在 `buildRuleLaneMaterial`(「一次『不再询问』= 静默拆掉 operator 的档位,
1013
+ // 且拆完之后遥测里连 ask 都不再出现」)与回决侧的 `rule_governance_forced` 第二道门;session grant
1014
+ // 是同形、只是更短命的一揽子放行,此前两侧都没门 —— 短路排在出卡/落行/发帧之前,于是同一 (owner,
1015
+ // session) 同能力桶的后续治理 ask 直接 `return true`,连一条 ask 痕迹都不留。wire 契约面
1016
+ // (docs/ASSISTANT-WIRE-CONTRACT.md 的 governanceForced 段)逐字承诺「客户端表态掀不掉」。
1017
+ // 🔴 判据 = 本地 peek **∪ 持久行上的出处**(codex 对抗复审 R1-[high],验真后修)。
1018
+ // 本注上一版写的是「本地 peek 说是治理门就够了,行说是的那一半由下面的车道门 fail-closed 兜住」——
1019
+ // **那句话是错的**,而且错在最坏的地方:短路一旦 return,下面那道车道门根本不会执行。本文件自己
1020
+ // 早就成文「本地表是**进程内**表,failover / 重启后幂等命中既有行时它**恒空**,而行上的卡里存着
1021
+ // 落库那一刻的判定」(见 ensureAsk 段的 F2 家族三条)。于是缺口是:一个副本上人点过一次同能力桶的
1022
+ // 普通 allow_session ⇒ 本进程有 grant;随后一只**行上已标治理**的 ask 在本副本重入(本地标表空)
1023
+ // ⇒ 旧形按本地 peek 判「非治理」直接 return true —— 运维治理门被掀掉,且不出卡、不落行、无痕迹。
1024
+ // 处置照本文件既定的**放宽动作 fail-closed 合成**纪律(与规则车道逐字同形:任一侧说治理门就不放宽):
1025
+ // · 无 `askStore` ⇒ 不存在能反驳本地 peek 的行,本地判据即全部真相,短路照旧(零行为变化);
1026
+ // · 有 `askStore` ⇒ 先做一次**确定性 askId 的点读**:行不在(常见形:这只 ask 头一次铸)⇒ 放行;
1027
+ // 行在且卡说治理 ⇒ 不短路(往下走真出卡);读失败/超时 ⇒ **不短路**(不知道 = 不放宽)。
1028
+ // 代价如实记:grant 命中时多一次主键点读;店抖动时人会多看到一张卡(方向是收紧,且 noteStoreError
1029
+ // 留痕)。这不违反 D5「裁决可用性不因店抖动而降级」——人照样答得了,只是不再被**静默**代答。
1030
+ if (!governanceForced &&
1031
+ req.requiresRealApproval !== true &&
1032
+ primary.sessionId &&
1033
+ this.allowAllSessions.has(sessionAllowKey(primary.owner, primary.sessionId, sessionAllowCategory(req.toolName))) &&
1034
+ (await this.sessionGrantUncontestedByRow(origin, req))) {
917
1035
  return true;
918
1036
  }
919
1037
  // #151 车3 刀 3b(设计稿 §0 X-2):**写侧准入门** —— 落 pending / 建 timer / 落行 / 发帧之前。
@@ -936,10 +1054,6 @@ export class ToolApprovalCoordinator {
936
1054
  const id = uuidv7();
937
1055
  const child = isChildAsk(req);
938
1056
  const bounded = boundArgs(req.args);
939
- // [2942]/[2943]:治理来源标的**一次** peek(非消费式)。此前它只在下面的帧字面量里就地读 ——
940
- // #204 件7 起规则车道也要它(治理档的 ask 不进车道),两处读同一个值必须是**同一次** peek:
941
- // 分开读会让「帧说这是治理门 / 车道说不是」这种自相矛盾的卡在理论上存在。
942
- const governanceForced = (this.governanceAskMarks ?? governanceAskMarksFor(this.streamKey(primary.owner, primary.sessionId, origin.taskId))).isMarked(req.toolCallId);
943
1057
  // 🔴 **规则车道用的是本地 peek 与持久行的并集**(#204 件7,codex 对抗复审 R1-中,验真后修)。
944
1058
  // 本地表是进程内的:failover / 重启后幂等命中一条既有行时它恒空,而行上的卡里存着落库那一刻的判定。
945
1059
  // 下面 `ensureAsk` 的对账段按「行 = 真源」同步**帧上**那个键(那是出处属性,行说了算);但规则车道
@@ -1155,6 +1269,8 @@ export class ToolApprovalCoordinator {
1155
1269
  // 写下的 PARKING 行会让**同一只 ask** 的终局取决于「重试没重试」:首次 deny、重入 park。
1156
1270
  // 人的真决议(DECIDED ⇒ true/false)不经这个口 —— 那不是「无人可答」,回放它是幂等复述。
1157
1271
  const replayed = replayTerminalAskRow(existingOrCreated.row);
1272
+ if (replayed === true)
1273
+ recordRowDerivedApproveReplay("ensure-ask-idempotent-replay"); // A-054.15
1158
1274
  return replayed === "unavailable" ? this.unattendedOutcome() : replayed;
1159
1275
  }
1160
1276
  // 🔴 F2 修:行 = 真源。刚插的行上这三样与本地一致(采信是恒等操作);**重入**拿到既有行时,
@@ -1450,6 +1566,7 @@ export class ToolApprovalCoordinator {
1450
1566
  settle(false, "expired");
1451
1567
  }
1452
1568
  else if (outcome === true) {
1569
+ recordRowDerivedApproveReplay(`converge:${where}`); // A-054.15
1453
1570
  settle(true, "allowed");
1454
1571
  }
1455
1572
  else {
@@ -1486,7 +1603,19 @@ export class ToolApprovalCoordinator {
1486
1603
  windowRouteUnavailable = true;
1487
1604
  settle(false, "expired", undefined, hostSettledBy);
1488
1605
  };
1489
- const windowExpired = () => {
1606
+ /**
1607
+ * 🔴 `hostSelfReport` = 本次调用**结束这场等待的真实原因**能不能被本仓如实说出(A-054.13,2026-08-19
1608
+ * 合并重扫 confirmed)。缺省 `"timeout"` = 真的是本仓的窗走完了(定时器腿);断连强转的**有店支**
1609
+ * 整段委托本函数,它必须传 `undefined` —— 结束等待的是连接没了,`"timeout"` 会把「任务的流没了」谎成
1610
+ * 「没人答」(core 对该词铸的是「approver 的窗走完了没人答」)。此前有店支不传、于是继承了缺省词,
1611
+ * 与三处成文(本臂自注 / `PendingApproval.forceParkNow` 的契约句 / CHANGELOG 7.34.0 + 五臂表)逐字相反,
1612
+ * 而无店支一直是对的 —— 同一件事实的两条腿归因分家。自报只在 `deny` 政策下可见(park 路由把词吞掉)。
1613
+ *
1614
+ * ⚠️ 形参**必填、无缺省**(施工时先写成 `= "timeout"` 缺省形,当场被自己的红先钉打回:JS 的缺省参数
1615
+ * 对**显式传入的 `undefined`** 同样生效,`windowExpired(undefined)` 拿到的还是 `"timeout"`)。必填形
1616
+ * 顺带把「这条腿凭什么说得出这个词」变成每个调用点都要当场回答的问题。
1617
+ */
1618
+ const windowExpired = (hostSelfReport) => {
1490
1619
  void (async () => {
1491
1620
  if (!this.askStore || askId === undefined || batchId === undefined) {
1492
1621
  // D1(无持久 ask 店)——终局**完全**由本仓这个窗决定,没有第二个写者。
@@ -1502,7 +1631,7 @@ export class ToolApprovalCoordinator {
1502
1631
  // (根本不是 deny),词的存在理由被 park 语义整体取代;而 `deny` 政策下结束这次等待的**确实**
1503
1632
  // 是本仓的窗,词仍然如实,必须留。
1504
1633
  // 单赢者提交(codex R2-F1):人已落定 ⇒ 一个字都不改(D1 的 respond 是同步收尾,这条交错真实可达)。
1505
- settleParkRouteIfUnsettled("timeout");
1634
+ settleParkRouteIfUnsettled(hostSelfReport);
1506
1635
  return;
1507
1636
  }
1508
1637
  try {
@@ -1562,10 +1691,12 @@ export class ToolApprovalCoordinator {
1562
1691
  // 本修只补「人的真决议不许被丢」这**一条**缺口,不顺手改别人的语义。
1563
1692
  const decided = await this.readDecidedForRecovery(askId);
1564
1693
  if (decided !== undefined && !done) {
1694
+ if (decided)
1695
+ recordRowDerivedApproveReplay("expire-error-recovery"); // A-054.15:行派生 approve ⇒ 记债
1565
1696
  settle(decided, decided ? "allowed" : "denied");
1566
1697
  return;
1567
1698
  }
1568
- settleParkRouteIfUnsettled("timeout");
1699
+ settleParkRouteIfUnsettled(hostSelfReport); // A-054.13:断连强转委托进来时这里恒缺席
1569
1700
  }
1570
1701
  })();
1571
1702
  };
@@ -1574,6 +1705,9 @@ export class ToolApprovalCoordinator {
1574
1705
  // ⚠️ #280 R-13 之后无店支**仍不**复用 windowExpired,理由换了一条:两臂的 park/deny 方向如今同判
1575
1706
  // (A1),但**宿主自报**不同——窗到期臂说得出 `"timeout"`(结束等待的是本仓的窗),断连臂说不出
1576
1707
  // (结束等待的是连接没了,那个词会把「任务的流没了」谎成「没人答」)。自报只在 `deny` 政策下可见。
1708
+ // 🔴 A-054.13:**有店支也归这条纪律管**。此前它裸调 `windowExpired()`,于是继承了那边的缺省
1709
+ // `"timeout"`——店报错 catch 臂一落词,断连臂就说出了自己明写「说不出」的那个词(三处成文同时被打脸,
1710
+ // 且只在 `deny` 部署上可观测,所以一直没人撞见)。现在显式传 `undefined`:两条腿的归因从此同形。
1577
1711
  const forceParkNow = () => {
1578
1712
  if (done)
1579
1713
  return;
@@ -1581,11 +1715,13 @@ export class ToolApprovalCoordinator {
1581
1715
  settleParkRouteIfUnsettled(); // 单赢者提交(自报缺席:结束等待的是断连,不是窗)
1582
1716
  return;
1583
1717
  }
1584
- windowExpired();
1718
+ windowExpired(undefined);
1585
1719
  };
1586
1720
  // F2 修:定时器量的是**行上那个绝对 deadline** 的剩余时长(重入拿到既有行时,剩余时长比一整个窗短
1587
1721
  // —— 旧形会给一条早就该到期的行重新挂一个满窗的表)。store 缺席时逐字沿用 `effectiveWindowMs`(D1)。
1588
- timer = setTimeout(windowExpired, this.askStore ? Math.max(0, persistedExpiresAtMs - Date.now()) : effectiveWindowMs);
1722
+ // 🔴 包一层箭头而不是裸传函数引用:`windowExpired` 现在带必填形参(A-054.13),裸传会让 timer 的实参
1723
+ // 语义直接落到 `hostSelfReport` 上。定时器腿**结束等待的确实是本仓的窗** ⇒ 如实自报 `"timeout"`。
1724
+ timer = setTimeout(() => windowExpired("timeout"), this.askStore ? Math.max(0, persistedExpiresAtMs - Date.now()) : effectiveWindowMs);
1589
1725
  timer.unref?.();
1590
1726
  // #151 车2(D2 取消竞争者:task signal abort / 连接全灭同路):askStore 缺席 ⇒ settle(false,
1591
1727
  // "expired") 逐字不变(D1)。在场 ⇒ settle 前先打一把 transitionAsk(STREAM_PENDING→VOID)CAS。
@@ -2109,10 +2245,16 @@ export class ToolApprovalCoordinator {
2109
2245
  // 只放行**这一次**调用、绝不铸 session grant——铸了就等于把「必须逐次真人批」的位翻译成「批一次
2110
2246
  // 解锁整族」。rememberApplied=false 诚实回显(壳不得渲染「本 session 全放行」)。读侧配对臂
2111
2247
  // (askBroadcast 短路跳过)保证即使别的普通 ask 铸过同族 grant,安全类 ask 也照样出卡。
2248
+ // 🔴 A-054.20 写侧配对臂(与 #287 的安全类臂同形、同理由):**治理档的卡上点 allow_session 不铸 grant**。
2249
+ // 铸了就等于把「运维治理层下的那道门」翻译成「批一次解锁整族」;`buildRuleLaneMaterial` 对持久规则
2250
+ // 早已是这个判据(治理 ask 不进车道),session grant 只是它更短命的一揽子形。读侧配对臂在
2251
+ // `askBroadcast`(治理 ask 无视既有 grant)—— **两臂各管一半**:读侧还额外覆盖「普通卡铸的 grant
2252
+ // 掀掉后续治理门」这条更宽的面,写侧管的是「治理卡自己别再去铸一个」。
2112
2253
  let rememberApplied;
2254
+ const grantable = entry.requiresRealApproval !== true && entry.governanceForced !== true;
2113
2255
  if (parsed.value === "allow_session")
2114
- rememberApplied = Boolean(entry.sessionId) && entry.requiresRealApproval !== true;
2115
- if (parsed.value === "allow_session" && entry.sessionId && entry.requiresRealApproval !== true) {
2256
+ rememberApplied = Boolean(entry.sessionId) && grantable;
2257
+ if (parsed.value === "allow_session" && entry.sessionId && grantable) {
2116
2258
  // Bounded remember (insertion-order evict). 修2 scope: the grant is keyed by THIS ask's own capability
2117
2259
  // category — a Write/Edit-family ask unlocks the fs-write family for the (owner, session); ANY other tool's
2118
2260
  // allow_session unlocks only that same tool. A cheap non-write ask can therefore never become a session-wide
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.35.1",
3
+ "version": "7.36.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.43.0",
57
+ "@sema-agent/core": "^5.45.0",
58
58
  "@sema-agent/registry-core": "^0.18.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",