@sema-agent/server 7.11.0 → 7.13.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/README.md +1 -1
- package/USAGE.md +86 -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/auth-keys.d.ts +28 -4
- package/dist/auth-keys.js +60 -15
- package/dist/boot/parked-revive-gate.d.ts +18 -2
- package/dist/boot/parked-revive-gate.js +136 -14
- package/dist/boot/permission-rules-audit.d.ts +49 -0
- package/dist/boot/permission-rules-audit.js +85 -0
- package/dist/boot/reapers.d.ts +15 -0
- package/dist/boot/reapers.js +101 -44
- package/dist/boot/resolve-spec.js +43 -12
- package/dist/boot/runner-deps.d.ts +16 -2
- package/dist/boot/runner-deps.js +5 -4
- package/dist/budget.js +22 -0
- package/dist/config-types.d.ts +31 -11
- package/dist/config.d.ts +28 -2
- package/dist/config.js +348 -79
- package/dist/governance-ask-marks.js +8 -2
- package/dist/http/active-run-conflict.d.ts +33 -8
- package/dist/http/active-run-conflict.js +37 -2
- package/dist/http/route-ctx.d.ts +6 -3
- package/dist/http/routes/adoption.js +25 -2
- package/dist/http/routes/approvals-assistant.js +35 -4
- package/dist/http/routes/capabilities.js +69 -10
- package/dist/http/routes/images.js +18 -0
- package/dist/http/routes/rules.d.ts +19 -7
- package/dist/http/routes/rules.js +180 -4
- package/dist/http/routes/runs.js +21 -5
- package/dist/http/routes/tasks.js +18 -6
- package/dist/http/server.d.ts +30 -10
- package/dist/http/server.js +183 -19
- package/dist/http/wire-types.d.ts +48 -0
- package/dist/main.js +65 -7
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/metrics.js +2 -1
- package/dist/observability/tool-trace.d.ts +5 -1
- package/dist/observability/tool-trace.js +33 -6
- package/dist/parked-decide.d.ts +13 -3
- package/dist/parked-decide.js +10 -1
- package/dist/plugins/adoption-log-sql.d.ts +40 -0
- package/dist/plugins/adoption-log-sql.js +69 -2
- package/dist/plugins/file-run-store.d.ts +85 -1
- package/dist/plugins/file-run-store.js +450 -17
- package/dist/plugins/permission-rule-store-file.d.ts +83 -0
- package/dist/plugins/permission-rule-store-file.js +371 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +52 -0
- package/dist/plugins/permission-rule-store-sql.js +71 -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 +12 -6
- package/dist/plugins/store-backend.js +82 -10
- package/dist/rules-consent.d.ts +98 -1
- package/dist/rules-consent.js +84 -1
- package/dist/run-local.js +126 -15
- package/dist/runtime-governance.d.ts +33 -0
- package/dist/runtime-governance.js +41 -3
- package/dist/task-settings.d.ts +44 -0
- package/dist/task-settings.js +57 -1
- package/dist/tool-approval.d.ts +38 -1
- package/dist/tool-approval.js +125 -26
- package/dist/trace/core-keyset-guard.d.ts +14 -3
- package/dist/trace/project.d.ts +19 -2
- package/dist/trace/project.js +24 -4
- package/package.json +3 -3
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import { redactSecrets } from "../trace/redact.js";
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The gate sources core's `PermissionDeniedPayload.source` names. Treated as an OPEN enum on the WIRE
|
|
4
|
+
* (a core newer than this build may send a word this table has never heard of — that must not explode
|
|
5
|
+
* the Prometheus label set), but as a CLOSED SET against the core we compile with.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 core 5.23.0([3372] 提货批件②):这张表**曾是手抄的**(`new Set([...五个字面量])`,与 core 的类型
|
|
8
|
+
* 零编译期联系),于是 core 先加 `classifier`、又在 5.23.0(design/182 §7)加 `org` 时,两个**真词**都
|
|
9
|
+
* 被静默折进了 `other`。那不是「少一个标签」——`permission_denied_total{source="other"}` 的告警语义是
|
|
10
|
+
* 「引擎发了本 build 不认识的词,去对表」,真词混进来就把这个信号读废了,而 `org`(组织治理层的拒)
|
|
11
|
+
* 恰恰是运维最该单独看见的一类。
|
|
12
|
+
*
|
|
13
|
+
* ⇒ 表改为 `Record<PermissionDeniedSource, true>` 穷举:**core 增删一个词,本表编译期先红**(闭集词表
|
|
14
|
+
* 禁手抄、必须从 core 类型推导的成文纪律)。运行期配对锚在 `test/tool-trace.test.ts`(从装树 core 的
|
|
15
|
+
* `.d.ts` 解析联合词表逐词断言),因为 vitest 走 esbuild 不查类型 —— 两道锚缺一不可。
|
|
16
|
+
*/
|
|
17
|
+
const KNOWN_DENY_SOURCE_TABLE = {
|
|
18
|
+
policy: true,
|
|
19
|
+
hook: true,
|
|
20
|
+
safety: true,
|
|
21
|
+
shellGate: true,
|
|
22
|
+
planMode: true,
|
|
23
|
+
classifier: true,
|
|
24
|
+
org: true,
|
|
25
|
+
};
|
|
26
|
+
/** 表内 ⇒ 收窄成 `PermissionDeniedSource`(类型守卫)。计量点用它判「这个词是不是本 build 认得的真词」。
|
|
27
|
+
* 导出是为了让运行期锚(`test/tool-trace.test.ts`,从装树 core 的 `.d.ts` 解析真词表)**复用同一个谓词**
|
|
28
|
+
* —— 测试自己另写一份判据 = 又一张手抄表,正是本文件刚消灭的那个病。 */
|
|
29
|
+
export function isKnownDenySource(v) {
|
|
30
|
+
return typeof v === "string" && Object.hasOwn(KNOWN_DENY_SOURCE_TABLE, v);
|
|
31
|
+
}
|
|
6
32
|
/**
|
|
7
33
|
* ALWAYS-ON gate-deny meter (goal B4, 2026-07-08): `permission_denied_total{source}` — how often the
|
|
8
34
|
* adjudicate chain DENIES a tool call, by gate source. Deliberately SEPARATE from {@link createToolTracer}:
|
|
@@ -15,7 +41,7 @@ const KNOWN_DENY_SOURCES = new Set(["policy", "hook", "safety", "shellGate", "pl
|
|
|
15
41
|
export function createPermissionDeniedMeter(metrics) {
|
|
16
42
|
return {
|
|
17
43
|
permissionDenied(payload) {
|
|
18
|
-
metrics.inc("permission_denied_total", { source:
|
|
44
|
+
metrics.inc("permission_denied_total", { source: isKnownDenySource(payload.source) ? payload.source : "other" });
|
|
19
45
|
},
|
|
20
46
|
};
|
|
21
47
|
}
|
|
@@ -82,7 +108,8 @@ export function createToolTracer(logger) {
|
|
|
82
108
|
},
|
|
83
109
|
// core 1.257: the gate's DENY short-circuit never reaches postToolUse* (the call
|
|
84
110
|
// doesn't execute), so denied calls were INVISIBLE to this trace — the exact observability hole
|
|
85
|
-
// asked about. `source` = core's adjudicate-chain enum (
|
|
111
|
+
// asked about. `source` = core's adjudicate-chain enum (the closed set is `KNOWN_DENY_SOURCE_TABLE`
|
|
112
|
+
// above — deliberately NOT re-listed here, a second hand-written copy is how the meter drifted); the
|
|
86
113
|
// deny `reason` can quote the adjudicated args (an approver's text, a policy message), so redact+clip
|
|
87
114
|
// like every other line. Same `tool_trace` key so existing grep workflows see denies in sequence.
|
|
88
115
|
permissionDenied(payload) {
|
package/dist/parked-decide.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 判别必须 parked-first:legacy 腿的 `resumeStream` 会绕开 bg registry(无 consumeParkedFlip、
|
|
9
9
|
* 行永 parked、生命周期分叉)。
|
|
10
10
|
*/
|
|
11
|
-
import type { BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, TaskRegistry, ToolSpec } from "@sema-agent/core";
|
|
11
|
+
import type { ApprovalSettledBy, BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, TaskRegistry, ToolSpec } from "@sema-agent/core";
|
|
12
12
|
export interface ParkedAgentMatch {
|
|
13
13
|
handle: string;
|
|
14
14
|
row: BackgroundAgentRecord;
|
|
@@ -46,8 +46,13 @@ export interface ParkedDecideDeps {
|
|
|
46
46
|
* 解析槽一条,单层链;exempt 探针锚= row.rootSessionId,与 remember grant 锚同键)。core 1.396 起
|
|
47
47
|
* `parkedResume.inheritedGate` 席位把它透传进 resume 的官方重供通道——不供 = 重启后带
|
|
48
48
|
* requiresParentConstraint 的 checkpoint 恒被 pre-CAS 门拒(retryable 假话,永不可赎回)。链长形状
|
|
49
|
-
* 校验在 core(fail-closed,供错链长照拒)。
|
|
50
|
-
|
|
49
|
+
* 校验在 core(fail-closed,供错链长照拒)。
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ **async**(A-010.1):重建条目的第三位决议链元数据 `autoModeArmed` 与 `durableMandate` 的
|
|
52
|
+
* `forceDurableGate` 项都要按**本行的 principal** 现解 entitlement(`RunnerDeps.runtimeCapsResolver`
|
|
53
|
+
* 本身就是 sync-or-async 的座),理由全在 `boot/parked-revive-gate.ts`。本腿一直就在 async 里,
|
|
54
|
+
* 「席位必须同步」是那个文件此前自设的约束、不是结构事实。 */
|
|
55
|
+
rebuildInheritedGate?: (row: BackgroundAgentRecord) => Promise<unknown>;
|
|
51
56
|
warn?: (event: string, fields: Record<string, unknown>) => void;
|
|
52
57
|
}
|
|
53
58
|
export interface ParkedDecideRequest {
|
|
@@ -57,6 +62,11 @@ export interface ParkedDecideRequest {
|
|
|
57
62
|
/** cp.pendingAction 原样(outcome 铸造的 persisted 回落源,与 legacy 腿 D-1 姿势逐字同形)。 */
|
|
58
63
|
pendingAction: unknown;
|
|
59
64
|
decision: "approve" | "deny";
|
|
65
|
+
/** #204 件6③ core `ApprovalSettledBy`:**这次结算的出处**,由调用方命名(必填,理由与 legacy 腿
|
|
66
|
+
* `resumeCheckpoint` 的同名参数逐字同源 —— 本函数分辨不出谁在叫它,漏报要是编译错而不是假出处)。
|
|
67
|
+
* 今天唯一的调用方是 `server.ts` 的人为 `/decide`(那道门写着 `req !== undefined`,而内部 D-D SLA
|
|
68
|
+
* deny-sweep 恰是 req 缺席的那条腿,它对 parked 行另有静默跳过臂)⇒ 恒 `"human"`。 */
|
|
69
|
+
settledBy: ApprovalSettledBy;
|
|
60
70
|
reason?: string;
|
|
61
71
|
/** D-1 decision-binding echo(操作员在 GET 看到的 boundCallId/boundInputHash 原样透传;server
|
|
62
72
|
* 绝不重算,core resume 侧做 fail-closed 等值校验——与 legacy 腿同姿势)。 */
|
package/dist/parked-decide.js
CHANGED
|
@@ -97,6 +97,13 @@ export async function decideParkedAgent(deps, req) {
|
|
|
97
97
|
// ([1588] Q1:name 必传承重——省略会把 revive 降级成匿名形)。
|
|
98
98
|
return { status: 409, body: { error: "parked row carries no agent name — the revive path requires a named agent (row damaged?)", errorCode: "decide.row_unrevivable", taskId: match.handle } };
|
|
99
99
|
}
|
|
100
|
+
// [1597]/A-010.1:席位重建**在 claim 之前**解开(codex 对抗复审 R1-中 的真 finding)。
|
|
101
|
+
// 🔴 位置即正确性:claim 之后到下面那道 try/catch(它才挂 `rollbackParkedClaim`)之间是一段
|
|
102
|
+
// **无回滚保护区**。工厂现在是 async(要现解 entitlement),把 await 放进那一段 = 任何 rejection
|
|
103
|
+
// 都带着一个已认领的 claim 逃逸,行上的 `parkClaimId` 要等 stale-claim 清算窗(默认可达一小时)
|
|
104
|
+
// 才放开,这期间该审批**再决不动**。搬到 claim 前:此刻还没有任何东西需要回滚,抛错就是干净地抛错。
|
|
105
|
+
// (工厂本身只读 row 的 scope/handle/sessionId/rootSessionId —— claim 不改这四个字段,搬位零语义差。)
|
|
106
|
+
const inheritedGate = deps.rebuildInheritedGate ? await deps.rebuildInheritedGate(row) : undefined;
|
|
100
107
|
const stores = { agentStore: deps.agentStore, checkpointStore: deps.checkpointStore };
|
|
101
108
|
const claim = await deps.registry.claimParkedAgent(stores, match.handle, { scope: row.scope, owner: row.owner });
|
|
102
109
|
if (!claim.ok) {
|
|
@@ -147,6 +154,8 @@ export async function decideParkedAgent(deps, req) {
|
|
|
147
154
|
// 是两件不同的事(姊妹 approval-hmac.ts `env.reason ?? null` 同判据:`??` 只在 null/undefined 时落
|
|
148
155
|
// null,空串照样入签名载荷)。真值判定会把显式 "" 与缺席折成同一个结果,审计/签名面丢了这个区分。
|
|
149
156
|
...(req.reason !== undefined ? { reason: req.reason } : {}),
|
|
157
|
+
// #204 件6③:结算出处逐字上 outcome(见 `ParkedDecideRequest.settledBy` 顶注)。
|
|
158
|
+
settledBy: req.settledBy,
|
|
150
159
|
};
|
|
151
160
|
const ctx = {
|
|
152
161
|
toolCallId: `drv-${ticket.claimId}`,
|
|
@@ -158,7 +167,7 @@ export async function decideParkedAgent(deps, req) {
|
|
|
158
167
|
outcome,
|
|
159
168
|
// [1597] 席位:重建父约束链(同进程时 core 的内存 registry 也能自动重供,席位供了也无害
|
|
160
169
|
// ——core 侧 count 校验对得上即用;跨进程/重启形全靠这里)。
|
|
161
|
-
...(deps.rebuildInheritedGate ? { inheritedGate
|
|
170
|
+
...(deps.rebuildInheritedGate ? { inheritedGate } : {}),
|
|
162
171
|
},
|
|
163
172
|
},
|
|
164
173
|
};
|
|
@@ -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
|
// 锁在连接关闭时会自动释放,所以释放失败不该盖掉真正的结果 —— 但同样不空吞:
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* An in-memory INDEX (the same Maps MemoryRunStore holds) is hydrated on construction by scanning each runs/<taskId>/
|
|
23
23
|
* run.json + the active claim files — so listRuns/listSessions/getActiveTaskId are O(in-mem) with byte-identical keyset sort to the SQL/Memory
|
|
24
24
|
* twins. Every write touches BOTH the disk and the index on one path. Single-process (the FileStorageBackend boot lock
|
|
25
|
-
* guarantees one writer per data dir), so there is no per-op lock
|
|
25
|
+
* guarantees one writer per data dir), so there is no per-op lock — with ONE exception: #213's claim acquisition
|
|
26
|
+
* (`withClaimLock`) runs under a per-session in-process critical section, because judging a claim stale involves
|
|
27
|
+
* awaits (the checkpoint probe) and the claim + run row must commit as one unit (the SQL twin's transaction). The
|
|
28
|
+
* checkpoint-coupled suspended-run reapers run
|
|
26
29
|
* off an injected {@link RunStoreCheckpointProbe} ([868]; wired by the local backend where checkpoint + run store
|
|
27
30
|
* share one root) and degrade to honest no-ops when the probe is absent — exactly like MemoryRunStore.
|
|
28
31
|
*/
|
|
@@ -45,6 +48,10 @@ export declare class FileRunStore {
|
|
|
45
48
|
/** [868] the checkpoint-table stand-in (see memory-run-store.ts {@link RunStoreCheckpointProbe} — the full
|
|
46
49
|
* rationale lives on the interface); absent ⇒ the suspended-run reapers stay honest NO-OPs. */
|
|
47
50
|
private checkpointProbe?;
|
|
51
|
+
/** #213 —— 本实例的 claim 身份(见 {@link ClaimOwner} 的选型注)。 */
|
|
52
|
+
private readonly claimOwner;
|
|
53
|
+
/** #213 —— sessionId → 该会话 claim 获取的临界区链尾(见 {@link withClaimLock})。 */
|
|
54
|
+
private readonly claimLocks;
|
|
48
55
|
constructor(root: string);
|
|
49
56
|
private runDir;
|
|
50
57
|
private runJsonPath;
|
|
@@ -73,6 +80,8 @@ export declare class FileRunStore {
|
|
|
73
80
|
ok: false;
|
|
74
81
|
activeTaskId: string;
|
|
75
82
|
}>;
|
|
83
|
+
/** #213 —— createRun 临界区的后半段:落 claim 索引 + run 行(两者一体,行写失败即回滚 claim)。 */
|
|
84
|
+
private commitNewRun;
|
|
76
85
|
requestCancel(taskId: string, owner: string | null): Promise<boolean>;
|
|
77
86
|
isCancelRequested(taskId: string, owner: string | null): Promise<boolean>;
|
|
78
87
|
requestPreempt(taskId: string, owner: string | null): Promise<boolean>;
|
|
@@ -179,6 +188,81 @@ export declare class FileRunStore {
|
|
|
179
188
|
* pending gate is excluded by the NOT-pending clause).
|
|
180
189
|
*/
|
|
181
190
|
failSuspendedWithExpiredCheckpoint(): Promise<number>;
|
|
191
|
+
/**
|
|
192
|
+
* #213 —— 裁定/接管路径上唯一许可的取行方式:**盘优先**,盘上没有才回退内存索引。
|
|
193
|
+
*
|
|
194
|
+
* 复审 R2 [high]:反过来(索引优先)会在「同进程两只实例、两边索引都热」时出事 —— A 把 park 行
|
|
195
|
+
* `markResuming` 成 running 并消费掉 checkpoint,B 的索引里还留着**旧的 park 对象**;B 于是读不到
|
|
196
|
+
* 变化、拿自己那份陈旧对象通过复核,把 A 正在跑的行盖成 failed 再放锁。盘是唯一权威,判据只能读盘。
|
|
197
|
+
* 回退内存索引只为「行还没落盘」这一种形(理论上不该出现,留着不让判据凭空缺行)。
|
|
198
|
+
* 代价:只在 EEXIST 争用路径上多一次读文件,常路零开销。
|
|
199
|
+
*/
|
|
200
|
+
private authoritativeRow;
|
|
201
|
+
/** #213 —— 越过内存索引直接读盘上的那一行(索引可能比另一实例的写晚一步;判据必须以盘为准)。
|
|
202
|
+
* 读不出/坏行 ⇒ undefined,与 hydrate 的跳过口径一致。 */
|
|
203
|
+
private readRowFromDisk;
|
|
204
|
+
/**
|
|
205
|
+
* A1/codex R2 —— 盘上扫该会话最新的非终局行:撕裂 claim(解析不出记录)唯一还能认主的通道。
|
|
206
|
+
* 内存索引不可用作判据 —— 同进程冷索引姊妹实例的行可能晚于本实例 hydrate 才落盘(L9/[1684] 同族),
|
|
207
|
+
* 判据必须以盘为准。O(盘上 run 数),只在「claim 解析不出记录」这条罕见路径上走,常路零开销。
|
|
208
|
+
* 行的读法与 hydrate/readRowFromDisk 同一套宽容口径(坏行跳过,不毒判定)。
|
|
209
|
+
*/
|
|
210
|
+
private newestNonTerminalRowOnDisk;
|
|
211
|
+
/** 本进程要落盘的 claim 形(带持有者身份 —— #213 的活性证据)。 */
|
|
212
|
+
private claimRecord;
|
|
213
|
+
/**
|
|
214
|
+
* #213 —— **每会话**的 claim 获取临界区。
|
|
215
|
+
*
|
|
216
|
+
* 为什么必须有:接管的判定链里有 `await`(checkpoint probe 的两条 EXISTS 谓词),两条并发 `createRun`
|
|
217
|
+
* 会在同一枚陈旧 claim 上**各判各的**、然后**各接管各的** —— 第二条的 `rename` 搬走的是第一条刚写下的
|
|
218
|
+
* **新** claim,于是两条都拿到 ok:true,单活不变式被并发撕开。(这不是纸面推演:L7 那一格先红,就是
|
|
219
|
+
* 这条路径。`rename` 只保证「同一个源路径只成功一次」,它**不是**对被判定那份内容的 CAS。)
|
|
220
|
+
*
|
|
221
|
+
* 为什么进程内互斥就够:这条车道是「一个数据根一个写者」——`FileStorageBackend` 的 `root/LOCK` 让第二个
|
|
222
|
+
* 活进程 fail fast。跨进程并发在本车道**不存在**;而这正是接管判据本身所依赖的同一条不变式
|
|
223
|
+
* (`reclaimOrphanedAtBoot` 也全靠它),所以这里没有引入新的假设。`rename` 仍然留着当廉价二道闸:
|
|
224
|
+
* 判定与接管之间 claim 若已被别腿正常释放,它给 ENOENT,整轮重来。
|
|
225
|
+
*
|
|
226
|
+
* 形状:每 session 一条 promise 链,后来者 await 前一位;链尾归零时删表项(不无界增长)。
|
|
227
|
+
*/
|
|
228
|
+
private withClaimLock;
|
|
229
|
+
/** #213 —— 临界区内的 claim 获取本体:铸不下就裁定持有者,判陈旧则接管后重铸。有界重试(论证见 takeOverClaim)。 */
|
|
230
|
+
private acquireClaim;
|
|
231
|
+
/**
|
|
232
|
+
* #213 —— 撞上 EEXIST 之后,对**现持有者**的裁定。默认方向是 `live`(挡住):只有能**证明**
|
|
233
|
+
* 持有者已死的形才判 `stale`。证不出来一律 fail-closed —— 单活不变式比自愈更重要。
|
|
234
|
+
*
|
|
235
|
+
* 三族:
|
|
236
|
+
* ① 行不在 / 行已终局 —— claim 是残骸(R9 回滚失手、跨进程窗)。`sweepClaims` 本该收掉,
|
|
237
|
+
* 它只在 boot 与几条 reaper 上跑;这里就地补齐。
|
|
238
|
+
* ② 行 `running` —— 有没有在飞的驱动腿,取决于**谁**写的这枚 claim:本进程写的 ⇒ 驱动腿就在
|
|
239
|
+
* 本进程内存里 ⇒ 真活主;别的进程写的 ⇒ 那个进程已死(boot 单写者锁作证)⇒ 驱动腿随它一起没了,
|
|
240
|
+
* 行永远不会自己走到终局。这与 `reclaimOrphanedAtBoot` 的判决**逐字同源**,只是时点不同。
|
|
241
|
+
* ③ 行 `suspended`/`needs_review`(park)—— park 按契约**没有在飞的腿**(腿返回 suspended 后就退出了,
|
|
242
|
+
* resume 腿会先 `markResuming` 翻回 running),所以这一族与「谁写的」无关,只问一件事:
|
|
243
|
+
* **还有东西可续吗**。有 pending ⇒ 可续(409 体里的 pendingGate 就是出路);有 expired ⇒ 归
|
|
244
|
+
* [868] `failSuspendedWithExpiredCheckpoint` 那条腿收(它要落 `approval.expired` 这个终局语义,
|
|
245
|
+
* 本函数不许抢);两者皆无 ⇒ 这条 park 谁也续不动,而**没有任何一条排期腿够得着它**
|
|
246
|
+
* (reapStale 只碰 running;boot 回收刻意跳过 park;时间腿 reapSuspended 挂 APPROVAL_TIMEOUT_SEC,
|
|
247
|
+
* 默认 0 = 不排期)⇒ 永久占用,判 stale。probe 缺席 ⇒ 证不出「无可续」⇒ fail-closed 判 live。
|
|
248
|
+
*/
|
|
249
|
+
private judgeClaim;
|
|
250
|
+
/**
|
|
251
|
+
* #213 —— 接管一枚已判定陈旧的 claim。返回 false = 现场已经变了 / 抢输了,调用方整轮重来。
|
|
252
|
+
* **整段同步**(无 await)⇒ 进程内不可被打断;跨进程由 rename 与单写者锁兜。
|
|
253
|
+
*
|
|
254
|
+
* ① **复核先行**(复审 codex R1 [critical]):judgeClaim 的 probe await 期间,resume 腿可能已经
|
|
255
|
+
* `markResuming`、终局腿可能已经放锁重铸。所以第一件事是拿判定时的快照逐字比对**盘上现状**
|
|
256
|
+
* (claim 的 taskId+owner nonce、行的 status+updatedAt)。任何一项动了 ⇒ 判决作废,不许动手。
|
|
257
|
+
* ② **先把行写死,再拆 claim**(复审 codex R1 [medium]):次序反过来的话,「claim 已拆、行还 park」
|
|
258
|
+
* 这半拍崩溃会留下一条**永远 park** 的行 —— boot 回收刻意跳过 park、sweepClaims 又没有 claim 可循,
|
|
259
|
+
* 没人收得掉。现在的次序里,同一处崩溃留下的是「行已终局 + claim 还在」,而那正是 `sweepClaims`
|
|
260
|
+
* 每次 boot / 每条 reaper 都会扫掉的形 —— 崩在哪一步都自愈。
|
|
261
|
+
* ③ 拆 claim 用 `renameSync(claim → tmp/隔离名)`:POSIX `rename` 对同一个源路径只可能**成功一次**,
|
|
262
|
+
* 并发的第二个 racer 拿 ENOENT。隔离件落在 `tmp/` 而不是 `runs/active/` —— `hydrate()` 把 activeDir 里的
|
|
263
|
+
* **每个文件**都当 claim 读,隔离件留在那儿就等于把刚拆掉的幽灵在下次 boot 原样复活。
|
|
264
|
+
*/
|
|
265
|
+
private takeOverClaim;
|
|
182
266
|
/** Release a session's single-active claim: unlink the claim file + drop the index entry (idempotent). */
|
|
183
267
|
private releaseClaim;
|
|
184
268
|
/** Drop a run entirely (registry + event log + open fd + on-disk dir) — used by deleteBySession. */
|