@sema-agent/server 7.14.0 → 7.16.0-rc.1

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 (71) hide show
  1. package/MIGRATION.md +16 -1
  2. package/USAGE.md +38 -4
  3. package/dist/approval-ask-machine.d.ts +10 -0
  4. package/dist/approval-ask-machine.js +10 -0
  5. package/dist/approval-card.d.ts +65 -0
  6. package/dist/approval-card.js +54 -6
  7. package/dist/approval-reconciler.d.ts +17 -1
  8. package/dist/boot/memory-boundary.d.ts +6 -0
  9. package/dist/boot/memory-boundary.js +10 -2
  10. package/dist/boot/resolve-spec.js +31 -4
  11. package/dist/boot/runner-deps.d.ts +41 -2
  12. package/dist/boot/runner-deps.js +43 -0
  13. package/dist/boot/session-faces.js +26 -4
  14. package/dist/boot/shutdown.js +17 -0
  15. package/dist/boot/stores.js +28 -7
  16. package/dist/capabilities/memory-notice.d.ts +13 -9
  17. package/dist/capabilities/memory-notice.js +35 -15
  18. package/dist/config-center/apply-effective.d.ts +14 -0
  19. package/dist/config-center/apply-effective.js +81 -1
  20. package/dist/config-types.d.ts +28 -2
  21. package/dist/config.js +38 -2
  22. package/dist/fleet/fleet-terminal-window.d.ts +12 -0
  23. package/dist/fleet/fleet-terminal-window.js +27 -4
  24. package/dist/http/active-run-conflict.d.ts +41 -1
  25. package/dist/http/active-run-conflict.js +24 -10
  26. package/dist/http/routes/approvals-assistant.d.ts +11 -3
  27. package/dist/http/routes/approvals-assistant.js +97 -20
  28. package/dist/http/routes/capabilities.js +20 -1
  29. package/dist/http/routes/diagnostics.d.ts +18 -0
  30. package/dist/http/routes/diagnostics.js +26 -0
  31. package/dist/http/routes/runs.js +86 -19
  32. package/dist/http/routes/side-query.js +15 -1
  33. package/dist/http/routes/tasks.js +32 -3
  34. package/dist/http/routes/trace-usage.js +38 -37
  35. package/dist/http/server.js +67 -13
  36. package/dist/leader/fanout.d.ts +18 -0
  37. package/dist/leader/fanout.js +34 -1
  38. package/dist/leader/leader.js +9 -5
  39. package/dist/leader/wire.js +16 -5
  40. package/dist/main.js +1 -0
  41. package/dist/memory-scope.d.ts +20 -0
  42. package/dist/memory-scope.js +45 -0
  43. package/dist/model-select.d.ts +43 -1
  44. package/dist/model-select.js +70 -2
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +7 -1
  48. package/dist/parent-watch.d.ts +57 -0
  49. package/dist/parent-watch.js +108 -0
  50. package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
  51. package/dist/plugins/checkpoint-store-sql.js +133 -7
  52. package/dist/plugins/local-checkpoint-store.js +11 -1
  53. package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
  54. package/dist/plugins/memory-embedder-fingerprint.js +280 -0
  55. package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
  56. package/dist/plugins/permission-rule-store-sql.js +1 -7
  57. package/dist/plugins/pg-pool.js +3 -0
  58. package/dist/plugins/store-backend.d.ts +5 -6
  59. package/dist/plugins/store-backend.js +4 -1
  60. package/dist/plugins/store-contracts.d.ts +17 -0
  61. package/dist/plugins/store-contracts.js +33 -0
  62. package/dist/plugins/tidb-pool.js +7 -0
  63. package/dist/plugins/tool-result-store-sql.d.ts +18 -13
  64. package/dist/plugins/tool-result-store-sql.js +50 -29
  65. package/dist/run-local.js +1 -0
  66. package/dist/tool-approval.d.ts +10 -0
  67. package/dist/tool-approval.js +143 -9
  68. package/dist/trace/project.js +8 -0
  69. package/dist/trace/redact.d.ts +14 -1
  70. package/dist/trace/redact.js +14 -2
  71. package/package.json +3 -3
package/dist/run-local.js CHANGED
@@ -582,6 +582,7 @@ export async function runLocal(argv, deps = {}) {
582
582
  */
583
583
  const sharedRunnerDeps = createSharedRunnerDeps({
584
584
  config,
585
+ logger, // #240②:onNotice 席(共享基座键,本地形同席——结构化通告落同一 logger)
585
586
  // 交接件⑤:commit 尾注署名座进**共享基座** ⇒ 主 runner 与 subRunner 同源。
586
587
  // [931]① clay 拍:run-local = Sema 品牌本地形态,commit 尾注接 Sema 署名(core 1.300 缺省已翻转不署)。
587
588
  // 修前它只写在下面主 runner 的差异键里 ⇒ **委派出去的子代提交不带署名**,而它提交进的是同一个仓、
@@ -190,6 +190,7 @@ export declare function parseToolApprovalResponse(body: unknown): {
190
190
  value: ToolApprovalDecision;
191
191
  updatedInput?: unknown;
192
192
  persistRule?: string;
193
+ note?: string;
193
194
  } | {
194
195
  ok: false;
195
196
  error: string;
@@ -418,7 +419,16 @@ export declare class ToolApprovalCoordinator {
418
419
  * `req.args` 是 `unknown` ⇒ 窄读,**禁裸 as-cast**(宪法 [2704]:一个形状漂了的 args 若被 cast,
419
420
  * 会把 `undefined` 当命令送进 `prepareCardApproval`,那是放宽面上的静默垃圾)。
420
421
  */
422
+ /** 两份候选是否**逐条逐键**相等(顺序即展示序 ⇒ 顺序敏感)。两侧都缺席 = 相等;一侧缺席 = 不等。
423
+ * 用在幂等重入的「行 = 真源」对账上(见 `ensureAsk` 那段撤回臂)。 */
424
+ private static ruleSuggestionsEqual;
421
425
  private buildRuleLaneMaterial;
426
+ /** #241([3731]/[3730] 双属主裁定):把某 wire run 的**流内未决 ask** 立即转 durable park——断连支专用。
427
+ * 匹配键=`ctxTaskId`(wire run id)。该字段的顶注说它「不是清扫判据」——那是因为清扫要判**连接**的
428
+ * 生死(同 id 可被两个 ctx 实例复用);本臂语义不同:调用方就是该 wire run 的承载流,断连=这个 id 的
429
+ * 流没了,值相等即目标集;极端复用形下多转的那条也只是从流内卡变 durable 卡,方向保守。
430
+ * 返回触发条数(0 = 无流内未决 ask,调用方照旧 abort)。幂等:已结算条目跳过,重复调用无害。 */
431
+ parkOpenAsksForTask(wireTaskId: string): number;
422
432
  /** 测试/可观测性钩子(X-2):某 (taskId) 维当前占用的准入名额数。 */
423
433
  admittedCount(taskId: string): number;
424
434
  /** #151 车2(D5 store 故障姿势):任何 store 调用 throw ⇒ fail-open 到进程内机械照旧(store 是记账/
@@ -47,8 +47,8 @@ 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 { deriveAskId, deriveBatchId } from "./approval-ask-machine.js";
51
- import { ApprovalCardEnvelopeSchema, buildApprovalCard, buildApprovalCardEnvelope, buildApprovalRequestFrame, buildRevokeFrame, } from "./approval-card.js";
50
+ import { deriveAskId, deriveBatchId, MAX_DECISION_NOTE_CHARS } from "./approval-ask-machine.js";
51
+ import { ApprovalCardEnvelopeSchema, MAX_RULE_SUGGESTIONS, buildApprovalCard, buildApprovalCardEnvelope, buildApprovalRequestFrame, buildRevokeFrame, } from "./approval-card.js";
52
52
  import { governanceAskMarksFor, runWithGovernanceAskScope } from "./governance-ask-marks.js";
53
53
  /** #151 车2:本模块自有的日志出口——同 config-provider.ts/runtime-caps-resolver.ts 先例(协调器不走
54
54
  * DI logger,构造签名是设计定稿钉死的三键 options bag,加第四个 logger 键属于重议已裁事项)。仅用于
@@ -159,7 +159,27 @@ export function parseToolApprovalResponse(body) {
159
159
  }
160
160
  persistRule = rule;
161
161
  }
162
- return { ok: true, value: d, ...(d !== "deny" && u !== undefined ? { updatedInput: u } : {}), ...(persistRule !== undefined ? { persistRule } : {}) };
162
+ // #229(设计稿 233 稿B):回决**理由**。与 durable (`http/routes/runs.ts` `AskDecisionBodySchema.note`)
163
+ // **同词同源同上限**({@link MAX_DECISION_NOTE_CHARS}),落的也是同一列 `decision_note` —— 不造第三口径。
164
+ // 🔴 **任何 decision 都可带**(deny 也算),与 durable 腿的无条件记账形对齐:「为什么拒」正是审计面上
165
+ // 最值钱的那一条,做成 allow-only 等于把它扔掉。⚠️ `allow_session` 是 wire 上的三值之一,落到行上是
166
+ // `approve`(三值映二值)—— 它的 note 因此记在**那条 approve 行**上,不另开一行、也不丢。
167
+ // 坏形(非串 / 超上限)是**响亮 400**,不静默截断:一条被悄悄砍半的审计理由比没有理由更坏。
168
+ const rawNote = body.note;
169
+ let note;
170
+ if (rawNote !== undefined) {
171
+ if (typeof rawNote !== "string" || rawNote.length > MAX_DECISION_NOTE_CHARS) {
172
+ return { ok: false, error: `note must be a string of at most ${MAX_DECISION_NOTE_CHARS} characters` };
173
+ }
174
+ note = rawNote;
175
+ }
176
+ return {
177
+ ok: true,
178
+ value: d,
179
+ ...(d !== "deny" && u !== undefined ? { updatedInput: u } : {}),
180
+ ...(persistRule !== undefined ? { persistRule } : {}),
181
+ ...(note !== undefined ? { note } : {}),
182
+ };
163
183
  }
164
184
  return { ok: false, error: 'decision must be one of "allow" | "allow_session" | "deny"' };
165
185
  }
@@ -430,6 +450,15 @@ export class ToolApprovalCoordinator {
430
450
  * `req.args` 是 `unknown` ⇒ 窄读,**禁裸 as-cast**(宪法 [2704]:一个形状漂了的 args 若被 cast,
431
451
  * 会把 `undefined` 当命令送进 `prepareCardApproval`,那是放宽面上的静默垃圾)。
432
452
  */
453
+ /** 两份候选是否**逐条逐键**相等(顺序即展示序 ⇒ 顺序敏感)。两侧都缺席 = 相等;一侧缺席 = 不等。
454
+ * 用在幂等重入的「行 = 真源」对账上(见 `ensureAsk` 那段撤回臂)。 */
455
+ static ruleSuggestionsEqual(a, b) {
456
+ if (a === undefined || b === undefined)
457
+ return a === b;
458
+ if (a.length !== b.length)
459
+ return false;
460
+ return a.every((x, i) => x.rule === b[i].rule && x.match === b[i].match && x.command === b[i].command);
461
+ }
433
462
  buildRuleLaneMaterial(req, owner, governanceForced) {
434
463
  if (this.ruleConsent === undefined)
435
464
  return undefined;
@@ -449,9 +478,21 @@ export class ToolApprovalCoordinator {
449
478
  // 「单机 + 无 principal」的部署第一次同时满足其余三项。
450
479
  if (owner === null)
451
480
  return undefined;
452
- const suggestions = req.ruleSuggestions;
453
- if (!Array.isArray(suggestions) || suggestions.length === 0)
481
+ const rawSuggestions = req.ruleSuggestions;
482
+ if (!Array.isArray(rawSuggestions) || rawSuggestions.length === 0)
454
483
  return undefined;
484
+ // 🔴 **基数帽在这里扣,而且只在这里扣**(重扫二轮红先修)。此前同步腿对这一格**零执法**:两族帧与
485
+ // `card_json` 都是逐字透传,真正按 {@link MAX_RULE_SUGGESTIONS} 截的只有耐久腿
486
+ // (`boundedRuleSuggestions`)。于是上游给 5 条时:live 帧发 5 条(契约文 §4a-bis 却把「server 执法帽 4」
487
+ // 写在**活卡帧**这条载体上,消费端按 ≤4 布局)、`card_json` 落 5 条,而一切读面都过
488
+ // `ApprovalCardSchema`(`ruleSuggestions.max(4)`)⇒ 判假 ⇒ 重放腿跳过 + `persisted-row-unusable` 臂返
489
+ // `"unavailable"` ⇒ **整只 ask 走 park**。那条「容忍余量」的设计意图在同步腿上完全没兑现,只是把悬崖
490
+ // 从 2 挪到了 4。
491
+ // 截在**素材铸点**而不是 `buildApprovalCard`:后者只截卡,会让 live 帧(5 条)与卡(4 条)分家,违背
492
+ // `approval-card.ts` 顶注自证的「wire 帧与 card_json 用同一份已洗素材,不是两处各算一遍的巧合」。
493
+ // 单一属主收在这里,于是**两族帧 + `card_json` + 回决口 `rule_not_offered` 的等式左边**拿的是同一份 ——
494
+ // 没渲出来的候选也就恒兑不动(反过来才是真扩权面)。保序取前 N:core 的 `exact` 形恒在 index 0。
495
+ const suggestions = rawSuggestions.length > MAX_RULE_SUGGESTIONS ? rawSuggestions.slice(0, MAX_RULE_SUGGESTIONS) : rawSuggestions;
455
496
  const args = req.args;
456
497
  if (args === null || typeof args !== "object" || Array.isArray(args))
457
498
  return undefined;
@@ -466,6 +507,21 @@ export class ToolApprovalCoordinator {
466
507
  ...(boundInputHash !== null ? { boundInputHash } : {}),
467
508
  };
468
509
  }
510
+ /** #241([3731]/[3730] 双属主裁定):把某 wire run 的**流内未决 ask** 立即转 durable park——断连支专用。
511
+ * 匹配键=`ctxTaskId`(wire run id)。该字段的顶注说它「不是清扫判据」——那是因为清扫要判**连接**的
512
+ * 生死(同 id 可被两个 ctx 实例复用);本臂语义不同:调用方就是该 wire run 的承载流,断连=这个 id 的
513
+ * 流没了,值相等即目标集;极端复用形下多转的那条也只是从流内卡变 durable 卡,方向保守。
514
+ * 返回触发条数(0 = 无流内未决 ask,调用方照旧 abort)。幂等:已结算条目跳过,重复调用无害。 */
515
+ parkOpenAsksForTask(wireTaskId) {
516
+ let n = 0;
517
+ for (const e of this.pending.values()) {
518
+ if (e.ctxTaskId === wireTaskId && e.settledOutcome === undefined) {
519
+ e.forceParkNow();
520
+ n++;
521
+ }
522
+ }
523
+ return n;
524
+ }
469
525
  /** 测试/可观测性钩子(X-2):某 (taskId) 维当前占用的准入名额数。 */
470
526
  admittedCount(taskId) {
471
527
  return this.admitByTask.get(taskId) ?? 0;
@@ -1033,6 +1089,28 @@ export class ToolApprovalCoordinator {
1033
1089
  cardForFrame = rest;
1034
1090
  }
1035
1091
  }
1092
+ // 🔴 **候选是「行 = 真源」F2 家族的第三位**(codex 对抗复审 R1-[medium],验真后修)。上面两条把
1093
+ // `governanceForced` / `persistedRuleShadowed` 收进了这条纪律,候选却漏在外面:幂等命中一条既有行时
1094
+ // 呈卡帧读的是**行上的卡**,而旧帧的 `ruleSuggestions` 与 `ruleLaneMaterial`(= 回决口
1095
+ // `rule_not_offered` 那道等式的**左边**)仍来自**这一腿**的 `req`。两腿的候选可以真的不同(重试 /
1096
+ // 并发 / 混版跑 / 规则店在两腿之间变了),于是同一个 `approvalId` 上会出现:呈卡帧展示 A、旧帧展示 B、
1097
+ // 兑付口按 B 判 —— 人点了卡上看见的 A,拿回一句 `rule_not_offered`。
1098
+ //
1099
+ // 处置 = **不一致就整条撤回车道**,与上面那条治理撤回**逐字同形**(本文件既定的安全方向:少渲一格
1100
+ // 是安全的,渲一格按不动的才是 wire 谎言)。不选「采信行的候选」:兑付还要 `command`/`boundInputHash`
1101
+ // 等素材,而行上的卡不带它们,拼起来就是第三份来源;也不选「采信本地」:人看见的是行上那张卡。
1102
+ // ⚠️ 残余如实记:这里撤的是**本次投递**的两族帧与素材,行本身不动 ⇒ 纯重放腿(`buildReplayFrame`)
1103
+ // 仍会从行上投出候选。那条腿本来就没有自己的兑付通道(射程见 `ApprovalCardSchema.ruleSuggestions`
1104
+ // 顶注:只有同副本 live `approvalId` 兑得动),所以不构成「按得动却兑不动」。
1105
+ if (ruleLaneMaterial !== undefined && !ToolApprovalCoordinator.ruleSuggestionsEqual(cardForFrame.ruleSuggestions, ruleLaneMaterial.suggestions)) {
1106
+ this.noteStoreError(new Error("persisted approval row offers a different ruleSuggestions set than this leg — withdrawing the rule lane for this delivery"), "ensureAsk(rule-suggestions-drift)");
1107
+ ruleLaneMaterial = undefined;
1108
+ delete frame.ruleSuggestions;
1109
+ if (cardForFrame.ruleSuggestions !== undefined) {
1110
+ const { ruleSuggestions: _drifted, ...rest } = cardForFrame;
1111
+ cardForFrame = rest;
1112
+ }
1113
+ }
1036
1114
  }
1037
1115
  catch (err) {
1038
1116
  this.noteStoreError(err, "ensureAsk");
@@ -1271,6 +1349,18 @@ export class ToolApprovalCoordinator {
1271
1349
  }
1272
1350
  })();
1273
1351
  };
1352
+ // #241:断连强制转 park(接口注释见 PendingApproval.forceParkNow)。有店 ⇒ 走窗到期同路(expireAsk
1353
+ // CAS+兄弟撤卡,赢=unavailable/输=向持久真源收敛——人已决就尊重人);无店 ⇒ 进程内直接改判 park 路由。
1354
+ const forceParkNow = () => {
1355
+ if (done)
1356
+ return;
1357
+ if (!this.askStore || askId === undefined || batchId === undefined) {
1358
+ windowRouteUnavailable = true;
1359
+ settle(false, "expired");
1360
+ return;
1361
+ }
1362
+ windowExpired();
1363
+ };
1274
1364
  // F2 修:定时器量的是**行上那个绝对 deadline** 的剩余时长(重入拿到既有行时,剩余时长比一整个窗短
1275
1365
  // —— 旧形会给一条早就该到期的行重新挂一个满窗的表)。store 缺席时逐字沿用 `effectiveWindowMs`(D1)。
1276
1366
  timer = setTimeout(windowExpired, this.askStore ? Math.max(0, persistedExpiresAtMs - Date.now()) : effectiveWindowMs);
@@ -1394,6 +1484,7 @@ export class ToolApprovalCoordinator {
1394
1484
  // 自己的 TTL/abortSignal 才作数;上面的 aliveCtxs abort 倒计时仍照常适用于它自己的连接级 abort)。
1395
1485
  const pendingEntry = {
1396
1486
  settle,
1487
+ forceParkNow,
1397
1488
  owner: primary.owner,
1398
1489
  ctxTaskId: primary.taskId,
1399
1490
  originCtx: primary,
@@ -1664,14 +1755,28 @@ export class ToolApprovalCoordinator {
1664
1755
  * 时仍是纯同步函数(D1)。`askStore`/`askId`/`batchId` 由调用方在已窄化的分支里传入(避免非空断言)。 */
1665
1756
  async respondWithCas(askStore, askId, batchId, id, entry, parsed) {
1666
1757
  let decidedWon = false;
1758
+ // 🔴 #229 + codex 对抗复审 round1 [high](验真后修):`noteRecorded` 的判据比 `decidedWon` **更严**,
1759
+ // 所以是**两个**变量而不是一个。`decidedWon` 回答「这条决议是不是终局」(单赢者 CAS 的语义,足够支撑
1760
+ // 200/404 的分派与 `notifyExternalDecision`);`noteLanded` 回答「**这一次请求**的那段文本在不在行上」。
1761
+ // 两者在**提交歧义**臂上会分岔(见下方 catch 里的理由),而回执上那句「你的理由记上了」只能由后者答。
1762
+ let noteLanded = false;
1667
1763
  try {
1668
- const decided = await this.withStoreDeadline(askStore.decideAsk(askId, batchId, { decision: parsed.value === "deny" ? "deny" : "approve" }), "decideAsk", DURABLE_CALL_TIMEOUT_MS, (late) => {
1764
+ const decided = await this.withStoreDeadline(
1765
+ // #229:`decisionNote` 是**店缝上既有**的键(`DecideAskInput.decisionNote`,SQL twin 与 memory twin
1766
+ // 都已实装)⇒ 零新 SQL、零新列,live 腿与 durable 腿写同一条 UPDATE 的同一列。
1767
+ askStore.decideAsk(askId, batchId, {
1768
+ decision: parsed.value === "deny" ? "deny" : "approve",
1769
+ ...(parsed.note !== undefined ? { decisionNote: parsed.note } : {}),
1770
+ }), "decideAsk", DURABLE_CALL_TIMEOUT_MS, (late) => {
1669
1771
  // R3-1 迟到成功:HTTP 响应早已发出(这次调用报的是超时),但**决议真落了盘** —— 同 askId 下
1670
1772
  // 还在悬挂的本地条目必须按真决议结清,否则它们只能等自己的窗/取消再绕一圈。
1671
1773
  if (late.ok)
1672
1774
  this.notifyExternalDecision(askId, parsed.value !== "deny", parsed.updatedInput);
1673
1775
  });
1674
1776
  decidedWon = decided.ok;
1777
+ // 干净赢下 CAS ⇒ note 与决议是**同一条 UPDATE** 写的,赢即落,不必再读一次。
1778
+ if (decided.ok)
1779
+ noteLanded = true;
1675
1780
  if (!decided.ok) {
1676
1781
  // CAS 输——respond 绝不覆盖赢家(D2)。逐字复用现行 404 形(409/410 分化留给车4)。
1677
1782
  return { status: 404, body: { error: "no pending tool approval for this id (settled, expired, or not on this replica)", errorCode: "tool_approval.not_pending" } };
@@ -1687,8 +1792,18 @@ export class ToolApprovalCoordinator {
1687
1792
  // 那本就该回放成功)。复核读本身失败/超时 ⇒ 维持「不知道」,守卫照旧 404(不许把未知说成成功)。
1688
1793
  try {
1689
1794
  const row = await this.withStoreDeadline(askStore.getAsk(askId), "getAsk(decideAsk-confirm)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
1690
- if (row?.state === "DECIDED" && row.decision === (parsed.value === "deny" ? "deny" : "approve"))
1795
+ if (row?.state === "DECIDED" && row.decision === (parsed.value === "deny" ? "deny" : "approve")) {
1691
1796
  decidedWon = true;
1797
+ // 🔴 codex 对抗复审 round1 [high](真 finding,红先复现):**note 的判据不能沿用决议的判据**。
1798
+ // 上一段那句「别人不可能写出同一条决议再让我们看见」对**决议**成立(单赢者 CAS),对 `note`
1799
+ // **不成立** —— 两路并发 respond 完全可以带**同决议、不同 note**:一路干净赢下 CAS(写进去的是
1800
+ // 它的 note),另一路撞上提交歧义,在这次确认读里看见那条 DECIDED 行。只比决议的话,输的那一路
1801
+ // 会把别人的胜利认成自己的,回一句「你的理由记上了」,而行上是**别人**的理由 —— 恰好是本布尔
1802
+ // 存在的意义被反过来用。所以这里逐字比**行上的 note**:相等才算落地(文本真的相同就不是谎,
1803
+ // 哪怕是别人写的);行上没有 note、或与本次提交不同 ⇒ 如实 `false`。
1804
+ // 决议侧的 `decidedWon` 保持原样(200/404 的分派与 `notifyExternalDecision` 的判据不动)。
1805
+ noteLanded = parsed.note === undefined || row.decisionNote === parsed.note;
1806
+ }
1692
1807
  }
1693
1808
  catch (confirmErr) {
1694
1809
  this.noteStoreError(confirmErr, "getAsk(decideAsk-confirm)");
@@ -1726,9 +1841,12 @@ export class ToolApprovalCoordinator {
1726
1841
  // session 记忆照记(grant 谈的是**将来**的 ask,与这次投递是否由我完成无关)。
1727
1842
  // 但 `updatedInput` **没有**随那次结算送达闭包(R2-4)⇒ 显式声明未投递,回显里不许出现
1728
1843
  // `updatedInputForwarded`。
1729
- return this.finishRespond(id, entry, parsed, { updatedInputDelivered: false });
1844
+ // #229:`noteRecorded` 取的是**店的真实结果**(`noteLanded`,比 `decidedWon` 更严——见其声明处),
1845
+ // 与 `updatedInputForwarded` 的判据刻意分家:那一格问「闭包收到编辑没有」(本支恒否),
1846
+ // 这一格问「行上记下的是不是**这次**的理由」。两件事,不共用一个布尔。
1847
+ return this.finishRespond(id, entry, parsed, { updatedInputDelivered: false, noteRecorded: noteLanded });
1730
1848
  }
1731
- const result = this.finishRespond(id, entry, parsed);
1849
+ const result = this.finishRespond(id, entry, parsed, { noteRecorded: noteLanded });
1732
1850
  // round5(pendingByAskId 顶注):回决赢下 CAS 时同样要收尾「同 askId 重复本地注册」那一支——与
1733
1851
  // windowExpired/runCancel/emitAllP 三条竞争者的赢家路径同精神(那三处已经这么做)。此处 entry 已经
1734
1852
  // 经 `finishRespond` 自行 settle 并从 pendingByAskId 的 Set 里摘除自己,故这里天然只会清算真正
@@ -1768,6 +1886,21 @@ export class ToolApprovalCoordinator {
1768
1886
  // 命令行);此时还回 `updatedInputForwarded: true` 等于告诉壳「你的编辑生效了」,是最不该撒的那种谎。
1769
1887
  // 调用方在 stale-entry 分支传 `updatedInputDelivered: false`,回显里这个键就整个缺席(壳按未透传处理)。
1770
1888
  const updatedInputDelivered = opts?.updatedInputDelivered ?? true;
1889
+ // #229(设计稿 233 稿B v2 §1):`noteRecorded` = 这次回决的**理由到底有没有落进持久行**。
1890
+ //
1891
+ // 🔴 形照 `rulePersisted`(always-emit 布尔,只在请求真带了 `note` 时在场),值照**店的真实结果**
1892
+ // (调用方传进来的 `decidedWon`,含 CAS 抛错后那次确认读的改判),不是「askStore 在不在」:
1893
+ // · 本方法被 `respond()` **直接**调到 = store 缺席,或 store 在场但 `ensureAsk` 失败已清掉坐标 ——
1894
+ // 两种都是「压根没打过那条 UPDATE」,缺省 `false` 正是这一支的真相;
1895
+ // · `respondWithCas` 的 D5 fail-open 支(店抖动、确认读也没读到赢)同样 `false` —— **不知道不许
1896
+ // 说成成功**(与 `updatedInputForwarded` 从不发 `false`、只在真投递时发 `true` 是同一条纪律的两面:
1897
+ // 那一格靠缺席表达否定,这一格是三态里的一态,必须显式说 `false`,否则壳分不出「这台不支持」);
1898
+ // · 提交歧义臂上**赢了决议但行上是别人的 note**(同决议不同 note 的并发)同样 `false` —— 判据是
1899
+ // 「行上那段文本是不是这次提交的」,不是「这条决议是不是终局」(codex round1 [high],见调用方
1900
+ // `respondWithCas` 里 `noteLanded` 的推导)。
1901
+ // 能力位 `capabilities.approvalDecisionNote` 与本格**必须同车**:老服务对未知键静默丢 + 200,少了
1902
+ // 能力位,「记上了」与「这台不认识 note」在 wire 上不可判别。
1903
+ const noteRecorded = opts?.noteRecorded ?? false;
1771
1904
  return {
1772
1905
  status: 200,
1773
1906
  body: {
@@ -1776,6 +1909,7 @@ export class ToolApprovalCoordinator {
1776
1909
  decision: parsed.value,
1777
1910
  ...(rememberApplied !== undefined ? { rememberApplied } : {}),
1778
1911
  ...(parsed.updatedInput !== undefined && allowed && updatedInputDelivered ? { updatedInputForwarded: true } : {}),
1912
+ ...(parsed.note !== undefined ? { noteRecorded } : {}),
1779
1913
  },
1780
1914
  };
1781
1915
  }
@@ -886,7 +886,15 @@ function mapTraceEventInner(type, seq, data) {
886
886
  return { event: "prompt-assembled", data: { seq, ...data } };
887
887
  case "done":
888
888
  return { event: "done", data: { seq } };
889
+ // 🔴 park 的账本事件名有**两个**(扫描P2,status/kind 同名词混淆病族):`suspended` 与
890
+ // `needs_review` —— 写点是 `trace/ledger-sink.ts` 的 `appendParked(kind)`(kind 逐字取自
891
+ // `result.status`)、`runs.ts` 的 bg 腿、`http/server.ts` 的两条 resume 腿;`needs_review` 就是
892
+ // plan_review / dry_run 两种 gate 的 park 落名。只映射前者 ⇒ 一条 plan_review park 在 trace 流上
893
+ // 落进 `default → null`,**一帧都不发**,消费端只看到流无声中止(既不是 done 也不是 error)。
894
+ // 契约 2.3 的 park 标记只有 `suspended: true` 这一个位 ⇒ 两个词共用同一张帧(gate 细分在
895
+ // `GET /v1/runs/:id`,不在这条流上)。
889
896
  case "suspended":
897
+ case "needs_review":
890
898
  return { event: "done", data: { seq, suspended: true } };
891
899
  case "failed":
892
900
  // BL-13: redact at the read boundary too (defense-in-depth) — covers any errorMessage that reached the
@@ -1,5 +1,18 @@
1
1
  export declare function setRedactionObserver(fn: ((patternLabel: string, count: number) => void) | undefined): void;
2
- export declare function redactSecrets(text: string): string;
2
+ /**
3
+ * `opts.observe: false` = **照常脱敏,但这一遍不计数**(合并码重扫)。
4
+ *
5
+ * 为什么需要它:本函数对自己的输出**值幂等、计数不幂等** —— URL-userinfo 与 JSON 秘密字段两条 pattern
6
+ * 会命中自己已经替换过的输出并再计一次。于是任何**重扫已脱敏文本**的读路径(耐久待批队列的回读腿,
7
+ * `/v1/approvals/stream` 每 3s 一拍)都会持续给 `redactions_applied_total` 加数,把那个用来识别
8
+ * 「过度脱敏 / pattern 从不触发」的指标灌成不可读。
9
+ *
10
+ * 🔴 关的是**计数**,不是脱敏:重扫这一遍仍是纵深防御(手改过的行、未来某条写路径漏了脱敏,读面照样
11
+ * 兜住)—— 少脱一次的代价是把凭证投上跨租户可见的运维队列,那条永远不能省。
12
+ */
13
+ export declare function redactSecrets(text: string, opts?: {
14
+ observe?: boolean;
15
+ }): string;
3
16
  /**
4
17
  * 🔴 环 + 深度预算(2026-07-25 补)。原实现是纯递归、**既不记已访问节点也不限深度**,而它的调用位置是
5
18
  * **最不可信的边界**:`toolStart`/`toolEnd` 把工具的 `args`/`output`/`structured` 直接喂进来。后果两条:
@@ -33,11 +33,23 @@ export function setRedactionObserver(fn) {
33
33
  onRedaction = fn;
34
34
  }
35
35
  const PATTERN_LABELS = SECRET_PATTERNS.map(([, repl]) => repl.replace(/[«»]/g, "").replace(/^redacted:/, ""));
36
- export function redactSecrets(text) {
36
+ /**
37
+ * `opts.observe: false` = **照常脱敏,但这一遍不计数**(合并码重扫)。
38
+ *
39
+ * 为什么需要它:本函数对自己的输出**值幂等、计数不幂等** —— URL-userinfo 与 JSON 秘密字段两条 pattern
40
+ * 会命中自己已经替换过的输出并再计一次。于是任何**重扫已脱敏文本**的读路径(耐久待批队列的回读腿,
41
+ * `/v1/approvals/stream` 每 3s 一拍)都会持续给 `redactions_applied_total` 加数,把那个用来识别
42
+ * 「过度脱敏 / pattern 从不触发」的指标灌成不可读。
43
+ *
44
+ * 🔴 关的是**计数**,不是脱敏:重扫这一遍仍是纵深防御(手改过的行、未来某条写路径漏了脱敏,读面照样
45
+ * 兜住)—— 少脱一次的代价是把凭证投上跨租户可见的运维队列,那条永远不能省。
46
+ */
47
+ export function redactSecrets(text, opts) {
48
+ const observe = opts?.observe ?? true;
37
49
  let out = text;
38
50
  for (let i = 0; i < SECRET_PATTERNS.length; i++) {
39
51
  const [re, repl] = SECRET_PATTERNS[i];
40
- if (!onRedaction) {
52
+ if (!onRedaction || !observe) {
41
53
  out = out.replace(re, repl);
42
54
  continue;
43
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.14.0",
3
+ "version": "7.16.0-rc.1",
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.27.0",
57
+ "@sema-agent/core": "^5.28.0",
58
58
  "@sema-agent/registry-core": "^0.16.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.35.3"
70
70
  },
71
71
  "devDependencies": {
72
- "@sema-agent/sdk": "^6.15.0",
72
+ "@sema-agent/sdk": "^6.17.2",
73
73
  "@types/libsodium-wrappers": "^0.7.14",
74
74
  "@types/node": "22.10.2",
75
75
  "@types/pg": "^8.20.0",