@sema-agent/server 7.3.0 → 7.4.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 (98) hide show
  1. package/USAGE.md +20 -0
  2. package/dist/approval-reconciler.d.ts +12 -5
  3. package/dist/boot/coordinators.js +3 -1
  4. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  5. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  6. package/dist/boot/execution-env.js +11 -1
  7. package/dist/boot/lexical-path-env.d.ts +0 -4
  8. package/dist/boot/lexical-path-env.js +6 -34
  9. package/dist/boot/resolve-spec.js +50 -38
  10. package/dist/capabilities/center-prompts.js +4 -1
  11. package/dist/capabilities/oa-tools.d.ts +15 -0
  12. package/dist/capabilities/oa-tools.js +54 -0
  13. package/dist/config-types.d.ts +8 -1
  14. package/dist/config.js +9 -1
  15. package/dist/elicitation.js +5 -1
  16. package/dist/finance/cost-taxonomy.d.ts +34 -0
  17. package/dist/finance/cost-taxonomy.js +26 -0
  18. package/dist/hooks/hook-runner.js +32 -0
  19. package/dist/http/routes/diagnostics.d.ts +84 -0
  20. package/dist/http/routes/diagnostics.js +140 -0
  21. package/dist/http/routes/tasks.js +31 -10
  22. package/dist/http/server.d.ts +5 -0
  23. package/dist/http/server.js +6 -0
  24. package/dist/main.js +34 -3
  25. package/dist/observability/fail-open.d.ts +98 -0
  26. package/dist/observability/fail-open.js +216 -0
  27. package/dist/observability/prompt-manifest.d.ts +13 -0
  28. package/dist/observability/prompt-manifest.js +8 -0
  29. package/dist/plugins/approval-store-sql.d.ts +116 -0
  30. package/dist/plugins/approval-store-sql.js +151 -0
  31. package/dist/plugins/file-workflow-journal-store.d.ts +12 -0
  32. package/dist/plugins/file-workflow-journal-store.js +12 -0
  33. package/dist/plugins/pg-approval-store.d.ts +9 -0
  34. package/dist/plugins/pg-approval-store.js +9 -0
  35. package/dist/plugins/pg-breaker-state.d.ts +8 -0
  36. package/dist/plugins/pg-breaker-state.js +8 -0
  37. package/dist/plugins/pg-checkpoint-store.d.ts +10 -0
  38. package/dist/plugins/pg-checkpoint-store.js +10 -0
  39. package/dist/plugins/pg-file-snapshot-store.d.ts +8 -0
  40. package/dist/plugins/pg-file-snapshot-store.js +8 -0
  41. package/dist/plugins/pg-image-bake.d.ts +12 -0
  42. package/dist/plugins/pg-image-bake.js +11 -0
  43. package/dist/plugins/pg-image-index.d.ts +12 -0
  44. package/dist/plugins/pg-image-index.js +11 -0
  45. package/dist/plugins/pg-outcome-ledger.d.ts +12 -0
  46. package/dist/plugins/pg-outcome-ledger.js +11 -0
  47. package/dist/plugins/pg-resume-anchor-store.d.ts +7 -0
  48. package/dist/plugins/pg-resume-anchor-store.js +7 -0
  49. package/dist/plugins/pg-run-store.d.ts +9 -0
  50. package/dist/plugins/pg-run-store.js +9 -0
  51. package/dist/plugins/pg-session-policy-store.d.ts +7 -0
  52. package/dist/plugins/pg-session-policy-store.js +7 -0
  53. package/dist/plugins/pg-session-store.d.ts +12 -0
  54. package/dist/plugins/pg-session-store.js +12 -0
  55. package/dist/plugins/pg-tool-result-store.d.ts +9 -0
  56. package/dist/plugins/pg-tool-result-store.js +9 -0
  57. package/dist/plugins/pg-workflow-journal-store.d.ts +9 -0
  58. package/dist/plugins/pg-workflow-journal-store.js +9 -0
  59. package/dist/plugins/pg-workflow-run-store.d.ts +9 -0
  60. package/dist/plugins/pg-workflow-run-store.js +9 -0
  61. package/dist/plugins/tidb-approval-store.d.ts +8 -0
  62. package/dist/plugins/tidb-approval-store.js +8 -0
  63. package/dist/plugins/tidb-breaker-state.d.ts +7 -0
  64. package/dist/plugins/tidb-breaker-state.js +7 -0
  65. package/dist/plugins/tidb-checkpoint-store.d.ts +9 -0
  66. package/dist/plugins/tidb-checkpoint-store.js +9 -0
  67. package/dist/plugins/tidb-file-snapshot-store.d.ts +8 -0
  68. package/dist/plugins/tidb-file-snapshot-store.js +8 -0
  69. package/dist/plugins/tidb-image-bake.d.ts +12 -0
  70. package/dist/plugins/tidb-image-bake.js +11 -0
  71. package/dist/plugins/tidb-image-index.d.ts +12 -0
  72. package/dist/plugins/tidb-image-index.js +11 -0
  73. package/dist/plugins/tidb-outcome-ledger.d.ts +12 -0
  74. package/dist/plugins/tidb-outcome-ledger.js +12 -0
  75. package/dist/plugins/tidb-resume-anchor-store.d.ts +7 -0
  76. package/dist/plugins/tidb-resume-anchor-store.js +7 -0
  77. package/dist/plugins/tidb-run-store.d.ts +10 -0
  78. package/dist/plugins/tidb-run-store.js +9 -0
  79. package/dist/plugins/tidb-session-policy-store.d.ts +7 -0
  80. package/dist/plugins/tidb-session-policy-store.js +7 -0
  81. package/dist/plugins/tidb-tool-result-store.d.ts +8 -0
  82. package/dist/plugins/tidb-tool-result-store.js +10 -0
  83. package/dist/plugins/tidb-workflow-journal-store.d.ts +9 -0
  84. package/dist/plugins/tidb-workflow-journal-store.js +9 -0
  85. package/dist/plugins/tidb-workflow-run-store.d.ts +10 -0
  86. package/dist/plugins/tidb-workflow-run-store.js +10 -0
  87. package/dist/plugins/workflow-journal-limits.d.ts +12 -0
  88. package/dist/plugins/workflow-journal-limits.js +12 -0
  89. package/dist/question.d.ts +18 -14
  90. package/dist/question.js +83 -34
  91. package/dist/sema-registry.d.ts +41 -0
  92. package/dist/sema-registry.js +40 -0
  93. package/dist/task-settings.d.ts +13 -12
  94. package/dist/task-settings.js +6 -6
  95. package/dist/tool-approval.js +15 -7
  96. package/dist/trace/project.d.ts +36 -1
  97. package/dist/trace/project.js +55 -2
  98. package/package.json +3 -3
@@ -0,0 +1,140 @@
1
+ import { resolveStreamApprovalGate } from "../../tool-approval.js";
2
+ import { sendJson, sendError } from "../send.js";
3
+ import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
4
+ /** 纯数据产物(`build*`):三个门的当前读数。`streamApproval` 走**单一谓词**
5
+ * `resolveStreamApprovalGate` —— 与 `/v1/capabilities` 的 `streamApproval` 位、与回决口的 501 门同一个
6
+ * 符号,所以诊断页读到的与消费端撞到的行为不可能不同。 */
7
+ export function buildServerWiringGates(input) {
8
+ const gate = resolveStreamApprovalGate({
9
+ toolApprovalEnabled: input.toolApprovalEnabled,
10
+ streamApprovalEnabled: input.streamApprovalEnabled,
11
+ backend: input.backend,
12
+ parkFacility: input.checkpointStore !== undefined,
13
+ });
14
+ return {
15
+ streamApproval: gate.active ? "active" : gate.reason,
16
+ durableApproval: input.durableApproval,
17
+ checkpointStore: input.checkpointStore !== undefined,
18
+ };
19
+ }
20
+ /**
21
+ * 三项对表(设计稿件二)。判据全是**两个独立谓词对同一件事的答案**,矛盾即装配谎言 —— 装配谎言不是
22
+ * 「配置不理想」,是「服务端对自己接了什么线的两套说法互斥」,继续启动只会把矛盾带进运行期的每一条腿。
23
+ *
24
+ * ① park 设施:core 从 `spec.checkpointStore ?? deps.checkpointStore` 判 `parkLane.capable`,我方从
25
+ * `backend.checkpoint ∧ DURABLE_APPROVAL` 判 `parkFacility` 并据此决定流内审批协议上不上场。两者
26
+ * 不等 ⇒ 协议按「窗到期能 park」上场而引擎侧根本不 park(结局是 core fail-closed deny),或反之
27
+ * 白白关掉一个可用的协议。`capable` 真而 `effective` 不真同样致命:能力在、这条部署形上却不会真的
28
+ * park,窗到期依旧无降级目的地。
29
+ * ② 会话店持久性:manifest 声明 `declared_durable` 而后端是 local / 根本没有后端 ⇒ 「重启存活」的声明
30
+ * 没有介质兑现。这条与 memory+durable 两条既有拒启同族(声明与介质必须对得上)。
31
+ * ③ interaction posture 声明 `interactive` 却没有任何 question 投递面 ⇒ 只 **warn**:core 在 prepare 时
32
+ * 会按腿拒,boot 先一步把话说成人话。不拒启,因为部署形本身可能只是暂时没接活体面,而 core 侧已有
33
+ * 兜底门 —— 拒启会把一个可运行的部署挡在门外。
34
+ * ④ park 车道的持久性读数不可信时 ⇒ **warn**(理由与这条判据为什么不是拒启,见其行内注)。
35
+ *
36
+ * 判决分两级是有判据的:**拒启**留给「两个谓词对同一件事互斥」(其中一个必错,继续跑就是带着谎言服务);
37
+ * **警告**留给「读数不可信/声明不完整」(信号本身还不足以断定谁错,据此拒启会挡住正常部署)。
38
+ */
39
+ export function buildStaticWiringAudit(facts) {
40
+ const refusals = [];
41
+ const warnings = [];
42
+ const park = facts.manifest.parkLane;
43
+ if (park.capable !== facts.parkFacility) {
44
+ refusals.push(`park facility contradiction: the engine's static wiring says parkLane.capable=${park.capable} while this server assembled its approval gates with parkFacility=${facts.parkFacility}. ` +
45
+ `One of the two is wrong about the same checkpoint store — refusing to start rather than serving a worker whose approval lane can silently fail closed. ` +
46
+ `Check DURABLE_APPROVAL and the backend's checkpoint face (they are what both predicates read).`);
47
+ }
48
+ else if (facts.parkFacility && park.effective !== true) {
49
+ refusals.push(`park facility contradiction: this server assembled its approval gates with parkFacility=true, but the engine's static wiring resolves parkLane.effective=${JSON.stringify(park.effective)} (reasons: ${park.reasons.join(", ") || "none"}). ` +
50
+ `A window expiry would then have no degradation target and adjudicate as a fail-closed deny — refusing to start.`);
51
+ }
52
+ // 判据写成「不在持久集合里」而不是「是 local」是有方向的:将来多一种 backend kind 时,它会**拒启**
53
+ // 直到有人显式把它判进持久集,而不是静默通过 —— 声明与介质的对表,失败方向必须朝拒绝那边。
54
+ if (facts.manifest.session.store === "declared_durable" && facts.backendKind !== "mysql" && facts.backendKind !== "pg") {
55
+ refusals.push(`session store durability contradiction: the engine's static wiring reports session.store="declared_durable" while the store backend is ${facts.backendKind === undefined ? "absent (env-only worker)" : `"${facts.backendKind}"`}. ` +
56
+ `A durable declaration with no durable medium is a claim nothing can honor — refusing to start. Wire DB_BACKEND=mysql|pg, or fix the store's durability declaration.`);
57
+ }
58
+ // park 车道的**持久性读数**(codex 交叉复审 2026-08-07 R2-F2,验真后部分采纳)。`capable` 说的是
59
+ // 「有没有 checkpoint 店」、`effective` 说的是「这条部署形会不会真 park」—— 两者都为真时仍可能是一个
60
+ // 重启即丢的店,那样窗到期后被 park 的审批在重启后无法赎回,而流内审批协议正是拿 park 当降级目的地。
61
+ // 🔴 为什么是 **warn 而不是拒启**:本仓的 SQL/local checkpoint 店**都还没有 `durability` 声明**,core
62
+ // 因此对每一个真持久部署都读出 `process_local`。据此拒启会拿一个不可信信号挡住 100% 的现存 durable
63
+ // 部署 —— 那不是查出矛盾。根治在店侧补声明;在那之前,把「这个读数不可信」响亮说出来,不静默。
64
+ if (facts.parkFacility && facts.manifest.parkLane.checkpointDurability !== "declared_durable") {
65
+ warnings.push(`park facility is enabled but the checkpoint store does not declare durable storage ` +
66
+ `(parkLane.checkpointDurability=${JSON.stringify(facts.manifest.parkLane.checkpointDurability ?? null)}). ` +
67
+ `A parked approval survives a restart only on a durable store — and the in-tree checkpoint adapters currently declare no durability at all, so this reading cannot yet distinguish "really process-local" from "nobody declared it". ` +
68
+ `Treat park-lane recovery as unproven until the adapter declares its durability.`);
69
+ }
70
+ if (facts.manifest.interaction.posture === "interactive" && facts.manifest.question.wired !== "wired") {
71
+ warnings.push(`interactionPosture declares "interactive" but no question delivery face is wired (question.wired=${JSON.stringify(facts.manifest.question.wired)}) — ` +
72
+ `every leg that asks a question will be refused by the engine at prepare time. Wire the live question seam (ASK_QUESTION_ENABLED), or declare the headless posture.`);
73
+ }
74
+ return { refusals, warnings };
75
+ }
76
+ /**
77
+ * boot 消费口:`refusals` 非空即抛(拒启,与 memory+durable 两条既有拒启同族的 fail-loud 形),
78
+ * `warnings` 逐条 warn。返回判决本体,调用方可以继续把读数打进启动 info 行。
79
+ */
80
+ export function assertStaticWiringConsistent(facts, logger) {
81
+ const audit = buildStaticWiringAudit(facts);
82
+ for (const w of audit.warnings)
83
+ logger?.warn("wiring_static_warning", { detail: w });
84
+ if (audit.refusals.length > 0) {
85
+ throw new Error(`static wiring self-check failed — refusing to start:\n${audit.refusals.map((r) => ` · ${r}`).join("\n")}`);
86
+ }
87
+ return audit;
88
+ }
89
+ export async function handleDiagnostics(req, res, url, ctx) {
90
+ const miss = { fell: false };
91
+ await handleDiagnosticsBody(req, res, url, ctx, miss);
92
+ return !miss.fell;
93
+ }
94
+ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
95
+ const { deps } = ctx;
96
+ // 件三:「这台 worker 到底接了什么线」一屏看清 = core 的静态半场(含 `governance` 段 —— 本面**就是**
97
+ // 那个段的 operator 受众)+ server 自己三个装配谓词的读数。
98
+ //
99
+ // 门 = `explicitOperatorOk`(**不是** `isOperator`)。执法条件是**名单在场 ∨ 多租户**,不是只看
100
+ // `REQUIRE_PRINCIPAL`:本件的 SSE 腿对同一段 governance 恒判 `explicitOperatorOk`,若这里只在
101
+ // REQUIRE_PRINCIPAL 时判,「列了 operator 名单却没开 REQUIRE_PRINCIPAL」的部署上就成了流里关着、
102
+ // 端点上开着 —— 一个秘密两套门,严的那道白严(codex 交叉复审 2026-08-07 R2-F1,验真后采纳)。
103
+ // 名单为空 ∧ 非多租户 = 单租户 turnkey:该部署没有 operator 这个概念,唯一的用户就是 operator,
104
+ // 边界由全局 service-credential 门把守(与 `GET /v1/outcomes` 同裁)。
105
+ // 401 由更上游的全局门给出,本域只判 operator 与否。
106
+ //
107
+ // ⚠️ 与 SSE 腿的**残余不对称**(显式裁定,不是疏忽):turnkey 形上流里仍然谁都拿不到治理段,而本端点
108
+ // 给。理由=流是多订阅面且身份来自连接、端点是一次显式的 operator 请求;失败方向因此在流那侧更严。
109
+ //
110
+ // **不含** per-leg effective 历史:治理段只存在于 live 帧的 operator 投影里,durable 账本恒存剥后形
111
+ // (账本写时不知道未来读者是谁)。历史考古是另一件事,不在本面伪装成有。
112
+ if (req.method === "GET" && url === "/v1/diagnostics/wiring") {
113
+ if (deps.config.operatorPrincipals.length > 0 || deps.config.requirePrincipal) {
114
+ const principal = gatedPrincipal(req, deps.config);
115
+ if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
116
+ sendError(res, 403, "auth.operator_only", "wiring diagnostics are operator-only on a multi-tenant worker");
117
+ return;
118
+ }
119
+ }
120
+ // 静态半场是 **composition root 在 boot 算的**(与拒启自检同一份产物,不可能与它不同)。缺席 ⇒ 这个
121
+ // 进程不是由 main.ts 装起来的(测试夹具形),此面诚实地不存在 —— 落到全局 404,而不是回一个空壳 200。
122
+ if (!deps.staticWiring) {
123
+ miss.fell = true;
124
+ return;
125
+ }
126
+ sendJson(res, 200, {
127
+ static: deps.staticWiring,
128
+ serverGates: buildServerWiringGates({
129
+ toolApprovalEnabled: deps.toolApproval !== undefined,
130
+ streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
131
+ backend: deps.backend,
132
+ checkpointStore: deps.checkpointStore,
133
+ durableApproval: deps.config.durableApproval === true,
134
+ }),
135
+ });
136
+ return;
137
+ }
138
+ miss.fell = true;
139
+ }
140
+ //# sourceMappingURL=diagnostics.js.map
@@ -6,7 +6,7 @@ import { defaultSubagentTailBus, projectTailFrame } from "../../fleet/subagent-t
6
6
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../../orchestration/workflow-completion-inbox.js";
7
7
  import { createLedgerSink } from "../../trace/ledger-sink.js";
8
8
  import { redactSecrets } from "../../trace/redact.js";
9
- import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
9
+ import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, wiringManifestOperatorEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
10
10
  import { cascadeConfig, runMeta } from "../run-meta.js";
11
11
  import { scopedIdempotencyKey } from "../idempotency.js";
12
12
  import { sendJson, sendError, sseHeaders } from "../send.js";
@@ -14,7 +14,8 @@ import { collectSsePreamble } from "../sse-log.js";
14
14
  import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
15
15
  import { createApprovalCardEmitter, resolveApprovalLeg, resolveStreamApprovalGate } from "../../tool-approval.js";
16
16
  import { buildActiveRunConflict, toDoneFrameResult } from "../active-run-conflict.js";
17
- import { headerStr, gatedPrincipal } from "../principal-gate.js";
17
+ import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
18
+ import { failOpenTagForDroppedFrame, recordFailOpen } from "../../observability/fail-open.js";
18
19
  /** #135([C95]/[C96]):live 腿 SSE 数据帧的**唯一**出口 —— 载荷带账本可锚 `eventId`(非空 string)的帧
19
20
  * 前缀 `id: <eventId>` 行(SSE 游标=账本 eventId,壳/SDK 拿它锚进账本或断线换道 events tail 续读);
20
21
  * 无 eventId 的帧**不发行**,EventSource 沿用上一游标 —— 绝不合成占位 id(那会把不可锚的位置伪装成可锚)。
@@ -199,6 +200,11 @@ async function handleTasksBody(req, res, url, ctx, miss) {
199
200
  if (!res.writableEnded)
200
201
  onDisconnect();
201
202
  });
203
+ // #154 件一:本连接的 `wiring_manifest` 帧投哪一形。判据 = `explicitOperatorOk`(**不是** `isOperator`:
204
+ // 空 `OPERATOR_PRINCIPALS` 必须是「没有人是 operator」;把它读成「所有人都是」会让未配名单的部署把
205
+ // 治理面发给每一个订阅者)。**每连接求一次**、不逐帧求:身份在一条连接内不会变,而逐帧调用会让
206
+ // 每帧都重跑一遍凭证解析 —— 更重要的是,身份只在这一处判,帧循环里没有第二个可漂的判据。
207
+ const wiringOperatorFace = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
202
208
  // #151 车3 §5.1:sync 腿的**开流重放**——`sseHeaders` + meta 帧之后、`streamBody` 之前,把同一
203
209
  // (owner, session) 的未决审批卡投成 `approval_request` preamble 帧。
204
210
  //
@@ -751,12 +757,17 @@ async function handleTasksBody(req, res, url, ctx, miss) {
751
757
  sseData(res, { type: "compaction_outcome", ...compactionOutcomeEventData(ev) });
752
758
  }
753
759
  else if (ev.type === "wiring_manifest") {
754
- // 🔴 core 5.14.0 design/173 新臂 —— **止泄第一刀**。本臂到货当天,它落在下面那条裸
755
- // catch-all 上,于是 `manifest.governance`(core 盖了 `audience:"operator"` 的治理面在场
756
- // 段)被原样透传给任意租户订阅者;core 把失败方向写死成「no projection do not disclose」,
757
- // 所以在 operator 投影(#154)落地之前,唯一正确的服务端行为是**整段剥除**。
758
- // 共享白名单构造器,与两条 durable 腿同源(账本可经 events 重放读走 ⇒ 同一泄露面同一剥法)。
759
- sseData(res, { type: "wiring_manifest", ...wiringManifestEventData(ev) });
760
+ // 🔴 core 5.14.0 design/173 新臂。`manifest.governance` 盖着 `audience:"operator"`,core 把
761
+ // 失败方向写死成「no projection do not disclose」——所以默认(租户)形整段剥除,含**对该段
762
+ // 取的哈希** `configFingerprint`(无盐 + 4 布尔 = 16 组合谕示器)。
763
+ // #154 件一:分叉只在这条 live 腿(**唯一知道调用方身份的腿**),两条 durable 腿恒存剥后形
764
+ // ——账本写时不知道未来读者是谁,存全量 = operator-only 面写进租户可读介质。
765
+ sseData(res, {
766
+ type: "wiring_manifest",
767
+ ...(wiringOperatorFace
768
+ ? wiringManifestOperatorEventData(ev)
769
+ : wiringManifestEventData(ev)),
770
+ });
760
771
  }
761
772
  else if (ev.type === "human_input") {
762
773
  // core 5.14.0 design/171 新臂:谁把什么喂进了这条 run(本帧不带正文,只带载体/署名)。
@@ -803,8 +814,18 @@ async function handleTasksBody(req, res, url, ctx, miss) {
803
814
  const askTaskId = durableTaskId ?? uuidv7();
804
815
  const askOwner = gatedPrincipal(req, deps.config) ?? null;
805
816
  const emitAsk = (frame) => {
806
- if (!res.writableEnded)
807
- res.write(`event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`);
817
+ // #157(codex R2 复审 HIGH):流已断时这里是**静默 no-op** —— 上游 coordinator 的 try/catch
818
+ // 因此拿不到任何信号,一次断连就被记成"投递成功",问题挂到 TTL 才按无人应答结算。方向本批
819
+ // 不动(改成 throw 是既有的刻意决定,见下面 drain 包装处的注释),但**必须留痕**:否则
820
+ // `open-frame-undelivered` 只覆盖"持久化抛错",覆盖不到真正常见的那条路。
821
+ // `destroyed` 与 `writableEnded` 一起判——emitApproval 用的就是这对谓词。
822
+ // 分类按帧型走(`failOpenTagForDroppedFrame`):这条闭包同时驮着 open 帧、完成面包屑与
823
+ // 其它运行流帧,一刀切会让**一次**断连把保护型计数记两次,真 open 失败率被面包屑盖住。
824
+ if (res.writableEnded || res.destroyed) {
825
+ recordFailOpen(failOpenTagForDroppedFrame(frame.type), `frame=${frame.type}`);
826
+ return;
827
+ }
828
+ res.write(`event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`);
808
829
  };
809
830
  // P1 ①②: this new turn is a stream-open for the session — push any async-workflow completions that
810
831
  // finished since (frames ride the SAME connection, before the run's own events; the shell dedups by
@@ -413,6 +413,11 @@ export interface ServiceDeploymentDeps {
413
413
  ageMs: number;
414
414
  error?: string;
415
415
  } | undefined;
416
+ /** #154 件三:core `describeStaticWiring(deps, specTemplate)` 的 boot 产物 —— `GET /v1/diagnostics/wiring`
417
+ * 的 `static` 半场,**与启动期拒启自检读的是同一份**(诊断页与拒启判据结构性同源)。
418
+ * operator-only 面,故整份原样下发(含 `governance` 段:本面就是那个段的受众)。
419
+ * 缺席 = 这个进程不是由 composition root 装起来的(测试夹具形)⇒ 该路由诚实地不存在(404)。 */
420
+ staticWiring?: import("@sema-agent/core").WiringManifest;
416
421
  /** ① core ruling — SPLIT: `capabilities.workflows` = the ENGINE-CAN axis, boot-computed from core's own
417
422
  * `workflowsCapability(deps)` (hardened script runner ∧ governance), NOT the `Boolean(workflowRunStore)` store
418
423
  * proxy. Orthogonal to `workflowsList` (the durable-list axis = `workflowRunStore`). Falls back to the store
@@ -29,6 +29,7 @@ import { streamSseLog } from "./sse-log.js";
29
29
  import { clampVerifyRounds, verifyRoundsFromBody } from "./verify-rounds.js"; // design/97 R9: the shape's neutral leaf (route-ctx.ts type-imports VerifyRoundsSpec from here, not from this file)
30
30
  import { handleCapabilities } from "./routes/capabilities.js";
31
31
  import { handleObservability } from "./routes/observability.js";
32
+ import { handleDiagnostics } from "./routes/diagnostics.js";
32
33
  import { handleMemoryPolicy } from "./routes/memory-policy.js";
33
34
  import { handleSessionsList } from "./routes/sessions-list.js";
34
35
  import { handleSessions, createSessionsLocal } from "./routes/sessions.js";
@@ -591,6 +592,9 @@ export function createHttpServer(rawDeps) {
591
592
  // design/158 A9:观测只读面(routes/observability.ts):/v1/outcomes + /v1/usage。
592
593
  if (await handleObservability(req, res, url, ctx))
593
594
  return;
595
+ // #154 件三:装配自证只读面(routes/diagnostics.ts):GET /v1/diagnostics/wiring(operator-only)。
596
+ if (await handleDiagnostics(req, res, url, ctx))
597
+ return;
594
598
  // design/158 A9:memory 导出/同步 + /v1/policy 只读面(routes/memory-policy.ts)。
595
599
  if (await handleMemoryPolicy(req, res, url, ctx))
596
600
  return;
@@ -2753,6 +2757,8 @@ const ROUTE_LABEL_LITERALS = new Set([
2753
2757
  // 名册门的枚举器当时只认 `url === …`,整条路由对门隐形(枚举器已一并补上)。
2754
2758
  "/v1/fleet/stream",
2755
2759
  "/v1/memory/export", "/v1/sendfile-links",
2760
+ // #154:装配自证读面(operator-only)。低频但**每次都在排障现场被打**,落进 `other` 桶等于排障时看不见。
2761
+ "/v1/diagnostics/wiring",
2756
2762
  ]);
2757
2763
  /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
2758
2764
  * [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
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);
@@ -181,7 +187,7 @@ async function main() {
181
187
  const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, 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 能力探测,坏配置在此拒启动)。
@@ -704,6 +710,30 @@ async function main() {
704
710
  // asyncRewake wake 管道 holder:server 启动时装 deliver(session→本副本活流 steer);createTaskHooks 的
705
711
  // ctx.wake 闭包经它投递(main 建壳/server 填实,双向不循环依赖)。
706
712
  const hookWakeBus = {};
713
+ // ═══════════ #154 件二:启动期静态装配自检(coordinators 构造后、listen 之前)═══════════
714
+ // core 的 `describeStaticWiring` 与本仓的装配谓词是**两个独立实现**在回答同一组问题。答案矛盾 =
715
+ // 装配谎言(不是「配置不理想」:是服务端对自己接了什么线有两套互斥说法),fail-loud 拒启 ——
716
+ // 与 memory+durable 两条既有拒启同族。判据本体在 http/routes/diagnostics.ts(诊断读面与拒启判据同源)。
717
+ //
718
+ // specTemplate = resolve-spec 对**每一条**任务腿都会盖的那两个 durable 键(它的 durableEnabled 分支)。
719
+ // 静态半场只读它们的**在场性**(`parkDurableApprovalOptIn = spec.durableApproval !== undefined`),
720
+ // 所以这里给的是那个形,而不是某条腿的 principal 真值。
721
+ const wiringSpecTemplate = durableEnabled
722
+ ? { checkpointStore, durableApproval: { scope: encodeCheckpointScope(undefined) } }
723
+ : {};
724
+ const staticWiring = describeStaticWiring(runnerDeps, wiringSpecTemplate);
725
+ // `parkFacility` 传的必须是**协调器真喂给 `resolveStreamApprovalGate` 的那个实参**(durableEnabled),
726
+ // 不是在这里重算一遍 —— 重算会把「两个站点算出了不同答案」这件正要被查的事抹平。
727
+ assertStaticWiringConsistent({ manifest: staticWiring, parkFacility: durableEnabled, backendKind: backend?.kind }, logger);
728
+ // operator 日志面(**非 wire**)。`configFingerprint` 只有 effective 半场才铸(core 按腿算),静态半场
729
+ // 没有这一位 ⇒ 诚实报 null,不自造一个服务端哈希(那会是一个要跨仓对表的新语义)。
730
+ logger.info("wiring_static", {
731
+ fingerprint: staticWiring.configFingerprint ?? null,
732
+ parkEffective: staticWiring.parkLane.effective,
733
+ sessionDurability: staticWiring.session.store,
734
+ posture: staticWiring.interaction.posture,
735
+ streamApproval: streamApprovalGate.active ? "active" : streamApprovalGate.reason,
736
+ });
707
737
  // ═══════════ design/158 A8:ServiceDeps 分组装配(七组窄接口)═══════════
708
738
  // 每组一个**带类型标注**的中间 const —— 标注位触发 TS 多余属性检查,键放错组当场编译红。
709
739
  // (平铺形做不到:69 键的全可选面里任何键名都「属于」它,于是键落错家/从未接线只能靠源码钉
@@ -823,6 +853,7 @@ async function main() {
823
853
  modelReady: () => configCenter.modelReady(), // boot ready 门:roster 未落地=计费提交 503+health ready:false
824
854
  scenarioDetails, // 场景详情只读面(内建+center 同判定源)
825
855
  workflowsCapable, // ① core split: capabilities.workflows = engine-can (workflowsCapability); workflowsList = store (below)
856
+ staticWiring, // #154 件三:GET /v1/diagnostics/wiring 的 static 半场(与上面的拒启自检同一份产物)
826
857
  instanceId,
827
858
  // Capability discovery: the deployment facts only main knows. Store-derived booleans
828
859
  // (asyncRuns/artifacts/approvals/leader) are computed inside the server from the deps themselves.
@@ -0,0 +1,98 @@
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** 帧(question / elicitation)写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,问到 TTL 才按无人应答结算。方向(no-op 而非 throw)是既有的刻意决定,本条只保证它不再无声。";
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 帧投递失败 ⇒ 按 headless 默认结算,模型不问人自己往下判。方向是刻意的产品姿态(绝不把 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
+ };
41
+ /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
42
+ export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
43
+ /**
44
+ * 断流丢帧该记哪个 tag —— **按帧型分类**,纯函数(与写流的那条闭包解耦,才单测得动)。
45
+ *
46
+ * 一刀切记同一个 tag 是错的:承载 HITL 帧的那条闭包同时驮着完成面包屑与其它运行流帧,于是**一次**
47
+ * 断连会让保护型计数涨两次(open 一次、complete 又一次),真正的 open 失败率被面包屑的假阳性盖住。
48
+ * 只有 open 帧是"人在环这道门被跳过"的证据,其余都是体验损失。
49
+ */
50
+ export declare function failOpenTagForDroppedFrame(frameType: string): FailOpenTag;
51
+ /** {@link createFailOpenRecorder} 返回的活对象(有行为、有状态 ⇒ `create*` 而非 `build*`)。 */
52
+ export interface FailOpenRecorder {
53
+ /** 走到一条登记过的兜底臂。总不抛——观测本身绝不能变成故障(见 reapers 同族判据)。 */
54
+ record(tag: FailOpenTag, detail?: string): void;
55
+ /** 本进程各 tag 的累计次数(逐次计数,与一次性 warn 无关)。 */
56
+ counts(): ReadonlyMap<FailOpenTag, number>;
57
+ /** 已经喊过 stderr 契约行的 tag 集合——装配时由新实例继承,避免 install 让同一 tag 再喊一次。 */
58
+ warnedTags(): ReadonlySet<FailOpenTag>;
59
+ /** 还没进过任何 metrics 汇的次数(装配前命中的欠账)。install 用它补账,补完清零。 */
60
+ metricsBacklog(): ReadonlyMap<FailOpenTag, number>;
61
+ /** 已经进过结构化日志的 tag 集合。与 {@link warnedTags} **分开**:stderr 契约行装配前就能喊,
62
+ * 结构化 warn 却要等 logger 到场——两者共用一个集合会让装配前命中的 tag 永远拿不到结构化痕迹。 */
63
+ loggedTags(): ReadonlySet<FailOpenTag>;
64
+ }
65
+ /** 探针文件的 env 名(跨仓同名同形:`audits/failopen-governance-176.md` §2-r2,壳/引擎同认)。 */
66
+ export declare const FAIL_OPEN_PROBE_ENV = "SEMA_FAILOPEN_PROBE";
67
+ export interface FailOpenRecorderDeps {
68
+ logger?: Logger;
69
+ metrics?: Metrics;
70
+ /** 探针文件路径。缺省=读 `SEMA_FAILOPEN_PROBE`(读一次,不每次调用都碰 env)。 */
71
+ probePath?: string;
72
+ /** 继承自上一个实例的状态(install 换实例时不重置计数、不重喊 warn、不丢欠账)。 */
73
+ seedCounts?: ReadonlyMap<FailOpenTag, number>;
74
+ seedWarned?: ReadonlySet<FailOpenTag>;
75
+ seedBacklog?: ReadonlyMap<FailOpenTag, number>;
76
+ seedLogged?: ReadonlySet<FailOpenTag>;
77
+ /** 测试注入:替掉 stderr 契约行的落点。缺省直写 `process.stderr`。 */
78
+ writeStderr?: (line: string) => void;
79
+ /** 测试注入:替掉探针文件追加。缺省 `appendFileSync`(同步=进程猝死也不丢已记的行)。 */
80
+ appendProbe?: (path: string, line: string) => void;
81
+ }
82
+ export declare function createFailOpenRecorder(deps?: FailOpenRecorderDeps): FailOpenRecorder;
83
+ /**
84
+ * 装配层接线:把 logger/metrics 接到进程单例上。搬四份状态过去,各有各的理由:
85
+ * - `seedCounts` —— 逻辑总数续算,不从 0 重来;
86
+ * - `seedWarned` —— boot 期已喊过的 stderr 契约行不再喊(「每进程每 tag 一次」是它的全部价值);
87
+ * - `seedBacklog` —— 装配**前**命中的次数还没进任何 metrics 汇,交给新实例补账(补完清零,
88
+ * 所以第二次 install 不会重放);
89
+ * - `seedLogged` —— 已真发出去的结构化 warn 不重发;没发过的由新 logger 补一条。
90
+ */
91
+ export declare function installFailOpenRecorder(deps: Omit<FailOpenRecorderDeps, "seedCounts" | "seedWarned" | "seedBacklog" | "seedLogged">): FailOpenRecorder;
92
+ /** 兜底臂的调用口。`tag` 必须已登记(闭集类型),`detail` 是可选的一行现场(路径/原因,不放密钥)。 */
93
+ export declare function recordFailOpen(tag: FailOpenTag, detail?: string): void;
94
+ /** 本进程各 tag 累计次数——`/metrics` 之外的进程内读数(测试与自检面用)。 */
95
+ export declare function failOpenCounts(): ReadonlyMap<FailOpenTag, number>;
96
+ /** test-only:换一个全新单例(计数与 warn 集合清零),让每个用例都从"第一次"看起。 */
97
+ export declare function resetFailOpenRecorderForTest(deps?: FailOpenRecorderDeps): FailOpenRecorder;
98
+ //# sourceMappingURL=fail-open.d.ts.map