@sema-agent/server 7.3.0 → 7.5.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 (78) hide show
  1. package/USAGE.md +63 -0
  2. package/dist/approval-card.d.ts +15 -3
  3. package/dist/approval-card.js +41 -7
  4. package/dist/approval-reconciler.d.ts +120 -16
  5. package/dist/approval-reconciler.js +146 -19
  6. package/dist/boot/coordinators.js +13 -3
  7. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  8. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  9. package/dist/boot/execution-env.js +11 -1
  10. package/dist/boot/org-memory.d.ts +6 -0
  11. package/dist/boot/org-memory.js +1 -1
  12. package/dist/boot/reapers.d.ts +2 -0
  13. package/dist/boot/reapers.js +11 -4
  14. package/dist/boot/resolve-spec.d.ts +3 -2
  15. package/dist/boot/resolve-spec.js +175 -63
  16. package/dist/boot/runner-deps.d.ts +23 -1
  17. package/dist/boot/runner-deps.js +8 -11
  18. package/dist/boot/workflow-orchestration.d.ts +8 -3
  19. package/dist/boot/workflow-orchestration.js +23 -1
  20. package/dist/capabilities/center-prompts.js +4 -1
  21. package/dist/config-center/apply-effective.js +33 -10
  22. package/dist/config-types.d.ts +32 -9
  23. package/dist/config.d.ts +6 -1
  24. package/dist/config.js +65 -12
  25. package/dist/elicitation.js +5 -1
  26. package/dist/env-facts.d.ts +3 -1
  27. package/dist/env-facts.js +3 -1
  28. package/dist/fleet/fleet-bus.d.ts +6 -1
  29. package/dist/fleet/fleet-bus.js +25 -3
  30. package/dist/governance-ask-marks.d.ts +31 -0
  31. package/dist/governance-ask-marks.js +122 -0
  32. package/dist/hooks/hook-runner.d.ts +28 -0
  33. package/dist/hooks/hook-runner.js +180 -24
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +145 -0
  36. package/dist/http/routes/memory-policy.d.ts +2 -1
  37. package/dist/http/routes/memory-policy.js +77 -13
  38. package/dist/http/routes/runs.js +1 -1
  39. package/dist/http/routes/tasks.js +87 -29
  40. package/dist/http/server.d.ts +10 -0
  41. package/dist/http/server.js +29 -12
  42. package/dist/http/wire-types.d.ts +7 -2
  43. package/dist/main.js +51 -8
  44. package/dist/observability/fail-open.d.ts +109 -0
  45. package/dist/observability/fail-open.js +227 -0
  46. package/dist/observability/prompt-manifest.d.ts +17 -0
  47. package/dist/observability/prompt-manifest.js +8 -0
  48. package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
  49. package/dist/orchestration/workflow-notify-journal.js +137 -32
  50. package/dist/parked-decide.js +9 -4
  51. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  52. package/dist/plugins/approval-ask-store-memory.js +3 -2
  53. package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
  54. package/dist/plugins/approval-ask-store-sql.js +9 -2
  55. package/dist/plugins/background-shell-support.d.ts +1 -1
  56. package/dist/plugins/background-shell-support.js +2 -2
  57. package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
  58. package/dist/plugins/checkpoint-store-sql.js +71 -11
  59. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  60. package/dist/plugins/local-checkpoint-store.js +19 -0
  61. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  62. package/dist/plugins/mailbox-store-sql.js +57 -4
  63. package/dist/question.d.ts +18 -14
  64. package/dist/question.js +83 -34
  65. package/dist/runs.d.ts +8 -0
  66. package/dist/runs.js +15 -2
  67. package/dist/runtime-governance.d.ts +18 -0
  68. package/dist/runtime-governance.js +90 -3
  69. package/dist/task-settings.d.ts +16 -21
  70. package/dist/task-settings.js +22 -19
  71. package/dist/tool-approval.d.ts +33 -6
  72. package/dist/tool-approval.js +95 -30
  73. package/dist/trace/core-keyset-guard.d.ts +17 -3
  74. package/dist/trace/project.d.ts +36 -1
  75. package/dist/trace/project.js +55 -2
  76. package/package.json +3 -3
  77. package/dist/boot/lexical-path-env.d.ts +0 -14
  78. package/dist/boot/lexical-path-env.js +0 -116
package/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { Runner, TtlSessionStore, uuidv7, defaultTaskRegistry, combinePolicies, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel, probeSearchBackend } from "@sema-agent/core";
4
+ import { Runner, TtlSessionStore, uuidv7, defaultTaskRegistry, combinePolicies, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel, probeSearchBackend, describeStaticWiring } from "@sema-agent/core";
5
5
  import { createSessionTitler } from "./session-titler.js";
6
6
  import { posIntEnv } from "./session-watch.js";
7
7
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
@@ -13,7 +13,7 @@ import { drainNumEnvWarnings } from "./plugins/remote-shell.js";
13
13
  import { ensureChildSessionDurableWithPromotion } from "./plugins/session-store.js";
14
14
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
15
15
  import { webSearchConfigFromEnv, createWebSearchBackend, setWebSearchBadPayloadObserver, shouldProbeWebSearchOnBoot } from "./plugins/web-search.js";
16
- import { createAuthorizer } from "./security.js";
16
+ import { createAuthorizer, encodeCheckpointScope } from "./security.js";
17
17
  import { assertGateIntentServiceable, createDurableAskPolicy, hasOperatorGateIntent } from "./approval.js";
18
18
  import { loadSkills } from "./capabilities/skills.js";
19
19
  import { GiteaClient } from "./capabilities/repo-tools.js";
@@ -22,6 +22,7 @@ import { createLogger } from "./observability/logger.js";
22
22
  import { createToolTracer, createPermissionDeniedMeter } from "./observability/tool-trace.js";
23
23
  import { createRegistryJwtVerifier } from "./auth-bridge.js";
24
24
  import { createMetrics } from "./observability/metrics.js";
25
+ import { installFailOpenRecorder } from "./observability/fail-open.js";
25
26
  import { setRedactionObserver, redactSecrets } from "./trace/redact.js";
26
27
  import { RateLimiter } from "./observability/rate-limit.js";
27
28
  import { createHttpServer, explicitOperatorOk } from "./http/server.js";
@@ -49,6 +50,7 @@ import { createRunnerDeps, createSharedRunnerDeps } from "./boot/runner-deps.js"
49
50
  import { createOrgMemoryAdmissionWiring } from "./boot/org-memory.js";
50
51
  import { createSessionFaces } from "./boot/session-faces.js";
51
52
  import { createLeaderFace } from "./boot/leader.js";
53
+ import { assertStaticWiringConsistent } from "./http/routes/diagnostics.js";
52
54
  import { installShutdownHandlers } from "./boot/shutdown.js";
53
55
  /** Service version for GET /v1/capabilities — read from the package.json shipped next to src/ in the
54
56
  * image (Dockerfile COPYs it). Best-effort: an unreadable file reports "unknown", never crashes boot. */
@@ -105,6 +107,10 @@ async function main() {
105
107
  }
106
108
  const logger = createLogger(config.logLevel);
107
109
  const metrics = createMetrics();
110
+ // #157 fail-open 治理:把 logger/metrics 接到进程级 failOpen 记录器上(见 observability/fail-open.ts)。
111
+ // 排在这里而不是更晚:兜底臂遍布装配链,越早接上,越少的 fail-open 只剩探针行没有结构化痕迹。
112
+ // 装配前已累计的计数与已喊过的 tag 由 install 继承,不重置也不重喊。
113
+ installFailOpenRecorder({ logger, metrics });
108
114
  // S20 (SILENT-FALLBACK P1) + [792]④ boot notices: config parsing runs before the logger exists — drain both
109
115
  // pre-logger collectors via the shared helper (F10: run-local uses the same one, so entrypoints can't drift).
110
116
  logConfigDiagnostics(logger);
@@ -178,10 +184,10 @@ async function main() {
178
184
  // baseline, so a task-hooks fold (resolveSpec) shadows neither.
179
185
  const deploymentHooks = toolTracer ? composeHooks(createPermissionDeniedMeter(metrics), toolTracer) : createPermissionDeniedMeter(metrics);
180
186
  // design/158 A10:workflow 编排面搬到 src/boot/workflow-orchestration.ts(逐字;构造次序契约见该文件头注)。
181
- const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, workflowAgentRegistry, subagentSteerRegistry, } = createWorkflowOrchestration({ config, logger, metrics, localRoot, backend, getRunStore: () => runStore });
187
+ const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, workflowRecoverOpts, workflowAgentRegistry, subagentSteerRegistry, } = createWorkflowOrchestration({ config, logger, metrics, localRoot, backend, getRunStore: () => runStore });
182
188
  // design/158 A10:活体协调器 + SendUserFile 工具面搬到 src/boot/coordinators.ts(逐字;durableEnabled 的
183
189
  // 「必须早于 runnerDeps 求值」次序契约见该文件头注)。
184
- const { elicitation, question, toolApproval, durableEnabled, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({ config, logger, backend, sendUserFileTaskEnvs });
190
+ const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({ config, logger, backend, sendUserFileTaskEnvs });
185
191
  // design/158 A10:per-principal caps 段搬到 src/boot/runtime-caps.ts(逐字)。
186
192
  const { principalCaps, centerRuntimeCapsResolver, runtimeCapsResolver } = createRuntimeCaps({ config, logger });
187
193
  // design/170 件A(#148 件3③):org 记忆准入装配(目录源三态选择+C12 能力探测,坏配置在此拒启动)。
@@ -400,8 +406,15 @@ async function main() {
400
406
  // Finding #2 (edge case): the single-user auto-accept tool-policy BASELINE applies ONLY when the operator
401
407
  // expressed NO gate intent. If they SET approval flags (APPROVAL_REQUIRE/APPROVAL_DENY/DURABLE_APPROVAL) but no
402
408
  // backing store is wired (e.g. DB_BACKEND=local → no checkpointStore ⇒ durableEnabled
403
- // false), that is a MISCONFIG — do NOT mask it with allow-all; leave toolPolicy undefined so core's UNGATED warning
404
- // still surfaces it. So the baseline needs single-user turnkey ∧ zero expressed gate intent.
409
+ // false), that is a MISCONFIG — do NOT mask it with allow-all; leave toolPolicy undefined. So the baseline needs
410
+ // single-user turnkey ∧ zero expressed gate intent.
411
+ // 🔴 #177 后本段的**信号面**已换人(原文「so core's UNGATED warning still surfaces it」已过期):守卫集
412
+ // (SENSITIVE_WRITE_PATTERNS,缺省非空)现在从 governance 拍无条件铸一条 DENY 基线 ⇒ 缺省部署的
413
+ // `spec.toolPolicy` 恒在场 ⇒ core 的 `hasEffectAwareGate`(prepare-task,判据是 policyLayers.length>0)
414
+ // 恒为真,那条 UNGATED onError 不再触发。补偿件在 boot/resolve-spec.ts:同一形(durable 门关 ∧ 单用户
415
+ // 基线不适用 ∧ 守卫集开)在 boot 期发一次 `tool_policy_only_sensitive_baseline` warn,把「守卫段之外
416
+ // 无裁决」这件事按我们自己的话说出来。本行留 `undefined` 仍是对的(不拿 allow-all 掩盖 misconfig),
417
+ // 只是它不再是那条信号的产地。
405
418
  // 单源在 approval.ts(与 inv#2 同文件)—— 这里原是内联的三格枚举,漏了 approvalNeverAuto,详见该函数顶注。
406
419
  const operatorGateIntent = hasOperatorGateIntent(config);
407
420
  // D3-F1(全窗复审,HIGH):门意图已表达但唯一门形(durable)不可服务 ⇒ boot 拒启带双出路指引。
@@ -657,7 +670,7 @@ async function main() {
657
670
  const reaper = startReapers({
658
671
  config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore,
659
672
  rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap,
660
- workflowNotifyGate, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
673
+ workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
661
674
  backgroundAgentStore, mailboxStore, toolApproval,
662
675
  getRunDenySweep: () => runDenySweep,
663
676
  });
@@ -704,6 +717,30 @@ async function main() {
704
717
  // asyncRewake wake 管道 holder:server 启动时装 deliver(session→本副本活流 steer);createTaskHooks 的
705
718
  // ctx.wake 闭包经它投递(main 建壳/server 填实,双向不循环依赖)。
706
719
  const hookWakeBus = {};
720
+ // ═══════════ #154 件二:启动期静态装配自检(coordinators 构造后、listen 之前)═══════════
721
+ // core 的 `describeStaticWiring` 与本仓的装配谓词是**两个独立实现**在回答同一组问题。答案矛盾 =
722
+ // 装配谎言(不是「配置不理想」:是服务端对自己接了什么线有两套互斥说法),fail-loud 拒启 ——
723
+ // 与 memory+durable 两条既有拒启同族。判据本体在 http/routes/diagnostics.ts(诊断读面与拒启判据同源)。
724
+ //
725
+ // specTemplate = resolve-spec 对**每一条**任务腿都会盖的那两个 durable 键(它的 durableEnabled 分支)。
726
+ // 静态半场只读它们的**在场性**(`parkDurableApprovalOptIn = spec.durableApproval !== undefined`),
727
+ // 所以这里给的是那个形,而不是某条腿的 principal 真值。
728
+ const wiringSpecTemplate = durableEnabled
729
+ ? { checkpointStore, durableApproval: { scope: encodeCheckpointScope(undefined) } }
730
+ : {};
731
+ const staticWiring = describeStaticWiring(runnerDeps, wiringSpecTemplate);
732
+ // `parkFacility` 传的必须是**协调器真喂给 `resolveStreamApprovalGate` 的那个实参**(durableEnabled),
733
+ // 不是在这里重算一遍 —— 重算会把「两个站点算出了不同答案」这件正要被查的事抹平。
734
+ assertStaticWiringConsistent({ manifest: staticWiring, parkFacility: durableEnabled, backendKind: backend?.kind }, logger);
735
+ // operator 日志面(**非 wire**)。`configFingerprint` 只有 effective 半场才铸(core 按腿算),静态半场
736
+ // 没有这一位 ⇒ 诚实报 null,不自造一个服务端哈希(那会是一个要跨仓对表的新语义)。
737
+ logger.info("wiring_static", {
738
+ fingerprint: staticWiring.configFingerprint ?? null,
739
+ parkEffective: staticWiring.parkLane.effective,
740
+ sessionDurability: staticWiring.session.store,
741
+ posture: staticWiring.interaction.posture,
742
+ streamApproval: streamApprovalGate.active ? "active" : streamApprovalGate.reason,
743
+ });
707
744
  // ═══════════ design/158 A8:ServiceDeps 分组装配(七组窄接口)═══════════
708
745
  // 每组一个**带类型标注**的中间 const —— 标注位触发 TS 多余属性检查,键放错组当场编译红。
709
746
  // (平铺形做不到:69 键的全可选面里任何键名都「属于」它,于是键落错家/从未接线只能靠源码钉
@@ -780,6 +817,10 @@ async function main() {
780
817
  memorySync: memoryExportBackend && memorySyncCursors
781
818
  ? (scope, syncReq) => performMemorySync(memoryExportBackend, memorySyncCursors, scope, syncReq)
782
819
  : undefined,
820
+ // design/170 件A §7 收编:memory-policy 面的 `org:` 属主门与 core 准入 seam 读**同一个**目录实例
821
+ // (`createOrgMemoryAdmissionWiring` 的产物),两面因此共享 TTL 缓存/退避窗/gen 高水位 —— 一个进程
822
+ // 对「谁属于 org:acme」只有一个答案。缺席(无 center 且无 env 表)⇒ 策略面 `org:` 键仍 operator-only。
823
+ orgMemoryDirectory: orgMemoryAdmission.directory,
783
824
  // sessionMirror 观测面(server 非执法端,论证在 ServiceDeps.sessionMirrorRuling):
784
825
  // 与 executionRuling 同车同缓存(零额外 center RTT);无 center/dry-run ⇒ 不接线,观测面暗、零行为差。
785
826
  sessionMirrorRuling: principalCaps
@@ -823,6 +864,7 @@ async function main() {
823
864
  modelReady: () => configCenter.modelReady(), // boot ready 门:roster 未落地=计费提交 503+health ready:false
824
865
  scenarioDetails, // 场景详情只读面(内建+center 同判定源)
825
866
  workflowsCapable, // ① core split: capabilities.workflows = engine-can (workflowsCapability); workflowsList = store (below)
867
+ staticWiring, // #154 件三:GET /v1/diagnostics/wiring 的 static 半场(与上面的拒启自检同一份产物)
826
868
  instanceId,
827
869
  // Capability discovery: the deployment facts only main knows. Store-derived booleans
828
870
  // (asyncRuns/artifacts/approvals/leader) are computed inside the server from the deps themselves.
@@ -997,8 +1039,9 @@ async function main() {
997
1039
  // blocked on it (recovery only re-fires completion notifications — independent of serving new requests). A
998
1040
  // sweep throw is logged, never fatal.
999
1041
  if (workflowNotifyGate) {
1042
+ // [2995]/[2999] opts 单点合成在 workflow-orchestration(可见性回灌 + replica-local 形 boot 孤儿判死)。
1000
1043
  void workflowNotifyGate
1001
- .recover({ orphanGraceMs: config.workflowOrphanGraceMs })
1044
+ .recover(workflowRecoverOpts)
1002
1045
  .then((tally) => logger.info("workflow_notify_recovered", tally))
1003
1046
  .catch((err) => logger.warn("workflow_notify_recover_failed", { err: String(err) }));
1004
1047
  }
@@ -0,0 +1,109 @@
1
+ import type { Logger } from "./logger.js";
2
+ import type { Metrics } from "./metrics.js";
3
+ /** 登记项:`cls` 是判据表的分类(见模块头——只有这两类允许走本封装),`note` 说清"放行的最坏后果"。 */
4
+ export interface FailOpenTagEntry {
5
+ /** `F`=纯体验/缓存回退,放行最坏后果只是"难看/不够好";`P-DEBT`=保护型欠账,方向明知不对、显式记债。 */
6
+ readonly cls: "F" | "P-DEBT";
7
+ /** 这条兜底放行了什么、最坏后果是什么。写给未来读遥测的人,不写来历。 */
8
+ readonly note: string;
9
+ }
10
+ /** 闭集 tag 词表。形=`<repo>.<domain>.<site>`(跨仓同形,便于三仓遥测并表)。 */
11
+ export declare const FAIL_OPEN_TAGS: {
12
+ readonly "server.hitl.frame-undelivered-stream-closed": {
13
+ readonly cls: "P-DEBT";
14
+ readonly note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。";
15
+ };
16
+ readonly "server.stream.frame-dropped-stream-closed": {
17
+ readonly cls: "F";
18
+ readonly note: "非 HITL-open 的运行流帧写向已断连接 ⇒ 丢弃。连接都没了,这一帧本就无人可看;单独立项是为了不让它挤进上面那条保护型计数。";
19
+ };
20
+ readonly "server.question.open-frame-undelivered": {
21
+ readonly cls: "P-DEBT";
22
+ readonly note: "AskUserQuestion 的 open 帧投递失败 ⇒ 该问按 unavailable 结算(#166 前是合成空答)。⚠️ 本臂**恒在门判 allow 之后**(工具已在执行),core 已过挂起点 ⇒ 落点**不是 park**:普通腿发 declined_unavailable 自答续跑卡,赎回既有审批的腿返 isError(engine 拒绝自答)。也就是说这一形**不铸 checkpoint、不可恢复**,人在环这道门被一次投递故障真的跳过了(门判 allow 之前就不可达的那些腿走的是 durable park,不经本臂)。「绝不把 run 挂死」是刻意的产品姿态,但债要在遥测里显形——记债,不当合法兜底。";
23
+ };
24
+ readonly "server.elicitation.open-frame-undelivered": {
25
+ readonly cls: "F";
26
+ readonly note: "MCP elicitation 的 open 帧投递失败 ⇒ 结算 decline。方向本身是 fail-closed(问不到人就是拒),缺的只是留痕。";
27
+ };
28
+ readonly "server.question.complete-breadcrumb-dropped": {
29
+ readonly cls: "F";
30
+ readonly note: "question_complete 面包屑(对话框消解提示)持久化失败。答案本身走的是另一条路且早已返回,丢的只是壳里一次收尾渲染。";
31
+ };
32
+ readonly "server.elicitation.complete-breadcrumb-dropped": {
33
+ readonly cls: "F";
34
+ readonly note: "elicitation_complete 面包屑持久化失败。同上——答案已经在壳手里,丢的是收尾渲染。";
35
+ };
36
+ readonly "server.prompts.artifact-cache-write-failed": {
37
+ readonly cls: "F";
38
+ readonly note: "已验签的提示词工件回填本地缓存失败。工件本身已返回给调用方;代价是下次同 digest 还要再取一次网络。";
39
+ };
40
+ readonly "server.fleet.subscriber-callback-threw": {
41
+ readonly cls: "F";
42
+ readonly note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。";
43
+ };
44
+ };
45
+ /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
46
+ export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
47
+ /**
48
+ * 断流丢帧该记哪个 tag —— **按帧型分类**,纯函数(与写流的那条闭包解耦,才单测得动)。
49
+ *
50
+ * 一刀切记同一个 tag 是错的:承载 HITL 帧的那条闭包同时驮着完成面包屑与其它运行流帧,于是**一次**
51
+ * 断连会让保护型计数涨两次(open 一次、complete 又一次),真正的 open 失败率被面包屑的假阳性盖住。
52
+ * 只有 open 帧是"人在环这道门被跳过"的证据,其余都是体验损失。
53
+ *
54
+ * ⚠️ **两条 question 臂在生产上已不可达**(#173 之后,2026-08-07):本函数唯一的生产调用点是
55
+ * `routes/tasks.ts` 的 `emitAsk` 断流臂,而 question 与 question_complete 两型帧都先过同文件
56
+ * `emitQuestion` 的断流守卫(两道守卫同步相邻、其间无 await)并在断流时 throw,永不抵达这里;另一条
57
+ * question 路(`runs.ts` 的 durable append)根本不经 emitAsk。在产的只剩 elicitation 族与 default。
58
+ * 两臂**保留不删**:它们是本映射的语义定义(单测直打的纯函数),elicit 侧若哪天照 question 收紧、或
59
+ * 新增第四条 emit 腿忘了守卫,留着的臂是正确落点而不是死码。读遥测时按上面这段判在产分布。
60
+ */
61
+ export declare function failOpenTagForDroppedFrame(frameType: string): FailOpenTag;
62
+ /** {@link createFailOpenRecorder} 返回的活对象(有行为、有状态 ⇒ `create*` 而非 `build*`)。 */
63
+ export interface FailOpenRecorder {
64
+ /** 走到一条登记过的兜底臂。总不抛——观测本身绝不能变成故障(见 reapers 同族判据)。 */
65
+ record(tag: FailOpenTag, detail?: string): void;
66
+ /** 本进程各 tag 的累计次数(逐次计数,与一次性 warn 无关)。 */
67
+ counts(): ReadonlyMap<FailOpenTag, number>;
68
+ /** 已经喊过 stderr 契约行的 tag 集合——装配时由新实例继承,避免 install 让同一 tag 再喊一次。 */
69
+ warnedTags(): ReadonlySet<FailOpenTag>;
70
+ /** 还没进过任何 metrics 汇的次数(装配前命中的欠账)。install 用它补账,补完清零。 */
71
+ metricsBacklog(): ReadonlyMap<FailOpenTag, number>;
72
+ /** 已经进过结构化日志的 tag 集合。与 {@link warnedTags} **分开**:stderr 契约行装配前就能喊,
73
+ * 结构化 warn 却要等 logger 到场——两者共用一个集合会让装配前命中的 tag 永远拿不到结构化痕迹。 */
74
+ loggedTags(): ReadonlySet<FailOpenTag>;
75
+ }
76
+ /** 探针文件的 env 名(跨仓同名同形:`audits/failopen-governance-176.md` §2-r2,壳/引擎同认)。 */
77
+ export declare const FAIL_OPEN_PROBE_ENV = "SEMA_FAILOPEN_PROBE";
78
+ export interface FailOpenRecorderDeps {
79
+ logger?: Logger;
80
+ metrics?: Metrics;
81
+ /** 探针文件路径。缺省=读 `SEMA_FAILOPEN_PROBE`(读一次,不每次调用都碰 env)。 */
82
+ probePath?: string;
83
+ /** 继承自上一个实例的状态(install 换实例时不重置计数、不重喊 warn、不丢欠账)。 */
84
+ seedCounts?: ReadonlyMap<FailOpenTag, number>;
85
+ seedWarned?: ReadonlySet<FailOpenTag>;
86
+ seedBacklog?: ReadonlyMap<FailOpenTag, number>;
87
+ seedLogged?: ReadonlySet<FailOpenTag>;
88
+ /** 测试注入:替掉 stderr 契约行的落点。缺省直写 `process.stderr`。 */
89
+ writeStderr?: (line: string) => void;
90
+ /** 测试注入:替掉探针文件追加。缺省 `appendFileSync`(同步=进程猝死也不丢已记的行)。 */
91
+ appendProbe?: (path: string, line: string) => void;
92
+ }
93
+ export declare function createFailOpenRecorder(deps?: FailOpenRecorderDeps): FailOpenRecorder;
94
+ /**
95
+ * 装配层接线:把 logger/metrics 接到进程单例上。搬四份状态过去,各有各的理由:
96
+ * - `seedCounts` —— 逻辑总数续算,不从 0 重来;
97
+ * - `seedWarned` —— boot 期已喊过的 stderr 契约行不再喊(「每进程每 tag 一次」是它的全部价值);
98
+ * - `seedBacklog` —— 装配**前**命中的次数还没进任何 metrics 汇,交给新实例补账(补完清零,
99
+ * 所以第二次 install 不会重放);
100
+ * - `seedLogged` —— 已真发出去的结构化 warn 不重发;没发过的由新 logger 补一条。
101
+ */
102
+ export declare function installFailOpenRecorder(deps: Omit<FailOpenRecorderDeps, "seedCounts" | "seedWarned" | "seedBacklog" | "seedLogged">): FailOpenRecorder;
103
+ /** 兜底臂的调用口。`tag` 必须已登记(闭集类型),`detail` 是可选的一行现场(路径/原因,不放密钥)。 */
104
+ export declare function recordFailOpen(tag: FailOpenTag, detail?: string): void;
105
+ /** 本进程各 tag 累计次数——`/metrics` 之外的进程内读数(测试与自检面用)。 */
106
+ export declare function failOpenCounts(): ReadonlyMap<FailOpenTag, number>;
107
+ /** test-only:换一个全新单例(计数与 warn 集合清零),让每个用例都从"第一次"看起。 */
108
+ export declare function resetFailOpenRecorderForTest(deps?: FailOpenRecorderDeps): FailOpenRecorder;
109
+ //# sourceMappingURL=fail-open.d.ts.map
@@ -0,0 +1,227 @@
1
+ /**
2
+ * #157 fail-open 治理专项 —— 兜底路径的**响亮化**工具(server 半场;跨仓方案见 sema-comms
3
+ * `audits/failopen-governance-176.md` §2,三仓语义对齐、实现各自)。
4
+ *
5
+ * ## 它解决的问题
6
+ * 「走了兜底」与「一切正常」在外部**同形**——成功回执一样、日志一样、指标一样。于是一个静默降级
7
+ * 能带病运行数周,只能靠"该发生的事没发生"这种间接症状发现。本模块给每条合法兜底臂一个**登记过的
8
+ * tag**,走到即留痕三件套:
9
+ *
10
+ * 1. **计数(逐次)** —— `fail_open_total{tag,cls}`;
11
+ * 2. **探针文件(逐次)** —— `SEMA_FAILOPEN_PROBE=<path>` 在场时逐次追加 `fail-open <tag> <detail>`。
12
+ * 逐次是承重的:同进程多腿时"第二腿起无行"会让「无行=没走兜底」的黑盒断言变成假绿;
13
+ * 3. **一次性 warn(每进程每 tag 一次)** —— 防刷屏。**只有这一件是一次性的**,计数与探针都逐次。
14
+ *
15
+ * ## 它**不**解决的问题(封装不是合法化通道)
16
+ * 词表的 `cls` 只有 `F` 与 `P-DEBT` 两种,**结构性**地把 P/Z 类挡在外面:
17
+ * - **P 保护型**(权限门/判定词表/凭证/车道选择):正解是 fail-closed,不是"留痕后继续放行"。
18
+ * 确实一时改不动的,才允许以 `P-DEBT` 登记——让债在遥测里显形,而不是让它消失。
19
+ * - **Z 计量型**(重试/预算/超时/清理范围):正解是归零 + 自己的节流告警
20
+ * (先例:`boot/reapers.ts` 的 `createThrottledReaperCatch`),不走本封装。
21
+ *
22
+ * ## 词表是闭集
23
+ * {@link FailOpenTag} 由 {@link FAIL_OPEN_TAGS} 的键推导,未登记的 tag 是**编译期错误**——
24
+ * "新 tag 须登记"因此是机器执法的,不靠自觉。新增 tag 同时要在 `docs/FAIL-OPEN-CENSUS.md` 补行。
25
+ *
26
+ * ## 进程级而非注入级
27
+ * 三件套里有两件(一次性 warn、进程计数)天然是**进程**范围的,且真正的兜底臂往往在没有 logger/
28
+ * metrics 句柄的深处(`question.ts` / `elicitation.ts` 全文零观测通道)。所以对外主口是自由函数
29
+ * {@link recordFailOpen},装配层用 {@link installFailOpenRecorder} 把 logger/metrics 接上即可;
30
+ * 装配之前的调用不会丢——探针与 stderr 契约行不依赖注入,计数也照常累加并在装配时继承过去。
31
+ */
32
+ import { appendFileSync } from "node:fs";
33
+ /** 闭集 tag 词表。形=`<repo>.<domain>.<site>`(跨仓同形,便于三仓遥测并表)。 */
34
+ export const FAIL_OPEN_TAGS = {
35
+ "server.hitl.frame-undelivered-stream-closed": {
36
+ cls: "P-DEBT",
37
+ note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。",
38
+ },
39
+ "server.stream.frame-dropped-stream-closed": {
40
+ cls: "F",
41
+ note: "非 HITL-open 的运行流帧写向已断连接 ⇒ 丢弃。连接都没了,这一帧本就无人可看;单独立项是为了不让它挤进上面那条保护型计数。",
42
+ },
43
+ "server.question.open-frame-undelivered": {
44
+ cls: "P-DEBT",
45
+ note: "AskUserQuestion 的 open 帧投递失败 ⇒ 该问按 unavailable 结算(#166 前是合成空答)。⚠️ 本臂**恒在门判 allow 之后**(工具已在执行),core 已过挂起点 ⇒ 落点**不是 park**:普通腿发 declined_unavailable 自答续跑卡,赎回既有审批的腿返 isError(engine 拒绝自答)。也就是说这一形**不铸 checkpoint、不可恢复**,人在环这道门被一次投递故障真的跳过了(门判 allow 之前就不可达的那些腿走的是 durable park,不经本臂)。「绝不把 run 挂死」是刻意的产品姿态,但债要在遥测里显形——记债,不当合法兜底。",
46
+ },
47
+ "server.elicitation.open-frame-undelivered": {
48
+ cls: "F",
49
+ note: "MCP elicitation 的 open 帧投递失败 ⇒ 结算 decline。方向本身是 fail-closed(问不到人就是拒),缺的只是留痕。",
50
+ },
51
+ "server.question.complete-breadcrumb-dropped": {
52
+ cls: "F",
53
+ note: "question_complete 面包屑(对话框消解提示)持久化失败。答案本身走的是另一条路且早已返回,丢的只是壳里一次收尾渲染。",
54
+ },
55
+ "server.elicitation.complete-breadcrumb-dropped": {
56
+ cls: "F",
57
+ note: "elicitation_complete 面包屑持久化失败。同上——答案已经在壳手里,丢的是收尾渲染。",
58
+ },
59
+ "server.prompts.artifact-cache-write-failed": {
60
+ cls: "F",
61
+ note: "已验签的提示词工件回填本地缓存失败。工件本身已返回给调用方;代价是下次同 digest 还要再取一次网络。",
62
+ },
63
+ "server.fleet.subscriber-callback-threw": {
64
+ cls: "F",
65
+ note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。",
66
+ },
67
+ };
68
+ /**
69
+ * 断流丢帧该记哪个 tag —— **按帧型分类**,纯函数(与写流的那条闭包解耦,才单测得动)。
70
+ *
71
+ * 一刀切记同一个 tag 是错的:承载 HITL 帧的那条闭包同时驮着完成面包屑与其它运行流帧,于是**一次**
72
+ * 断连会让保护型计数涨两次(open 一次、complete 又一次),真正的 open 失败率被面包屑的假阳性盖住。
73
+ * 只有 open 帧是"人在环这道门被跳过"的证据,其余都是体验损失。
74
+ *
75
+ * ⚠️ **两条 question 臂在生产上已不可达**(#173 之后,2026-08-07):本函数唯一的生产调用点是
76
+ * `routes/tasks.ts` 的 `emitAsk` 断流臂,而 question 与 question_complete 两型帧都先过同文件
77
+ * `emitQuestion` 的断流守卫(两道守卫同步相邻、其间无 await)并在断流时 throw,永不抵达这里;另一条
78
+ * question 路(`runs.ts` 的 durable append)根本不经 emitAsk。在产的只剩 elicitation 族与 default。
79
+ * 两臂**保留不删**:它们是本映射的语义定义(单测直打的纯函数),elicit 侧若哪天照 question 收紧、或
80
+ * 新增第四条 emit 腿忘了守卫,留着的臂是正确落点而不是死码。读遥测时按上面这段判在产分布。
81
+ */
82
+ export function failOpenTagForDroppedFrame(frameType) {
83
+ switch (frameType) {
84
+ case "question": // 见头注:#173 后生产不可达(emitQuestion 先 throw),保留为映射定义
85
+ case "elicitation":
86
+ return "server.hitl.frame-undelivered-stream-closed";
87
+ case "question_complete": // 同上,生产不可达
88
+ return "server.question.complete-breadcrumb-dropped";
89
+ case "elicitation_complete":
90
+ return "server.elicitation.complete-breadcrumb-dropped";
91
+ default:
92
+ return "server.stream.frame-dropped-stream-closed";
93
+ }
94
+ }
95
+ /** 探针文件的 env 名(跨仓同名同形:`audits/failopen-governance-176.md` §2-r2,壳/引擎同认)。 */
96
+ export const FAIL_OPEN_PROBE_ENV = "SEMA_FAILOPEN_PROBE";
97
+ /** 计数器名。`cls` 进标签:一眼分得出"合法兜底"与"保护型欠账"两种曲线,后者应当只降不升。 */
98
+ const FAIL_OPEN_COUNTER = "fail_open_total";
99
+ export function createFailOpenRecorder(deps = {}) {
100
+ const counts = new Map(deps.seedCounts ?? []);
101
+ const warned = new Set(deps.seedWarned ?? []);
102
+ const logged = new Set(deps.seedLogged ?? []);
103
+ const backlog = new Map(deps.seedBacklog ?? []);
104
+ const probePath = deps.probePath ?? process.env[FAIL_OPEN_PROBE_ENV];
105
+ const writeStderr = deps.writeStderr ?? ((line) => void process.stderr.write(line));
106
+ const appendProbe = deps.appendProbe ?? ((path, line) => appendFileSync(path, line));
107
+ deps.metrics?.counter(FAIL_OPEN_COUNTER, "fallback (fail-open) arms traversed, by registered tag — see docs/FAIL-OPEN-CENSUS.md");
108
+ // 补账:装配前(无 metrics/无 logger)命中的那几次不能凭空消失——否则 `fail_open_total` 永久
109
+ // 少算,少的还正是 boot 期兜底。metrics 到场即把欠账一次性灌进去并清零(所以再 install 一次不会重放),
110
+ // 结构化 warn 同理按 tag 补一条。stderr 契约行**不补**:它在装配前就已经喊过了,一 tag 一次是它的全部价值。
111
+ // 🔴 记账**后于**投递(codex R2 复审 MEDIUM):先清 backlog / 先 `logged.add` 再投递,等于把一次
112
+ // 投递失败记成"已送达"——欠账从此无法重放,而它保的正是最需要补的那批 boot 期兜底。
113
+ if (deps.metrics !== undefined && backlog.size > 0) {
114
+ for (const [tag, n] of [...backlog]) {
115
+ try {
116
+ deps.metrics.inc(FAIL_OPEN_COUNTER, { tag, cls: FAIL_OPEN_TAGS[tag].cls }, n);
117
+ backlog.delete(tag); // 只有真进了汇才销账
118
+ }
119
+ catch {
120
+ /* 留在 backlog 里,下一次装配再补 */
121
+ }
122
+ }
123
+ }
124
+ // 补痕的起算集=**观测到的 tag 全集**(`counts`),不是"stderr 喊成功过的那批"(`warned`)——
125
+ // 后者在"装配前命中 + stderr 正断裂"这条组合路上恒空,那次兜底的结构化痕迹就永久没了(codex R3)。
126
+ if (deps.logger !== undefined) {
127
+ for (const tag of counts.keys()) {
128
+ if (logged.has(tag))
129
+ continue;
130
+ try {
131
+ deps.logger.warn("fail_open", { tag, cls: FAIL_OPEN_TAGS[tag].cls, note: FAIL_OPEN_TAGS[tag].note, backfilled: true });
132
+ logged.add(tag); // 同上:warn 真发出去才算已报
133
+ }
134
+ catch {
135
+ /* 不记 logged,下一次装配/下一次命中再补 */
136
+ }
137
+ }
138
+ }
139
+ return {
140
+ record: (tag, detail) => {
141
+ // 三件套逐件独立 try:一件的失败(坏路径的探针文件、抛错的 logger 传输)绝不能让**另外两件**
142
+ // 也丢,更不能让调用方的兜底臂变成抛错——那等于把"降级"换成"故障",正是本模块要消灭的方向。
143
+ const entry = FAIL_OPEN_TAGS[tag];
144
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
145
+ if (deps.metrics === undefined) {
146
+ backlog.set(tag, (backlog.get(tag) ?? 0) + 1); // 记欠账,等 install 补
147
+ }
148
+ else {
149
+ try {
150
+ deps.metrics.inc(FAIL_OPEN_COUNTER, { tag, cls: entry.cls });
151
+ }
152
+ catch {
153
+ // 汇在场但这一次没进去 ⇒ 同样记欠账(而不是蒸发),等下一次装配补。
154
+ backlog.set(tag, (backlog.get(tag) ?? 0) + 1);
155
+ }
156
+ }
157
+ if (probePath !== undefined && probePath !== "") {
158
+ try {
159
+ appendProbe(probePath, `fail-open ${tag}${detail !== undefined && detail !== "" ? ` ${detail}` : ""}\n`);
160
+ }
161
+ catch {
162
+ /* 探针路径不可写(只读盘/权限)⇒ 静默放弃,不牵连本次兜底 */
163
+ }
164
+ }
165
+ // 只有**真发出去**才记进 logged —— 没有 logger 时记了,会让日后 install 的补账把这条当成
166
+ // "已经报过"而跳过,那正是本轮复审要修的漏账形。
167
+ if (deps.logger !== undefined && !logged.has(tag)) {
168
+ try {
169
+ deps.logger.warn("fail_open", { tag, cls: entry.cls, note: entry.note, ...(detail !== undefined ? { detail } : {}) });
170
+ logged.add(tag); // 记账后于投递:没发出去就不算已报,下一次命中重试
171
+ }
172
+ catch {
173
+ /* 观测不得成为故障 */
174
+ }
175
+ }
176
+ if (warned.has(tag))
177
+ return;
178
+ try {
179
+ // 跨仓约定的黑盒锚,逐字 `[sema] fail-open: <tag>`。**不依赖** logger 是否装配上——
180
+ // 评测线的断言打的就是这一行,它在 boot 之前的兜底上也必须成立。
181
+ writeStderr(`[sema] fail-open: ${tag}\n`);
182
+ warned.add(tag); // 同上。stderr 永久断裂时代价=每次命中试写一次(有界),不是丢掉这条契约行
183
+ }
184
+ catch {
185
+ /* 断裂的 stderr(EPIPE 族)⇒ 丢这行,不丢进程 */
186
+ }
187
+ },
188
+ counts: () => counts,
189
+ warnedTags: () => warned,
190
+ metricsBacklog: () => backlog,
191
+ loggedTags: () => logged,
192
+ };
193
+ }
194
+ /** 进程单例。装配前也可用(探针+stderr 契约行照常,计数照常;只是没有结构化 warn/metric)。 */
195
+ let current = createFailOpenRecorder();
196
+ /**
197
+ * 装配层接线:把 logger/metrics 接到进程单例上。搬四份状态过去,各有各的理由:
198
+ * - `seedCounts` —— 逻辑总数续算,不从 0 重来;
199
+ * - `seedWarned` —— boot 期已喊过的 stderr 契约行不再喊(「每进程每 tag 一次」是它的全部价值);
200
+ * - `seedBacklog` —— 装配**前**命中的次数还没进任何 metrics 汇,交给新实例补账(补完清零,
201
+ * 所以第二次 install 不会重放);
202
+ * - `seedLogged` —— 已真发出去的结构化 warn 不重发;没发过的由新 logger 补一条。
203
+ */
204
+ export function installFailOpenRecorder(deps) {
205
+ current = createFailOpenRecorder({
206
+ ...deps,
207
+ seedCounts: current.counts(),
208
+ seedWarned: current.warnedTags(),
209
+ seedBacklog: current.metricsBacklog(),
210
+ seedLogged: current.loggedTags(),
211
+ });
212
+ return current;
213
+ }
214
+ /** 兜底臂的调用口。`tag` 必须已登记(闭集类型),`detail` 是可选的一行现场(路径/原因,不放密钥)。 */
215
+ export function recordFailOpen(tag, detail) {
216
+ current.record(tag, detail);
217
+ }
218
+ /** 本进程各 tag 累计次数——`/metrics` 之外的进程内读数(测试与自检面用)。 */
219
+ export function failOpenCounts() {
220
+ return current.counts();
221
+ }
222
+ /** test-only:换一个全新单例(计数与 warn 集合清零),让每个用例都从"第一次"看起。 */
223
+ export function resetFailOpenRecorderForTest(deps = {}) {
224
+ current = createFailOpenRecorder(deps);
225
+ return current;
226
+ }
227
+ //# sourceMappingURL=fail-open.js.map
@@ -31,6 +31,12 @@ export interface PromptManifestRecord {
31
31
  shapeDigest: string;
32
32
  wireSchemaDigest: string;
33
33
  }>;
34
+ /** core 5.15.0(#181):deferred 披露策略自证三键(诊断值,零内容)。缺席=core <5.15 或无 deferred。 */
35
+ toolDisclosure?: {
36
+ deferredTools: number;
37
+ strategy: string;
38
+ source: string;
39
+ };
34
40
  totalChars: number;
35
41
  }
36
42
  /** Whitelist one `prompt.assembled` trace event into a {@link PromptManifestRecord} (v1 face required,
@@ -61,6 +67,17 @@ export declare function promptManifestRecordOf(e: {
61
67
  shapeDigest: string;
62
68
  wireSchemaDigest: string;
63
69
  }>;
70
+ /** core 5.15.0(#181,[2872] 提货②):deferred 工具的披露策略自证——{deferredTools(数), strategy
71
+ * ("static"|"swap"), source(四值:`spec` / `env` / `default` / `degraded_no_direct_lane`)}。
72
+ * 诊断三键(数 + 两闭集词,零内容)。词表属主是 core(`core/trace.d.ts` 的 `source` 联合),本仓宽收
73
+ * ⇒ 词错不会红,所以照抄时务必对表:`degraded_no_direct_lane` = 直呼车道联锁把 `static` 请求收窄成
74
+ * `swap` 的那一格,正是运维最需要认出来的一格(本注上一版把它写成了不存在的 `api-default`,下游看板
75
+ * 照抄就会把真实的 degraded 读成未知词)。 */
76
+ toolDisclosure?: {
77
+ deferredTools: number;
78
+ strategy: string;
79
+ source: string;
80
+ };
64
81
  totalChars: number;
65
82
  }): PromptManifestRecord;
66
83
  /**
@@ -14,6 +14,14 @@ export function promptManifestRecordOf(e) {
14
14
  ...(Array.isArray(e.tools)
15
15
  ? { tools: e.tools.map((t) => ({ wireName: String(t.wireName), aliases: (Array.isArray(t.aliases) ? t.aliases : []).map(String), contractId: String(t.contractId), implementationRevision: String(t.implementationRevision), cardId: String(t.cardId), shapeDigest: String(t.shapeDigest), wireSchemaDigest: String(t.wireSchemaDigest) })) }
16
16
  : {}),
17
+ // toolDisclosure(core 5.15.0 #181,[2872] 提货②):三键逐挑——数值有限、两词字符串非空才铸;
18
+ // 畸形按缺席(不铸假值,与 sections/tools 的 additive 同规)。
19
+ ...(typeof e.toolDisclosure === "object" && e.toolDisclosure !== null &&
20
+ typeof e.toolDisclosure.deferredTools === "number" && Number.isFinite(e.toolDisclosure.deferredTools) &&
21
+ typeof e.toolDisclosure.strategy === "string" && e.toolDisclosure.strategy.length > 0 &&
22
+ typeof e.toolDisclosure.source === "string" && e.toolDisclosure.source.length > 0
23
+ ? { toolDisclosure: { deferredTools: e.toolDisclosure.deferredTools, strategy: e.toolDisclosure.strategy, source: e.toolDisclosure.source } }
24
+ : {}),
17
25
  totalChars: e.totalChars,
18
26
  };
19
27
  }
@@ -87,7 +87,21 @@ export declare class WorkflowNotifyGate {
87
87
  * recovery-sweep error. */
88
88
  onError?: (stage: "record" | "deliver" | "recover", runId: string, err: unknown) => void;
89
89
  });
90
+ /**
91
+ * The runIds whose journal entry THIS process recorded — the boot-orphan judgment's authoritative anchor
92
+ * (round-1 review, MEDIUM). A wall-clock cutoff alone is not sound: after a BACKWARD clock step the entries
93
+ * this process records land BELOW the boot cutoff, and the sweep would finalize runs whose in-process
94
+ * executor is alive and running (a false `failed` notify for a workflow that then keeps going — worse than
95
+ * the absent row it was fixing). Membership here is a fact about THIS incarnation, unforgeable by any clock.
96
+ * Retired on ack AND the moment a sweep observes the run terminal (the anchor only ever gates the `running`
97
+ * arm), so a delivery/ack outage cannot pile up entries for runs that are no longer executing.
98
+ */
99
+ private readonly recordedThisIncarnation;
90
100
  private now;
101
+ /** Record a journal entry AND remember that this incarnation is the one that recorded it (see the field). */
102
+ private recordPending;
103
+ /** Ack an entry + drop its incarnation mark (acked ⇒ never scanned again, so the mark has no further use). */
104
+ private ackDelivered;
91
105
  /**
92
106
  * Journal a STARTED run as pending-notify. Call this with the synchronous `runId` from `startWorkflow` /
93
107
  * `run_workflow` — BEFORE the workflow can reach terminal — so a crash mid-run still leaves a recoverable
@@ -129,14 +143,50 @@ export declare class WorkflowNotifyGate {
129
143
  * workflow died) + ack, instead of leaking the entry forever. This closes the exact crash topology SVC-1
130
144
  * exists for (a replica SIGKILLed mid-run).
131
145
  * - `running` and FRESH (within the grace window): genuinely in flight → leave pending (the owning process
132
- * delivers its terminal notify, or the next sweep catches it once it goes terminal or stale).
146
+ * delivers its terminal notify, or the next sweep catches it once it goes terminal or stale). The sweep
147
+ * publishes NOTHING here — see the single-writer invariant below.
133
148
  * - MISSING (reaped / never persisted): ack-as-abandoned so the journal doesn't chase a ghost forever.
134
149
  * `orphanGraceMs` MUST exceed the max expected workflow runtime (the run store has no cross-replica liveness
135
150
  * signal, so age is the only orphan proxy). Returns a tally. A per-entry throw is isolated so one bad entry
136
151
  * can't abort the sweep.
152
+ *
153
+ * BOTH abandoned arms (boot-orphan + stale-past-grace) flip the DURABLE row to `failed` FIRST (CAS on rev),
154
+ * so the run store, the fleet panel and the delivered notify give ONE answer — previously the notify said
155
+ * `failed` while `/workflows` kept saying `running` forever ([2999]: republishing without finalizing would
156
+ * have turned "panel empty" into "panel shows a row that never moves"). A lost CAS means the run moved under
157
+ * the sweep (e.g. its real terminal landed concurrently) — skip this pass; the entry stays pending and the
158
+ * next sweep handles the NEW state.
159
+ *
160
+ * 🔴 SINGLE-WRITER INVARIANT for the fleet row (round-1 review, two HIGH findings): the sweep only ever
161
+ * publishes a **TERMINAL** fleet frame (the flip above, via `publishTerminalFleetRow` → final frame + remove).
162
+ * It NEVER publishes a `running` row. Two reasons, both "a row we mint here can become one nobody can retire":
163
+ * 1. cross-replica (SQL journal): a pending `running` entry may belong to ANOTHER replica. Its terminal
164
+ * update lands on that replica's own (replica-local) fleet bus, and its ack removes the entry from the
165
+ * shared journal — so this replica would never see the run again and its minted row would sit `running`
166
+ * forever. (The terminal-redelivery arm deliberately doesn't publish either — it is a notify path.)
167
+ * 2. same-replica: `runStore.get` is a READ-TIME SNAPSHOT. If the live run commits its terminal (and the
168
+ * wrapper removes the row) between that read and the publish, a `running` republish resurrects a row
169
+ * that will never be removed again.
170
+ * A live row's ONE writer is this replica's `put`/`update` observation point ({@link JournalingWorkflowRunStore}),
171
+ * which by construction sees every transition including the terminal one.
137
172
  */
138
173
  recover(opts?: {
139
174
  orphanGraceMs?: number;
175
+ /** Replica-local (File/in-memory) journal ONLY: a pending entry recorded BEFORE this timestamp belongs to
176
+ * a previous incarnation of THIS process — workflow executors are in-process, so they died with it and a
177
+ * still-`running` run is a boot-orphan: finalize it NOW instead of leaving the user's "Waiting for
178
+ * workflow" pointed at a run that can never finish for the whole grace window ([2995] 顺带①/[3000]§二).
179
+ * NEVER pass this for a cross-replica (SQL) journal — there a pending running run may be genuinely alive
180
+ * on another replica, and age (`orphanGraceMs`) is the only sound orphan proxy.
181
+ * ⚠️ This wall-clock cutoff is a NECESSARY, not sufficient, condition — {@link recordedThisIncarnation}
182
+ * is the authoritative one (a clock rollback after boot would otherwise stamp THIS process's own new
183
+ * entries below the cutoff and finalize live runs). */
184
+ finalizeStartedBeforeMs?: number;
185
+ /** Retire a fleet row for a run this sweep just finalized ([2995]/[2999]): called ONLY with a TERMINAL run,
186
+ * so the {@link JournalingWorkflowRunStore} projection publishes the final frame and then removes the row —
187
+ * the store, the panel and the notify end up saying the same thing. Never called with a `running` run (see
188
+ * the single-writer invariant above). Late-bound because the wrapper is constructed after this gate. */
189
+ publishTerminalFleetRow?: (id: string, run: WorkflowRun) => void;
140
190
  }): Promise<{
141
191
  scanned: number;
142
192
  redelivered: number;
@@ -234,6 +284,12 @@ export declare class JournalingWorkflowRunStore implements WorkflowRunStore {
234
284
  /** Derive + publish the MF-Fleet workflow row from a WorkflowRun (doneCount/totalCount from agents, tokens from
235
285
  * stats); a terminal workflow LEAVES the fleet (publish terminal then remove), a running one stays/updates. */
236
286
  private publishFleet;
287
+ /** [2995] recovery projection seam: the same fleet projection as the put/update observation points, exposed so
288
+ * the recovery sweep can RETIRE a row for a run it just finalized (terminal input ⇒ final frame, then remove).
289
+ * Wire it as `recover`'s `publishTerminalFleetRow` hook — and only ever hand it a TERMINAL run: a `running`
290
+ * row published from outside the wrapper's own write path has no guaranteed retirement (see the invariant on
291
+ * {@link WorkflowNotifyGate.recover}). */
292
+ republishFleet(id: string, run: WorkflowRun): void;
237
293
  put(id: string, run: WorkflowRun): Promise<void>;
238
294
  update(id: string, scope: string, run: WorkflowRun, expect?: {
239
295
  rev: number;