@sema-agent/server 7.12.0 → 7.14.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.
- package/USAGE.md +51 -2
- package/dist/adoption/plan.d.ts +38 -4
- package/dist/adoption/plan.js +72 -0
- package/dist/adoption/quiesce.d.ts +70 -0
- package/dist/adoption/quiesce.js +148 -0
- package/dist/adoption/runner.js +63 -5
- package/dist/adoption/sql.d.ts +15 -0
- package/dist/adoption/sql.js +18 -0
- package/dist/adoption/wire.d.ts +7 -1
- package/dist/adoption/wire.js +6 -0
- package/dist/approval-card.d.ts +5 -0
- package/dist/approval-card.js +22 -0
- package/dist/boot/coordinators.js +2 -1
- package/dist/boot/memory-boundary.d.ts +84 -0
- package/dist/boot/memory-boundary.js +110 -0
- package/dist/boot/permission-rules-audit.js +29 -1
- package/dist/boot/reapers.d.ts +15 -0
- package/dist/boot/reapers.js +101 -44
- package/dist/boot/resolve-spec.js +31 -0
- package/dist/boot/runner-deps.d.ts +16 -2
- package/dist/boot/runner-deps.js +24 -4
- package/dist/boot/stores.js +93 -3
- package/dist/capabilities/memory-notice.d.ts +83 -0
- package/dist/capabilities/memory-notice.js +90 -0
- package/dist/config-types.d.ts +100 -11
- package/dist/config.d.ts +1 -1
- package/dist/config.js +111 -1
- package/dist/governance-ask-marks.js +2 -1
- package/dist/http/active-run-conflict.d.ts +33 -8
- package/dist/http/active-run-conflict.js +37 -2
- package/dist/http/routes/adoption.js +25 -2
- package/dist/http/routes/approvals-assistant.js +33 -3
- package/dist/http/routes/capabilities.js +35 -5
- package/dist/http/routes/images.js +18 -0
- package/dist/http/routes/runs.js +21 -5
- package/dist/http/routes/tasks.js +18 -6
- package/dist/http/routes/trace-usage.js +43 -14
- package/dist/http/server.d.ts +35 -9
- package/dist/http/server.js +111 -17
- package/dist/http/wire-types.d.ts +6 -1
- package/dist/main.js +46 -6
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/plugins/adoption-log-sql.d.ts +40 -0
- package/dist/plugins/adoption-log-sql.js +69 -2
- package/dist/plugins/approval-ask-store-sql.d.ts +2 -1
- package/dist/plugins/approval-ask-store-sql.js +2 -1
- package/dist/plugins/file-run-store.d.ts +85 -1
- package/dist/plugins/file-run-store.js +450 -17
- package/dist/plugins/memory-embedder.d.ts +44 -0
- package/dist/plugins/memory-embedder.js +173 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +45 -0
- package/dist/plugins/permission-rule-store-sql.js +60 -2
- package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
- package/dist/plugins/shared-memory-store-sql.js +55 -18
- package/dist/plugins/sql-driver.d.ts +19 -0
- package/dist/plugins/sql-driver.js +12 -0
- package/dist/plugins/store-backend.d.ts +3 -1
- package/dist/plugins/store-backend.js +24 -1
- package/dist/plugins/tidb-pool.js +11 -4
- package/dist/plugins/tool-result-store-sql.d.ts +35 -2
- package/dist/plugins/tool-result-store-sql.js +127 -11
- package/dist/plugins/web-search.d.ts +3 -1
- package/dist/plugins/web-search.js +3 -1
- package/dist/rules-consent.d.ts +33 -4
- package/dist/rules-consent.js +43 -2
- package/dist/run-local.js +6 -2
- package/dist/runtime-governance.d.ts +33 -0
- package/dist/runtime-governance.js +32 -0
- package/dist/security.js +3 -1
- package/dist/tool-approval.d.ts +32 -0
- package/dist/tool-approval.js +39 -0
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/package.json +3 -3
package/dist/http/server.js
CHANGED
|
@@ -26,7 +26,10 @@ import { redactSecrets } from "../trace/redact.js";
|
|
|
26
26
|
import { IdempotencyCache, scopedIdempotencyKey } from "./idempotency.js";
|
|
27
27
|
export { scopedIdempotencyKey };
|
|
28
28
|
import { streamSseLog } from "./sse-log.js";
|
|
29
|
-
|
|
29
|
+
// design/97 R9: the shape's neutral leaf (route-ctx.ts type-imports VerifyRoundsSpec from here, not from this file).
|
|
30
|
+
// `clampVerifyRounds` used to be re-exported from here for the tests; that re-export was deleted (8566348) and the
|
|
31
|
+
// import sat on as a dead name — tests import it from `verify-rounds.js` directly, which is the single owner.
|
|
32
|
+
import { verifyRoundsFromBody } from "./verify-rounds.js";
|
|
30
33
|
import { handleCapabilities } from "./routes/capabilities.js";
|
|
31
34
|
import { handleObservability } from "./routes/observability.js";
|
|
32
35
|
import { handleDiagnostics } from "./routes/diagnostics.js";
|
|
@@ -135,7 +138,14 @@ function isCheckpointReopenedFailure(r) {
|
|
|
135
138
|
// named alias so the existing call sites read clearly; the JWT logic now lives in one place that createAuthorizer
|
|
136
139
|
// shares, so spec.principal (governance + cost) and these observability/owner sites can never drift apart again.
|
|
137
140
|
/** design/158 A9 装配表(顺序 = 拆分前 `handle()` 里各域出现的先后,逐行同序;真正的 `return` 在 handle 内,
|
|
138
|
-
* 这里只做**签名一致性**的编译期钉:任何域入口漂了形,是编译红,不是运行时静默 404)。
|
|
141
|
+
* 这里只做**签名一致性**的编译期钉:任何域入口漂了形,是编译红,不是运行时静默 404)。
|
|
142
|
+
*
|
|
143
|
+
* 🔴 A-010.24(验真后修):上面那句「逐行同序」此前是**一句自述,没有任何东西执法**,而它已经不成立 ——
|
|
144
|
+
* `handleDiagnostics` 从来没进过这张表,车二着陆后 `handleRules` / `handleSharedMemory` 的先后也反了。
|
|
145
|
+
* 类型钉不住这两类漂:少一个成员、调换两个成员,`RouteHandler[]` 全都收下。而这张表是评审时唯一会被
|
|
146
|
+
* 当成「域全集 + 域次序」来读的清单(新域该插哪儿、某域在谁之后才可达),它给错答案是要害。
|
|
147
|
+
* 执法面现在真的有了:`test/route-shape-roster.test.ts` 的**层D**从 `handle()` 的真源码抽出域调用序,
|
|
148
|
+
* 与本表**逐项**对拍 —— 漏员或乱序都指名道姓地红。改 handle() 的次序时,这张表要跟着改。 */
|
|
139
149
|
const ROUTE_DOMAINS = [
|
|
140
150
|
handleTraceUsage,
|
|
141
151
|
handleWorkflows,
|
|
@@ -150,10 +160,11 @@ const ROUTE_DOMAINS = [
|
|
|
150
160
|
handleImages,
|
|
151
161
|
handleApprovalsAssistant,
|
|
152
162
|
handleObservability,
|
|
163
|
+
handleDiagnostics,
|
|
153
164
|
handleAdoption,
|
|
154
165
|
handleMemoryPolicy,
|
|
155
|
-
handleSharedMemory,
|
|
156
166
|
handleRules,
|
|
167
|
+
handleSharedMemory,
|
|
157
168
|
handleSessionsList,
|
|
158
169
|
handleSessions,
|
|
159
170
|
handleSessionSync,
|
|
@@ -169,7 +180,15 @@ void ROUTE_DOMAINS;
|
|
|
169
180
|
* POST /v1/runs → async: 202 {taskId, sessionId, status}, runs in background (S1)
|
|
170
181
|
* GET /v1/runs/:id → run status + result (poll)
|
|
171
182
|
* GET /v1/runs/:id/events → SSE replay from Last-Event-ID, then tail to terminal (S2)
|
|
172
|
-
* POST /v1/sessions/:id/
|
|
183
|
+
* POST /v1/sessions/:id/notify → inject an out-of-session event (design/144 §2): live stream ⇒ core
|
|
184
|
+
* `TaskStream.notify()`; idle ⇒ parked in the session inbox, drained
|
|
185
|
+
* as a `task_notification` on the next stream open
|
|
186
|
+
* POST /v1/sessions/:id/wake → wake a `task_done` pure park with a message and resume it
|
|
187
|
+
* (design/144 §3). NOT a gate decision — a pending gate is refused
|
|
188
|
+
* (`wake.gate_pending`). Goes through `resumeWake`, which reuses the
|
|
189
|
+
* resume family's lease/CAS/run-log leg, so it BURNS MODEL TOKENS and
|
|
190
|
+
* is subject to the billable-submit gates (`SESSION_WAKE_RE`).
|
|
191
|
+
* (both live in `http/routes/notify-wake.ts` — design/158 A9 domain split)
|
|
173
192
|
*/
|
|
174
193
|
export function createHttpServer(rawDeps) {
|
|
175
194
|
// design/158 A8:分组装配 → 平铺视图。**分组是装配面的形,不是消费面的形**——下面 ~700 处 `deps.x`
|
|
@@ -528,10 +547,16 @@ export function createHttpServer(rawDeps) {
|
|
|
528
547
|
// The bake door (POST /v1/images/bakes*) is build-host-RCE-capable and authed by the Bearer-token-no-cookie
|
|
529
548
|
// model (§P2.4b) — it MUST also refuse when no service token is configured (else a forged principal header
|
|
530
549
|
// alone could reach an operator-gated RCE door).
|
|
531
|
-
|
|
532
|
-
|
|
550
|
+
// A-010.23 (#209 件5) adds the same sibling arm for the REWRITE doors (`isCredentialGatedRewrite`:
|
|
551
|
+
// adoption + rules/cc-import + rules revoke) — same sentence, same threat, different verbs: one rekeys every
|
|
552
|
+
// row a principal owns, one mints that principal's DURABLE allow rules, one deletes them. They stay OUT of
|
|
553
|
+
// `isBillableSubmitPath` on purpose (billable=false; see that predicate's twin comment), and that family is
|
|
554
|
+
// METHOD-AWARE (its revoke verb is DELETE) so it is evaluated OUTSIDE the POST conjunct — same shape as
|
|
555
|
+
// `isDestructiveSessionWrite` below.
|
|
556
|
+
if (!anyServiceAuth &&
|
|
533
557
|
!deps.config.allowUnauthedWrites &&
|
|
534
|
-
(isBillableSubmitPath(url) || url.startsWith("/v1/images/bakes")))
|
|
558
|
+
((req.method === "POST" && (isBillableSubmitPath(url) || url.startsWith("/v1/images/bakes"))) ||
|
|
559
|
+
isCredentialGatedRewrite(req.method ?? "", url))) {
|
|
535
560
|
sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions");
|
|
536
561
|
return;
|
|
537
562
|
}
|
|
@@ -2187,9 +2212,13 @@ export function createHttpServer(rawDeps) {
|
|
|
2187
2212
|
return { status: 200, body: { taskId, sessionId, status: "failed", errorCode: "cancelled" } };
|
|
2188
2213
|
}
|
|
2189
2214
|
// We flipped the row to `running` (claimedRow). Two CheckpointError classes diverge here (D-1, core 1.101):
|
|
2190
|
-
// • TERMINAL (already_resolved / not_found / gate_mismatch
|
|
2215
|
+
// • TERMINAL (already_resolved / not_found / gate_mismatch): the checkpoint is
|
|
2191
2216
|
// consumed or gone — drive the row terminal now (releasing task_active) instead of leaving a zombie
|
|
2192
2217
|
// `running` row for reapStale to mislabel and hold the session lock for ~runStaleSec. (Original case.)
|
|
2218
|
+
// ⚠️ `unsupported_version` USED to sit in this bullet unconditionally (its checkpoint does NOT go
|
|
2219
|
+
// away — that was the registered orphan debt). As of #209 / core 5.25.0 it SPLITS on
|
|
2220
|
+
// `detail.reason`: a recognised worker-swap word joins the retriable class below; an absent /
|
|
2221
|
+
// unrecognised word stays TERMINAL. Read the block under the closed set before touching it.
|
|
2193
2222
|
// • RETRIABLE PRE-CAS (invalid_outcome = binding mismatch / reopen_revote = env_failed must replay the
|
|
2194
2223
|
// persisted winner / reopened_concurrently = a concurrent resolve-reopen advanced the rev): the
|
|
2195
2224
|
// checkpoint STAYS pending and the operator re-fetches + re-/decides. Driving the row terminal would
|
|
@@ -2207,7 +2236,34 @@ export function createHttpServer(rawDeps) {
|
|
|
2207
2236
|
// 会释放 task_active 并孤儿化那条仍 pending 的卡 —— 一张还能点的卡当场变死件。
|
|
2208
2237
|
e.code === "resume.constraint_rejected" ||
|
|
2209
2238
|
e.code === "resume.constraint_unprojectable" ||
|
|
2210
|
-
e.code.startsWith("wake.")
|
|
2239
|
+
e.code.startsWith("wake.") || // design/144:wake 拒绝(gate_pending/nothing_to_deliver)全是 PRE-CAS,checkpoint 保持 pending——驱 terminal 会孤儿化仍在等的 park
|
|
2240
|
+
// 🟢 **`checkpoint.unsupported_version` 的一臂精确 retriable**(#209 件2,core 5.25.0 提货批
|
|
2241
|
+
// [3443];取代 #205 件4② 那条「维持 TERMINAL + 孤儿代价在案」的登记账)。
|
|
2242
|
+
//
|
|
2243
|
+
// 旧账的形状(留着,因为它解释了为什么这条臂长这样):该码在 core 上有**三个** PRE-CAS 铸点
|
|
2244
|
+
// (版本超上限 / org 治理行落到无 `permissionRuleOrg` 的 worker / 带 remote `workspaceHandle` 的行
|
|
2245
|
+
// 落到无 `executionEnvFactory` 的 Runner),三形卡一律留 pending 但**可恢复性不同**,而 core 当时
|
|
2246
|
+
// 同码同 `detail`(都不带)⇒ 下游只剩「按报文文案猜」或「按 checkpoint 形状反推」两条路,两版修法
|
|
2247
|
+
// 各被一轮 codex 对抗复审验伪(blanket-retriable 让无手臂永挂 task_active;按 `gate.realApproval`
|
|
2248
|
+
// 分流不健全 —— core 把该位放 gate、`workspaceHandle` 放 state,同一条行可以两样都带)。按宪法
|
|
2249
|
+
// 「源头修复,禁下游旁路」全回退,并把判别式列成上游请托([3434] 请托 1)。
|
|
2250
|
+
//
|
|
2251
|
+
// 请托已兑现:core 5.25.0 起三个铸点各带 `detail.reason`(`runtask.js:3706/3721/3733` 亲读),
|
|
2252
|
+
// d.ts 逐字说三者都是「retryable on a newer / factory-wired / org-wired worker」。⇒ 本臂**读词表**
|
|
2253
|
+
// (`WORKER_SWAP_REDEEMABLE`,闭集、新词 tsc 红),不做任何形状推断:
|
|
2254
|
+
// · 认得的三个词 ⇒ retriable:行重新 park suspended,那条仍 pending 的卡不再被孤儿化
|
|
2255
|
+
// (旧账里「行 failed 而卡活着、下一次 /decide → 404、只能等 `terminal_at_ms`」的代价至此销掉)。
|
|
2256
|
+
// · 词缺席 / 认不得 ⇒ **维持 TERMINAL**(旧行为逐字保留)。这不是保守的装饰:旧引擎的裸抛与
|
|
2257
|
+
// 将来某个我们还没读过的新臂都落在这一支,而把它们默认成 retriable 会把一条**真的没救**的行
|
|
2258
|
+
// 永久 park 在 suspended 上攥着 task_active —— 那正是 codex R1-[high] 验伪掉的那一版。
|
|
2259
|
+
// 🔴 形③(`env_factory_missing`)按 core 的词判 retriable,而不是按 #205 那条「选 Runner 由
|
|
2260
|
+
// `handsLanes.laneOf(spec)` 按场景判,重投永远同样失败」的推断:那条推断说的是**同一份部署配置下**
|
|
2261
|
+
// 重投无用,而 core 的词说的是「接上 `executionEnvFactory` 的 worker 兑得掉」—— 那是一次运维动作,
|
|
2262
|
+
// 与 `governed_unwired` 要求接 `permissionRuleOrg` 完全同族。两者都不是「换个副本再点一次」。
|
|
2263
|
+
// 期间行停在 suspended 攥着会话锁,与它 park 时的状态**逐字相同**(这条腿只是没能把它推进),
|
|
2264
|
+
// 而 TERMINAL 那一支要用「释放锁」换「卡变死件」,方向更坏。
|
|
2265
|
+
// 行为面钉:`test/durable-resume-http.test.ts` 的「#209 件2」三正格 + 三反极格。
|
|
2266
|
+
(e.code === "checkpoint.unsupported_version" && checkpointRowRedeemableElsewhere(e.detail?.reason));
|
|
2211
2267
|
if (claimedRow && taskId && deps.runStore) {
|
|
2212
2268
|
// Re-park on the SAME gate family the outcome targeted: a plan_review retry must re-park `needs_review`
|
|
2213
2269
|
// (keep the row's review status + the lock), not `suspended` — else GET /v1/assistant/tasks mislabels it.
|
|
@@ -2798,14 +2854,19 @@ export function createHttpServer(rawDeps) {
|
|
|
2798
2854
|
// value AS an http.Server — listen/close/etc. — while main.ts's reaper reads server.denyExpiredApprovals).
|
|
2799
2855
|
return Object.assign(server, { denyExpiredApprovals });
|
|
2800
2856
|
}
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2857
|
+
const WORKER_SWAP_REDEEMABLE = {
|
|
2858
|
+
version_newer: true,
|
|
2859
|
+
env_factory_missing: true,
|
|
2860
|
+
governed_unwired: true,
|
|
2861
|
+
real_approval_damaged: false,
|
|
2862
|
+
real_approval_forged: false,
|
|
2863
|
+
constraint_chain_missing: false,
|
|
2864
|
+
};
|
|
2865
|
+
/** 判别符缺席(旧引擎裸抛)或词表不认得 ⇒ **false**,即维持修前的 TERMINAL 归属。fail-closed 的方向在
|
|
2866
|
+
* 这里是「不宽容默认」:一个我们还没读过的新臂不该因为一个 `?? true` 被悄悄改判成「重试吧」。 */
|
|
2867
|
+
function checkpointRowRedeemableElsewhere(reason) {
|
|
2868
|
+
return reason !== undefined && WORKER_SWAP_REDEEMABLE[reason] === true;
|
|
2869
|
+
}
|
|
2809
2870
|
/** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
|
|
2810
2871
|
* guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
|
|
2811
2872
|
* via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
|
|
@@ -2827,6 +2888,39 @@ export function isBillableSubmitPath(url) {
|
|
|
2827
2888
|
SESSION_WAKE_RE.test(url) ||
|
|
2828
2889
|
url.startsWith("/v1/approvals"));
|
|
2829
2890
|
}
|
|
2891
|
+
/**
|
|
2892
|
+
* A-010.23(#209 件5)—— **改写门**:不烧模型、但在**没有任何 service credential** 的部署形下必须与
|
|
2893
|
+
* billable / bake 两族一样 fail-closed 的写门。谓词与 `isBillableSubmitPath` **刻意分开**:这几扇门
|
|
2894
|
+
* `billable=false`(零模型工作),塞进那张名单会让它们连带吃 drain 与 model-roster-pending 两道 503 ——
|
|
2895
|
+
* 而收编与规则读写在排空期/roster 未落时做完全无害。
|
|
2896
|
+
*
|
|
2897
|
+
* 判据(两个合取项,缺一不进本表)= 「授权的唯一输入是 principal 头」∧「动作是**持久改写**」:
|
|
2898
|
+
* · `POST /v1/adoption` —— operator-only 的一次性部署级动作,把一个 principal 名下**每一行**重写到另一个
|
|
2899
|
+
* 身份;授权判据 `explicitOperatorOk(gatedPrincipal(...))`。
|
|
2900
|
+
* · `POST /v1/rules/cc-import/*` —— principal lane 的**持久 allow 规则**铸造口;一条持久 allow 规则对后续
|
|
2901
|
+
* 同命令的 classify 档 ask 常驻消音(core #144 之后仍然如此,只是不再消 mandated 的那些)。
|
|
2902
|
+
* · `DELETE /v1/rules` —— 同一个规则店的**撤销**口。方向相反(收紧)但同样是持久改写,而且它多一条
|
|
2903
|
+
* operator 越权域(`?principal=` / body 的 `principal` 让 operator 收回**任一**租户的规则)⇒ 伪造一个
|
|
2904
|
+
* 列在 `OPERATOR_PRINCIPALS` 里的头,就能把每一位租户的规则一次清空。
|
|
2905
|
+
* 无 service token 的部署上 `verifiedPrincipal` 走的是「BFF/gated:头已由上游验过」那一支,而这一形恰恰
|
|
2906
|
+
* 是**没有**那个上游 —— 于是伪造一个头就能收编别人、替任意租户种下常驻放行、或替他们全删。bake 门当年
|
|
2907
|
+
* 补的就是同一句话,`isDestructiveSessionWrite` 从逐条路由白名单改成族判定也是同一句话(那次的措辞逐字是
|
|
2908
|
+
* 「a forged principal header alone could **tighten/DoS** a session's tools」——撤销口落的正是 DoS 那一半)。
|
|
2909
|
+
*
|
|
2910
|
+
* 🔴 **方法感知**(codex 对抗复审 R2-[high],验真后修):撤销走的是 `DELETE`,而本门最初只挂在 `POST` 的
|
|
2911
|
+
* 合取里 —— 于是一个自称「持久改写」的谓词把爆炸半径最大的那条动词漏在门外,正是 `isDestructiveSessionWrite`
|
|
2912
|
+
* 当年被抓到的同一形。签名因此收方法,与那只谓词逐字同形。
|
|
2913
|
+
* 🔴 读面不进本表:`GET /v1/adoption/:id` 与 `GET /v1/rules` 各有自己的属主/operator 门,本门只拦写。
|
|
2914
|
+
* 行为面钉:`test/rewrite-door-service-token-gate.test.ts`(四扇门各一正一反 + 逃生口格 + 读面负控)。
|
|
2915
|
+
*/
|
|
2916
|
+
export function isCredentialGatedRewrite(method, url) {
|
|
2917
|
+
const m = method.toUpperCase();
|
|
2918
|
+
if (m === "POST")
|
|
2919
|
+
return url === "/v1/adoption" || url.startsWith("/v1/rules/cc-import/");
|
|
2920
|
+
if (m === "DELETE")
|
|
2921
|
+
return url === "/v1/rules";
|
|
2922
|
+
return false;
|
|
2923
|
+
}
|
|
2830
2924
|
// ⚠️ SSE 帧纪律(2026-07-12):center BFF 中继凭「data 无 type 字段」识别心跳帧并吞掉
|
|
2831
2925
|
// (AgentEvent 是 closed oneof 全带 type,故可控)。**未来新增任何 SSE data 帧必须带 type 字段**,否则会被
|
|
2832
2926
|
// 中继当心跳吞掉、永远到不了前端。heartbeat 帧(`event: heartbeat` + `data: {}`)是唯一豁免。
|
|
@@ -287,7 +287,12 @@ export interface TaskRequestBody {
|
|
|
287
287
|
/** MF-30 memory PAUSE (shell-host contract, option B per-request — clay 2026-06-27): `false` makes THIS run
|
|
288
288
|
* read-only over long-term memory (`TaskSpec.memory.writeScope:null` — the design/138 memory ENGINE materializes/
|
|
289
289
|
* reads but its harvest commits nothing). Absent/`true` ⇒ normal read+write. The shell's `/memory` pause carries
|
|
290
|
-
* this per request (no stored per-session flag). Re-applies on resume (rides in the persisted body).
|
|
290
|
+
* this per request (no stored per-session flag). Re-applies on resume (rides in the persisted body).
|
|
291
|
+
* ⚠️ **NOT the same axis as the deployment's `MEMORY_PERSISTENCE_CAPABLE:false`** (core 5.27.0 / [3612] F2):
|
|
292
|
+
* that one declares the SESSION restricted — materialize/search/harvest serve the committed account and
|
|
293
|
+
* unbacked disk divergence is refused (`restricted_divergence`). A `writeScope:null` PLANE keeps its ordinary
|
|
294
|
+
* adopt-on-read semantics: a paused run still sees the user's hand-edits and git-pull drops, it just commits
|
|
295
|
+
* nothing. Restriction is the session's DECLARATION, never the plane's structure. */
|
|
291
296
|
memoryWrite?: boolean;
|
|
292
297
|
/** 142-S4 projectId 线程化(design/142 §2):这个 run 归属的项目(center 登记簿键,generic lowercase
|
|
293
298
|
* UUID — core S1 `resolveProjectId` marker 或 S3 无仓 mint 的产物;客户端只透传,零铸造权威)。是「哪个
|
package/dist/main.js
CHANGED
|
@@ -189,6 +189,23 @@ async function main() {
|
|
|
189
189
|
// 🔴 总开关在最前(codex round7 [high] 一):关 ⇒ 整条车道根本不装配(店/车道/帧键/两口一起消失)。
|
|
190
190
|
const permissionRuleStores = config.permissionRulesEnabled && backend ? backend.permissionRule() : undefined;
|
|
191
191
|
const ruleConsent = permissionRuleStores ? createRuleConsentLane(permissionRuleStores) : undefined;
|
|
192
|
+
// 🔴 A-010.10(验真后修):**旋钮开着却上不了场 ⇒ 打一行**,与同族旋钮 `STREAM_APPROVAL_ENABLED` 的
|
|
193
|
+
// `stream_approval_disabled` 一行(`boot/coordinators.ts`)对称。
|
|
194
|
+
// 此前这一格全程静默:`PERMISSION_RULES_ENABLED=true` 的部署若没有 backend、或 backend 没实装规则店,
|
|
195
|
+
// 车道就悄悄整条不装配 —— 运维看到的是「设了旋钮,可 `/v1/rules` 恒 501、卡上恒无候选」,而启动日志
|
|
196
|
+
// 里一个字都没有。判据逐字照那条先例:①**info 不是 warn**(这是合法部署形,不是配置错误);
|
|
197
|
+
// ②旋钮本来就没开的部署**不打**(显式关掉的人自己知道为什么,一行噪音零信息);
|
|
198
|
+
// ③原因写成机读位(哪一项不成立),不是一句人话。
|
|
199
|
+
// 与紧随其后的 `auditDormantPermissionRules` 是两件事、互不遮蔽:那条说的是「车道上了场,并且库里有
|
|
200
|
+
// 既有桶被默认 ON 唤醒」,这条说的是「车道压根没上场」——两者的合取恒假,永远只会打其中一行。
|
|
201
|
+
if (config.permissionRulesEnabled && permissionRuleStores === undefined) {
|
|
202
|
+
logger.info("permission_rules_lane_unavailable", {
|
|
203
|
+
knob: "PERMISSION_RULES_ENABLED",
|
|
204
|
+
reason: backend === undefined ? "no_backend" : "backend_has_no_rule_store",
|
|
205
|
+
backendKind: backend?.kind ?? null,
|
|
206
|
+
note: "PERMISSION_RULES_ENABLED is on but no permission-rule store is wired — /v1/rules/* answer 501, approval cards carry no rule suggestions, and respond's persistRule is refused with rule_lane_unavailable",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
192
209
|
// #203 §3(设计稿 v2 F4 残余):默认 ON 会把**既有**规则桶一并唤醒 —— 在启动日志里把它说出来。
|
|
193
210
|
// 三条判据(店缺席 / 零桶 / 运维显式表过态 ⇒ 都不打行)与失败方向(数不出来只 warn,绝不拒启、
|
|
194
211
|
// 也绝不编一个 0)逐字见 `boot/permission-rules-audit.ts`。
|
|
@@ -240,8 +257,15 @@ async function main() {
|
|
|
240
257
|
// ⚠️ 刻意**不动** `toolResultStore` 本身:那个键的「present ⇔ durable 后端在场」语义还管着 E21 purge、
|
|
241
258
|
// reaper 清扫、leader 装配与 /health 的接线自述(改它会让无 backend 的部署自述成 "shared(sql)" = 谎)。
|
|
242
259
|
const runnerOffloadStore = toolResultStore ?? new InMemoryToolResultStore({ maxTotalChars: 64_000_000 });
|
|
260
|
+
// 交接件⑤:commit 尾注署名座**算一次**,主/sub 两只 Runner 经共享基座同源。
|
|
261
|
+
// [931]① clay 拍(core 1.300 BREAKING:缺省不署 Co-Authored-By,署名=产品身份资产归部署):
|
|
262
|
+
// branded 形态(local provider = Sema 产品线,scenarios brandIdentity 同判据)commit 尾注接 Sema 署名;
|
|
263
|
+
// 非 brand 部署维持 core 新缺省(不署)。判据逐字不变,变的只是它现在**也**喂给 subRunner ——
|
|
264
|
+
// 一个被委派的子代提交进的是同一个仓、代表同一个部署,它的 commit 少一行 trailer 没有理由。
|
|
265
|
+
const commitHands = config.configProvider === "local" ? { commitCoAuthor: "Sema <noreply@vivi-ai.com>" } : undefined;
|
|
243
266
|
// design/158 A10:RunnerDeps 装配段搬到 src/boot/runner-deps.ts(逐字;runStore 晚绑改取值,见该文件头注)。
|
|
244
267
|
const runnerDeps = createRunnerDeps({
|
|
268
|
+
hands: commitHands,
|
|
245
269
|
config, logger, metrics, localRoot, promptSource: configCenter.promptSource, rosterStore, backgroundAgentStore, mailboxStore, usageWindowStore, brain,
|
|
246
270
|
pricing, tracer, outcomeSink, elicitation, question, toolApproval, sessionStore, memoryEngine,
|
|
247
271
|
memorySyncRunner, toolResultStore: runnerOffloadStore, sessionPolicyStore, runtimeCapsResolver, fileSnapshotStore,
|
|
@@ -274,11 +298,26 @@ async function main() {
|
|
|
274
298
|
// 与 resource suspend 都会写),续跑时被路由到这只无手 Runner,core 在 **CAS 之前**抛
|
|
275
299
|
// `CheckpointError("checkpoint.unsupported_version", "checkpoint has a remote workspaceHandle but no
|
|
276
300
|
// RunnerDeps.executionEnvFactory is wired to rebuild the env")`(runtask.js:3704)。
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// (
|
|
301
|
+
// 🟢 **裁定已翻新(#209 件2,core 5.25.0 提货批 [3443]):本形现在归 RETRIABLE。**
|
|
302
|
+
// 沿革留档(免下次重开同一个问题):该码在 core 上共三个铸点 —— 本形 / 版本超上限 / org 治理行
|
|
303
|
+
// 落到无 `permissionRuleOrg` 接线的 worker —— 三形都是 PRE-CAS、卡一律留 pending,但**可恢复性
|
|
304
|
+
// 不同**;#205 当时 core **不给判别式**(同码、`detail` 同样缺席),两版下游修法各被一轮 codex
|
|
305
|
+
// 对抗复审验伪(①三形一律 retriable ②按 `gate.realApproval` 分流),按「源头修复禁下游旁路」全
|
|
306
|
+
// 回退、维持 TERMINAL,并把判别式列成上游请托([3434] 请托 1)。
|
|
307
|
+
// 请托已兑现:core 5.25.0 给三个铸点各带 `CheckpointError.detail.reason`,本形 = `env_factory_missing`,
|
|
308
|
+
// d.ts 逐字「retryable on a factory-wired worker」。⇒ `http/server.ts` 的分类表现在**读词表**判
|
|
309
|
+
// (`WORKER_SWAP_REDEEMABLE`,闭集、core 加词 tsc 红),不做形状推断;本形的行**重新 park
|
|
310
|
+
// suspended**、那条仍 pending 的卡不再被孤儿化(#205 登记的孤儿代价至此销账)。
|
|
311
|
+
// ⚠️ 与旧裁定的差别在**代价的方向**,如实说清:修前拿「释放 task_active」换「卡变死件(下一次
|
|
312
|
+
// /decide → 404,只能等 `terminal_at_ms`)」;修后行停在 suspended 攥着会话锁,与它 park 时逐字
|
|
313
|
+
// 相同,直到运维给这条车道接上 `executionEnvFactory`(或把该场景路由回有手 Runner)。#205 当年
|
|
314
|
+
// 驳掉 blanket-retriable 的那条理由(`handsLanes.laneOf(spec)` 按场景判 ⇒ 换副本重投必同样失败)
|
|
315
|
+
// 仍然成立,但它说的是「换个副本再点一次没用」,而 core 的词说的是「接上 factory 的 worker 兑得掉」
|
|
316
|
+
// —— 那是一次**运维动作**,与 `governed_unwired` 要求接 `permissionRuleOrg` 完全同族。
|
|
317
|
+
// 升级窗仍是**响亮的**(409 + 该 errorCode + `retriable: true`),不是静默损坏;发车说明照写。
|
|
318
|
+
// 论证与钉:`http/server.ts` 分类表下方那段 + `test/durable-resume-http.test.ts` 的「#209 件2」六格。
|
|
319
|
+
// ⚠️ 未覆盖登记(原样保留):本形需要「真 durable 店 + 带 workspaceHandle 的旧 checkpoint」才能
|
|
320
|
+
// 驱动,真店端到端仍属真双库/迁移测试面,未建。
|
|
282
321
|
const handslessRunner = new Runner(withoutExecutionEnv(runnerDeps));
|
|
283
322
|
// codex R10: TRUE ⇒ the Runner just froze a PRIVATE tier-expanded catalog copy (core runtask.js constructor,
|
|
284
323
|
// dist-read) — in-place model-plane mutation no longer reaches it, so refresh-time plane changes must be
|
|
@@ -404,6 +443,7 @@ async function main() {
|
|
|
404
443
|
// 基座只有一份)。差异键在展开后显式列出,每个都有为何不同的理由(见 boot/runner-deps.ts 头注)。
|
|
405
444
|
...createSharedRunnerDeps({
|
|
406
445
|
config,
|
|
446
|
+
hands: commitHands, // 交接件⑤:与主 runner 同一个值(基座保证同源)
|
|
407
447
|
brain,
|
|
408
448
|
pricing,
|
|
409
449
|
tracer,
|
|
@@ -758,7 +798,7 @@ async function main() {
|
|
|
758
798
|
config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore,
|
|
759
799
|
rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap,
|
|
760
800
|
workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
|
|
761
|
-
backgroundAgentStore, mailboxStore, toolApproval,
|
|
801
|
+
backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores,
|
|
762
802
|
getRunDenySweep: () => runDenySweep,
|
|
763
803
|
});
|
|
764
804
|
// Optional OTLP/HTTP metrics export (1.37). Periodically pushes the registry to an OTel collector;
|
|
@@ -57,6 +57,10 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
57
57
|
readonly cls: "F";
|
|
58
58
|
readonly note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。";
|
|
59
59
|
};
|
|
60
|
+
readonly "server.run-store.stale-claim-quarantine-unremoved": {
|
|
61
|
+
readonly cls: "F";
|
|
62
|
+
readonly note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。";
|
|
63
|
+
};
|
|
60
64
|
};
|
|
61
65
|
/** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
|
|
62
66
|
export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
|
|
@@ -80,6 +80,10 @@ export const FAIL_OPEN_TAGS = {
|
|
|
80
80
|
cls: "F",
|
|
81
81
|
note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。",
|
|
82
82
|
},
|
|
83
|
+
"server.run-store.stale-claim-quarantine-unremoved": {
|
|
84
|
+
cls: "F",
|
|
85
|
+
note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。",
|
|
86
|
+
},
|
|
83
87
|
};
|
|
84
88
|
/**
|
|
85
89
|
* 断流丢帧该记哪个 tag —— **按帧型分类**,纯函数(与写流的那条闭包解耦,才单测得动)。
|
|
@@ -117,6 +117,31 @@ export declare function ensurePgAdoptionLogSchema(q: (text: string, params?: unk
|
|
|
117
117
|
* (PG 那条腿本来就走 hash 出来的 int4 对象键,这里只是把 MySQL 腿补齐到同一姿势。)
|
|
118
118
|
*/
|
|
119
119
|
export declare function adoptionLockName(fromPrincipal: string): string;
|
|
120
|
+
/**
|
|
121
|
+
* 把弧锁名限定到**一个库**的键空间(A-010.13,验真后修)。
|
|
122
|
+
*
|
|
123
|
+
* 🔴 病:两条腿的锁键空间**不等价**。MySQL/TiDB 的 `GET_LOCK` 名字是**服务器实例全局**的
|
|
124
|
+
* (`performance_schema.metadata_locks` 里就是一个进程级命名空间,与你连的是哪个 schema 无关);
|
|
125
|
+
* PG 的 advisory lock 是**每数据库**的(`pg_locks` 的 database 列参与身份)。而锁名此前只 hash 了
|
|
126
|
+
* `fromPrincipal`、前缀写死 `sema_adoption:` —— 于是同一台 MySQL 上并存的两套部署
|
|
127
|
+
* (`aiagent_prod` / `aiagent_staging`,同一个源身份)会**互相抢同一把锁**:一边正在跑弧,另一边
|
|
128
|
+
* 取不到锁、按「在飞」回一份 `stalled` 回执 —— 一个与它自己的库状态完全对不上的答复。切到 PG 同样
|
|
129
|
+
* 两套部署却各跑各的。同一份代码、同一份配置,两个后端上语义不同 = 方言不等价。
|
|
130
|
+
*
|
|
131
|
+
* 🔴 姿势:限定符 = **当前数据库名**(MySQL `DATABASE()` / PG `current_database()`),与 principal
|
|
132
|
+
* 一起进 hash 原像。于是 MySQL 腿被收窄到与 PG 腿同一个键空间语义(每库一把),而不是把 PG 放宽到
|
|
133
|
+
* 实例全局(放宽的那个方向会让**同库**的两副本以为各自持锁,那是真丢互斥,方向错得多)。
|
|
134
|
+
*
|
|
135
|
+
* 🔴 为什么仍然 hash 而不是 `${db}/${name}` 直接拼:`GET_LOCK` 的名字上限是 **64 字符**,而库名没有
|
|
136
|
+
* 本仓能保证的上限。拼接形会让一个长库名的部署在**取锁那一步**才失败,而那时意图行已经落库 ——
|
|
137
|
+
* 每一次重发、每一次 boot 续跑都确定性地再撞一次同一堵墙(与 `adoptionLockName` 自己头注里那条
|
|
138
|
+
* codex R3-F3 是同一个病)。hash 后定长 46 字符,与库名长度无关。
|
|
139
|
+
* 分隔符取 **NUL**(源码里写成转义 `\u0000`,不落裸控制字节 —— 那会让 grep 把整个文件当二进制):
|
|
140
|
+
* 库名可以含 `/`、空格这类字符(MySQL 反引号标识符 / PG 双引号标识符都允许),拿它们当分隔符时
|
|
141
|
+
* `(db="a", name="b/c")` 与 `(db="a/b", name="c")` 会铸出**同一个原像** —— 弱分隔符拼接的经典撞形。
|
|
142
|
+
* NUL 在两种引擎的标识符里都不合法,所以它是真的不可能出现在任何一段里。
|
|
143
|
+
*/
|
|
144
|
+
export declare function qualifyAdoptionLockName(database: string, name: string): string;
|
|
120
145
|
/**
|
|
121
146
|
* 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。
|
|
122
147
|
*
|
|
@@ -173,12 +198,27 @@ export declare class SqlAdoptionLogStore implements AdoptionLogStore {
|
|
|
173
198
|
finalizeAdopted(adoptionId: string, expectPhase: number, reportJson: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
174
199
|
/** 目的地冲突 ⇒ rejected 终态(源/目的地两侧字节零变更;phase 停在 INTENT)。 */
|
|
175
200
|
finalizeRejected(adoptionId: string, code: AdoptionRejectCode, detail: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
201
|
+
/**
|
|
202
|
+
* 本连接所在**数据库**的名字 = 弧锁的键空间限定符(A-010.13,理由见 {@link qualifyAdoptionLockName})。
|
|
203
|
+
*
|
|
204
|
+
* 在**交出来的那条连接**上问(不向池要第二条 —— 弧全程单连接,见 `AdoptionLogStore.getById` 的注),
|
|
205
|
+
* 结果缓存在店上:库名在一条驱动的生命周期里不会变,而每次取锁多打一个往返是白付的。
|
|
206
|
+
*
|
|
207
|
+
* 读不出来 ⇒ **响亮抛**,不回落到未限定的旧名字:那正是本条要消灭的形,静默回落等于「修了个寂寞」
|
|
208
|
+
* 且只在多部署共库的那台机器上才发作(安全轴禁静默 fail-open 的同族判据)。
|
|
209
|
+
*/
|
|
210
|
+
private lockKeyspace?;
|
|
211
|
+
private resolveLockKeyspace;
|
|
176
212
|
/**
|
|
177
213
|
* 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。
|
|
178
214
|
*
|
|
179
215
|
* **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发
|
|
180
216
|
* 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒
|
|
181
217
|
* 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。
|
|
218
|
+
*
|
|
219
|
+
* 🔴 入参 `name` 是**逻辑**锁名(`adoptionLockName(fromPrincipal)`);真正发给引擎的是它被
|
|
220
|
+
* {@link qualifyAdoptionLockName} 限定到本库之后的形(A-010.13:两方言键空间不等价)。限定发生在
|
|
221
|
+
* **这里**而不是调用方,因为「键空间是每库还是每实例」是**存储层**的知识,协议层(runner)不该知道。
|
|
182
222
|
*/
|
|
183
223
|
withLock<T>(name: string, fn: (conn: SqlTxConn) => Promise<T>, attempts?: number, sleepMs?: number): Promise<T | undefined>;
|
|
184
224
|
}
|
|
@@ -92,6 +92,33 @@ function pgLockObjectKey(name) {
|
|
|
92
92
|
export function adoptionLockName(fromPrincipal) {
|
|
93
93
|
return `sema_adoption:${createHash("sha256").update(fromPrincipal).digest("hex").slice(0, 32)}`;
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* 把弧锁名限定到**一个库**的键空间(A-010.13,验真后修)。
|
|
97
|
+
*
|
|
98
|
+
* 🔴 病:两条腿的锁键空间**不等价**。MySQL/TiDB 的 `GET_LOCK` 名字是**服务器实例全局**的
|
|
99
|
+
* (`performance_schema.metadata_locks` 里就是一个进程级命名空间,与你连的是哪个 schema 无关);
|
|
100
|
+
* PG 的 advisory lock 是**每数据库**的(`pg_locks` 的 database 列参与身份)。而锁名此前只 hash 了
|
|
101
|
+
* `fromPrincipal`、前缀写死 `sema_adoption:` —— 于是同一台 MySQL 上并存的两套部署
|
|
102
|
+
* (`aiagent_prod` / `aiagent_staging`,同一个源身份)会**互相抢同一把锁**:一边正在跑弧,另一边
|
|
103
|
+
* 取不到锁、按「在飞」回一份 `stalled` 回执 —— 一个与它自己的库状态完全对不上的答复。切到 PG 同样
|
|
104
|
+
* 两套部署却各跑各的。同一份代码、同一份配置,两个后端上语义不同 = 方言不等价。
|
|
105
|
+
*
|
|
106
|
+
* 🔴 姿势:限定符 = **当前数据库名**(MySQL `DATABASE()` / PG `current_database()`),与 principal
|
|
107
|
+
* 一起进 hash 原像。于是 MySQL 腿被收窄到与 PG 腿同一个键空间语义(每库一把),而不是把 PG 放宽到
|
|
108
|
+
* 实例全局(放宽的那个方向会让**同库**的两副本以为各自持锁,那是真丢互斥,方向错得多)。
|
|
109
|
+
*
|
|
110
|
+
* 🔴 为什么仍然 hash 而不是 `${db}/${name}` 直接拼:`GET_LOCK` 的名字上限是 **64 字符**,而库名没有
|
|
111
|
+
* 本仓能保证的上限。拼接形会让一个长库名的部署在**取锁那一步**才失败,而那时意图行已经落库 ——
|
|
112
|
+
* 每一次重发、每一次 boot 续跑都确定性地再撞一次同一堵墙(与 `adoptionLockName` 自己头注里那条
|
|
113
|
+
* codex R3-F3 是同一个病)。hash 后定长 46 字符,与库名长度无关。
|
|
114
|
+
* 分隔符取 **NUL**(源码里写成转义 `\u0000`,不落裸控制字节 —— 那会让 grep 把整个文件当二进制):
|
|
115
|
+
* 库名可以含 `/`、空格这类字符(MySQL 反引号标识符 / PG 双引号标识符都允许),拿它们当分隔符时
|
|
116
|
+
* `(db="a", name="b/c")` 与 `(db="a/b", name="c")` 会铸出**同一个原像** —— 弱分隔符拼接的经典撞形。
|
|
117
|
+
* NUL 在两种引擎的标识符里都不合法,所以它是真的不可能出现在任何一段里。
|
|
118
|
+
*/
|
|
119
|
+
export function qualifyAdoptionLockName(database, name) {
|
|
120
|
+
return `sema_adoption:${createHash("sha256").update(`${database}\u0000${name}`, "utf8").digest("hex").slice(0, 32)}`;
|
|
121
|
+
}
|
|
95
122
|
/**
|
|
96
123
|
* 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。
|
|
97
124
|
*
|
|
@@ -221,19 +248,59 @@ export class SqlAdoptionLogStore {
|
|
|
221
248
|
const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = ?, reject_detail = ?, updated_at_ms = ? WHERE adoption_id = ? AND state = 'in_flight'`, `UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = $1, reject_detail = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND state = 'in_flight'`), [code, detail, nowMs, adoptionId]);
|
|
222
249
|
return res.affected === 1;
|
|
223
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* 本连接所在**数据库**的名字 = 弧锁的键空间限定符(A-010.13,理由见 {@link qualifyAdoptionLockName})。
|
|
253
|
+
*
|
|
254
|
+
* 在**交出来的那条连接**上问(不向池要第二条 —— 弧全程单连接,见 `AdoptionLogStore.getById` 的注),
|
|
255
|
+
* 结果缓存在店上:库名在一条驱动的生命周期里不会变,而每次取锁多打一个往返是白付的。
|
|
256
|
+
*
|
|
257
|
+
* 读不出来 ⇒ **响亮抛**,不回落到未限定的旧名字:那正是本条要消灭的形,静默回落等于「修了个寂寞」
|
|
258
|
+
* 且只在多部署共库的那台机器上才发作(安全轴禁静默 fail-open 的同族判据)。
|
|
259
|
+
*/
|
|
260
|
+
lockKeyspace;
|
|
261
|
+
resolveLockKeyspace(conn) {
|
|
262
|
+
// 🔴 缓存的是**这一次尝试**,而失败的那一次必须从缓存里消失(codex 对抗复审 R1 [medium],验真后修)。
|
|
263
|
+
// 病:`??=` 把 promise 本身钉住,于是**一次瞬时**的 `SELECT DATABASE()` 失败(连接抖动、库短暂不可
|
|
264
|
+
// 达)会被永久缓存 —— 此后每一条弧都复用那个已拒的 promise、连库都不再问一次。而意图行是在取锁
|
|
265
|
+
// **之前**就落库的,所以首条弧永远停在 in_flight、每次 boot 续跑再撞同一堵墙,整台副本的收编面
|
|
266
|
+
// 直到重启为止都是死的。一次抖动升级成一次停机,方向完全反了。
|
|
267
|
+
// 修:只有**解析成功**的读数留在缓存里;拒绝时清掉,下一次真的重问。
|
|
268
|
+
const attempt = (this.lockKeyspace ??= (async () => {
|
|
269
|
+
const res = await conn.query(this.q("SELECT DATABASE() AS db", "SELECT current_database() AS db"));
|
|
270
|
+
const db = res.rows[0]?.db;
|
|
271
|
+
if (typeof db !== "string" || db === "") {
|
|
272
|
+
throw new Error("adoption: could not resolve the current database name, so the arc lock's key space cannot be qualified — refusing to take an UNQUALIFIED lock (on MySQL, GET_LOCK names are server-instance global: an unqualified name would collide with any other deployment sharing this server)");
|
|
273
|
+
}
|
|
274
|
+
return db;
|
|
275
|
+
})());
|
|
276
|
+
// 清缓存要**认准这一次**(`=== attempt`):否则一次迟到的失败会把别人刚缓存好的成功读数抹掉。
|
|
277
|
+
return attempt.catch((err) => {
|
|
278
|
+
if (this.lockKeyspace === attempt)
|
|
279
|
+
this.lockKeyspace = undefined;
|
|
280
|
+
throw err;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
224
283
|
/**
|
|
225
284
|
* 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。
|
|
226
285
|
*
|
|
227
286
|
* **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发
|
|
228
287
|
* 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒
|
|
229
288
|
* 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。
|
|
289
|
+
*
|
|
290
|
+
* 🔴 入参 `name` 是**逻辑**锁名(`adoptionLockName(fromPrincipal)`);真正发给引擎的是它被
|
|
291
|
+
* {@link qualifyAdoptionLockName} 限定到本库之后的形(A-010.13:两方言键空间不等价)。限定发生在
|
|
292
|
+
* **这里**而不是调用方,因为「键空间是每库还是每实例」是**存储层**的知识,协议层(runner)不该知道。
|
|
230
293
|
*/
|
|
231
294
|
async withLock(name, fn, attempts = 20, sleepMs = 100) {
|
|
232
295
|
const conn = await this.db.connect();
|
|
233
296
|
let held = false;
|
|
297
|
+
// 🔴 在 try **之外**声明:释放腿(finally)必须用**取锁时用过的那一个**名字。第一版把它 const 在
|
|
298
|
+
// try 里,于是 finally 只能回头用未限定的 `name` —— 取的是 A、放的是 B,锁会一直持到连接回收。
|
|
299
|
+
let lockName = "";
|
|
234
300
|
try {
|
|
301
|
+
lockName = qualifyAdoptionLockName(await this.resolveLockKeyspace(conn), name);
|
|
235
302
|
for (let i = 0; i < attempts && !held; i += 1) {
|
|
236
|
-
const res = await conn.query(this.q("SELECT GET_LOCK(?, 0) AS got", "SELECT pg_try_advisory_lock($1, $2) AS got"), this.db.dialect === "tidb" ? [
|
|
303
|
+
const res = await conn.query(this.q("SELECT GET_LOCK(?, 0) AS got", "SELECT pg_try_advisory_lock($1, $2) AS got"), this.db.dialect === "tidb" ? [lockName] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(lockName)]);
|
|
237
304
|
const got = res.rows[0]?.got;
|
|
238
305
|
held = got === 1 || got === true || got === "1";
|
|
239
306
|
if (!held)
|
|
@@ -248,7 +315,7 @@ export class SqlAdoptionLogStore {
|
|
|
248
315
|
finally {
|
|
249
316
|
if (held) {
|
|
250
317
|
try {
|
|
251
|
-
await conn.query(this.q("SELECT RELEASE_LOCK(?) AS released", "SELECT pg_advisory_unlock($1, $2) AS released"), this.db.dialect === "tidb" ? [
|
|
318
|
+
await conn.query(this.q("SELECT RELEASE_LOCK(?) AS released", "SELECT pg_advisory_unlock($1, $2) AS released"), this.db.dialect === "tidb" ? [lockName] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(lockName)]);
|
|
252
319
|
}
|
|
253
320
|
catch (releaseErr) {
|
|
254
321
|
// 锁在连接关闭时会自动释放,所以释放失败不该盖掉真正的结果 —— 但同样不空吞:
|
|
@@ -308,7 +308,8 @@ export declare const APPROVAL_BATCH_TABLE = "approval_batch";
|
|
|
308
308
|
* `task_run.task_id VARCHAR(64)` 同宽(此前 255 是本家族独有的偏离,无依据)。
|
|
309
309
|
* · `session_id` → 64:提交入口硬拒 >64(`http/server.ts` "sessionId must be at most 64 characters"),
|
|
310
310
|
* 与 `session_meta`/`task_run` 同宽。
|
|
311
|
-
* · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts
|
|
311
|
+
* · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts` 的同名导出常量,
|
|
312
|
+
* 刻意不钉行号 —— 旧注写的 `security.ts:350` 已随该文件编辑漂走)硬拒更长者;
|
|
312
313
|
* 全仓每一根 owner/scope 轴都是 190,此前 255 同样是无依据偏离。
|
|
313
314
|
* · `gate_token` → 120:它抄的是 checkpoint 的 token(core `mintCheckpointToken` = 16 字节 hex = 32 字符),
|
|
314
315
|
* 取与**被抄那一列** `checkpoint.token VARCHAR(120)` 同宽 —— 同一个值在两张表上宽度必须一致,
|
|
@@ -82,7 +82,8 @@ export const APPROVAL_BATCH_TABLE = "approval_batch";
|
|
|
82
82
|
* `task_run.task_id VARCHAR(64)` 同宽(此前 255 是本家族独有的偏离,无依据)。
|
|
83
83
|
* · `session_id` → 64:提交入口硬拒 >64(`http/server.ts` "sessionId must be at most 64 characters"),
|
|
84
84
|
* 与 `session_meta`/`task_run` 同宽。
|
|
85
|
-
* · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts
|
|
85
|
+
* · `owner` → 190:`assertPrincipalShape` 的 `PRINCIPAL_MAX_LENGTH = 190`(`security.ts` 的同名导出常量,
|
|
86
|
+
* 刻意不钉行号 —— 旧注写的 `security.ts:350` 已随该文件编辑漂走)硬拒更长者;
|
|
86
87
|
* 全仓每一根 owner/scope 轴都是 190,此前 255 同样是无依据偏离。
|
|
87
88
|
* · `gate_token` → 120:它抄的是 checkpoint 的 token(core `mintCheckpointToken` = 16 字节 hex = 32 字符),
|
|
88
89
|
* 取与**被抄那一列** `checkpoint.token VARCHAR(120)` 同宽 —— 同一个值在两张表上宽度必须一致,
|