@sema-agent/server 7.47.0 → 7.48.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 +10 -0
- package/dist/boot/stores.js +13 -0
- package/dist/config.d.ts +4 -1
- package/dist/config.js +15 -1
- package/dist/fleet/fleet-bus.d.ts +93 -6
- package/dist/fleet/fleet-bus.js +40 -1
- package/dist/hooks/hook-runner.d.ts +10 -10
- package/dist/hooks/hook-runner.js +142 -23
- package/dist/http/route-ctx.d.ts +24 -0
- package/dist/http/route-ctx.js +8 -0
- package/dist/http/routes/approvals-assistant.js +4 -11
- package/dist/http/routes/capabilities.js +1 -1
- package/dist/http/routes/fleet.js +2 -2
- package/dist/http/routes/notify-wake.js +2 -3
- package/dist/http/routes/runs.js +26 -2
- package/dist/http/routes/tasks.js +5 -0
- package/dist/http/server.js +4 -0
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/plugins/approval-ask-store-memory.js +2 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +50 -1
- package/dist/plugins/approval-ask-store-sql.js +40 -5
- package/dist/plugins/permission-rule-store-sql.d.ts +33 -0
- package/dist/plugins/permission-rule-store-sql.js +1 -8
- package/dist/plugins/sql-errors.d.ts +18 -0
- package/dist/plugins/sql-errors.js +10 -0
- package/dist/resource-window.d.ts +111 -0
- package/dist/resource-window.js +80 -0
- package/dist/rules-consent.d.ts +29 -1
- package/dist/rules-consent.js +22 -1
- package/dist/tool-approval.d.ts +47 -1
- package/dist/tool-approval.js +131 -11
- package/package.json +1 -1
|
@@ -105,6 +105,10 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
105
105
|
readonly cls: "F";
|
|
106
106
|
readonly note: "#193 车5(staleness-sweep P1-3):OTLP 周期导出失败(HTTP 非 2xx 或 fetch 拒绝)⇒ 丢弃本轮快照继续服务。导出本就 best-effort(collector 打嗝绝不影响 serving,方向不改),但此前唯一留痕是装配层 onError 的一条 warn——**metrics 轴零信号**,而 USAGE 力荐的 Prometheus-only 部署恰好只看 metrics ⇒ collector 持续宕机对运维完全不可见(遥测腿自盲)。放行的最坏后果 = 一段时间的指标断供;计数让「断供正在发生」本身成为一条指标。";
|
|
107
107
|
};
|
|
108
|
+
readonly "server.runs.cross-slice-usage-unavailable": {
|
|
109
|
+
readonly cls: "F";
|
|
110
|
+
readonly note: "[3833] S-4:`GET /v1/runs/:id` 的 running 态跨片用量投影(`crossSliceUsage`)在**已登记跨片窗**的 run 上,因账本读不可用而整键缺席 —— 两形:`runStore.getEvents` 抛(store 抖动/连接断),或降级/部分实现的 store 根本没有 `getEvents`。放行的最坏后果 = **预警面缺料**:运维读不到「已耗 / 上限」这一对,退回既有的事后姿势(等行翻 `suspended` + `resource_limit` gate 才知道逼近过)——不参与任何门/CAS/resume 判定(跨片额度的执法读的是 core 挂在 checkpoint 行上的 `ResourceLedger`,不经本投影),故 F 类。**方向刻意选缺席而不是铸零**:`spentTokens:0` / `spentMicroUsd:0` 在观测不到用量的那一刻恰好长得像「还没花钱」,是比缺席更坏的假象(RB-368「不知道≠免费」同一条纪律)。留痕理由:缺席与「这条 run 本来就没配跨片窗」在 wire 上完全同形,不计数则「投影为什么一直不出现」只能靠间接症状发现。红先=`test/resource-suspend.test.ts` 的 S-4 账本不可用两格(读抛 / 店无 getEvents ⇒ 计数 +1 且**无**零值投影)";
|
|
111
|
+
};
|
|
108
112
|
readonly "server.approvals.active-run-conflict-material-unavailable": {
|
|
109
113
|
readonly cls: "F";
|
|
110
114
|
readonly note: "A-057.47:`buildActiveRunConflict` 的材料装配段(getRun / turnActivity / peekPendingScope / findPendingTokenBySession / checkpoint `get`)任一失败 ⇒ 整段退化成裸 409(只剩 error + errorCode + activeTaskId),`activeTaskStatus` / `msSinceLastActivity` / `pendingGate`(kind·decidePath·governanceForced·checkpointId)全部蒸发。放行的最坏后果 = **展示/分诊**面缺料:壳画不出「这条 run 卡在哪道门、去哪儿决」,人退回 `POST /v1/runs/{activeTaskId}/cancel` 这条保底真路 —— 不参与任何门/CAS/resume 判定(执法面读的是 checkpoint blob 的 `get()`,不经本材料;与 census 第 21 行同一条判据),故 F 类。方向刻意不改:本材料是 best-effort 增强,让它抛会把一次 store 抖动变成整个 409 路径的 500(顶注第 11 行逐字成文「store 面任何失败都不得挡 409 本体」)。必须留痕的理由:本文件零 logger 席、pool 层无 per-query 错误日志、调用侧(tasks.ts / runs.ts / server.ts 五处)因本函数恒不 reject 结构上拿不到信号 ⇒ 「approval 出路材料系统性丢失」此前在遥测里与「一切正常」同形;滚动升级期 `checkpoint-store-sql.ts` 的 version-too-new throw 与 strict parseJson 也一并被吞在这里。";
|
|
@@ -117,6 +121,10 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
117
121
|
readonly cls: "F";
|
|
118
122
|
readonly note: "#310:`engine_notice` 分流器把一条白名单通告投给某条 run 腿的登记口时,那只口抛了(live 口写向已断/已撕裂的 SSE socket,或 durable 口的账本写同步抛)。放行的最坏后果 = **这一条通告的这一个终点**缺席:①日志终点在分流之前已经逐字打过(事实一条不丢);②两个终点注册成两只独立 sink ⇒ live 抛不牵连 durable(断连后仍看得见的那半保住);③同会话其余口照投。故 F 类。不放行的代价是把异常回抛给 core 的 `deliverEngineNotice` —— 它会吞掉,于是同一次失败**既没有留痕也没有第二只口**,正是本 tag 要根除的形。留痕是承重的:静默吞掉之后「wire 腿为什么总有几条通告不到」在遥测里与「core 本来就没发」同形。";
|
|
119
123
|
};
|
|
124
|
+
readonly "server.hooks.notice-sink-threw": {
|
|
125
|
+
readonly cls: "F";
|
|
126
|
+
readonly note: "#281:hook 观测回调(`onHookNotice`,部署把它接到 fleet 流)自己抛了。放行的最坏后果 = **这一条观测帧**缺席;补偿是结构性的:①日志终点在投递之前就已逐字落定(`hook_command_failed` / `hook_command_timeout` / `hook_command_spawn_failed` 一族),运维面零回退;②观测帧不参与任何门/裁决(纯 observe,#281 全族判据里「裁决面一个字节不动」是承重条款),故 F 类。不放行的代价严重不对称:异常会掀回 core 的 hook 回调栈,把一次**观察动作**变成一次**工具调用失败** —— 「观察一件事的动作反过来改变了那件事」正是本 tag 要根除的形,而它伤的恰恰是 hook 已经坏掉、用户最需要任务照跑的那一刻。留痕承重:静默吞掉之后「这台机器为什么一条 hook 通知都没有」在遥测里与「hook 从来没坏过」同形。";
|
|
127
|
+
};
|
|
120
128
|
readonly "server.park.toolcallid-read-failed": {
|
|
121
129
|
readonly cls: "F";
|
|
122
130
|
readonly note: "[4913]:durable park 投影腿在 strip 之前拿结果里的 checkpointToken 换 `pendingAction.toolCallId`(cs.get 一次读),那次读**抛了**(store 抖动 / 滚动升级期 checkpoint 格式 version guard / 行已被并发消费)⇒ 本次 park 的 wire 投影**缺 `toolCallId` 键**,其余键(gate/checkpointId)照旧。放行的最坏后果 = 消费方(cli 判别子 v3 写者②)退回该键到货前的行为——同族多兄弟 durable park 分不出主角、诚实全不标(cli 侧 S3 局限,成文的旧现状),纯展示/关联面,不参与任何门/CAS/resume 判定,故 F 类。绝不挡终局路径是承重的:park 投影点全在 done/suspended 事件写链上,让它抛会把一次 store 抖动升级成整条 run 终局写失败。必须留痕:键缺席与「tool-less park 本就无键」([1995]② OMITTED 契约)在 wire 上同形,不计数则「投影为什么总缺」在遥测里永不显形。";
|
|
@@ -96,6 +96,10 @@ export const FAIL_OPEN_TAGS = {
|
|
|
96
96
|
cls: "F",
|
|
97
97
|
note: "#193 车5(staleness-sweep P1-3):OTLP 周期导出失败(HTTP 非 2xx 或 fetch 拒绝)⇒ 丢弃本轮快照继续服务。导出本就 best-effort(collector 打嗝绝不影响 serving,方向不改),但此前唯一留痕是装配层 onError 的一条 warn——**metrics 轴零信号**,而 USAGE 力荐的 Prometheus-only 部署恰好只看 metrics ⇒ collector 持续宕机对运维完全不可见(遥测腿自盲)。放行的最坏后果 = 一段时间的指标断供;计数让「断供正在发生」本身成为一条指标。",
|
|
98
98
|
},
|
|
99
|
+
"server.runs.cross-slice-usage-unavailable": {
|
|
100
|
+
cls: "F",
|
|
101
|
+
note: "[3833] S-4:`GET /v1/runs/:id` 的 running 态跨片用量投影(`crossSliceUsage`)在**已登记跨片窗**的 run 上,因账本读不可用而整键缺席 —— 两形:`runStore.getEvents` 抛(store 抖动/连接断),或降级/部分实现的 store 根本没有 `getEvents`。放行的最坏后果 = **预警面缺料**:运维读不到「已耗 / 上限」这一对,退回既有的事后姿势(等行翻 `suspended` + `resource_limit` gate 才知道逼近过)——不参与任何门/CAS/resume 判定(跨片额度的执法读的是 core 挂在 checkpoint 行上的 `ResourceLedger`,不经本投影),故 F 类。**方向刻意选缺席而不是铸零**:`spentTokens:0` / `spentMicroUsd:0` 在观测不到用量的那一刻恰好长得像「还没花钱」,是比缺席更坏的假象(RB-368「不知道≠免费」同一条纪律)。留痕理由:缺席与「这条 run 本来就没配跨片窗」在 wire 上完全同形,不计数则「投影为什么一直不出现」只能靠间接症状发现。红先=`test/resource-suspend.test.ts` 的 S-4 账本不可用两格(读抛 / 店无 getEvents ⇒ 计数 +1 且**无**零值投影)",
|
|
102
|
+
},
|
|
99
103
|
"server.approvals.active-run-conflict-material-unavailable": {
|
|
100
104
|
cls: "F",
|
|
101
105
|
note: "A-057.47:`buildActiveRunConflict` 的材料装配段(getRun / turnActivity / peekPendingScope / findPendingTokenBySession / checkpoint `get`)任一失败 ⇒ 整段退化成裸 409(只剩 error + errorCode + activeTaskId),`activeTaskStatus` / `msSinceLastActivity` / `pendingGate`(kind·decidePath·governanceForced·checkpointId)全部蒸发。放行的最坏后果 = **展示/分诊**面缺料:壳画不出「这条 run 卡在哪道门、去哪儿决」,人退回 `POST /v1/runs/{activeTaskId}/cancel` 这条保底真路 —— 不参与任何门/CAS/resume 判定(执法面读的是 checkpoint blob 的 `get()`,不经本材料;与 census 第 21 行同一条判据),故 F 类。方向刻意不改:本材料是 best-effort 增强,让它抛会把一次 store 抖动变成整个 409 路径的 500(顶注第 11 行逐字成文「store 面任何失败都不得挡 409 本体」)。必须留痕的理由:本文件零 logger 席、pool 层无 per-query 错误日志、调用侧(tasks.ts / runs.ts / server.ts 五处)因本函数恒不 reject 结构上拿不到信号 ⇒ 「approval 出路材料系统性丢失」此前在遥测里与「一切正常」同形;滚动升级期 `checkpoint-store-sql.ts` 的 version-too-new throw 与 strict parseJson 也一并被吞在这里。",
|
|
@@ -108,6 +112,10 @@ export const FAIL_OPEN_TAGS = {
|
|
|
108
112
|
cls: "F",
|
|
109
113
|
note: "#310:`engine_notice` 分流器把一条白名单通告投给某条 run 腿的登记口时,那只口抛了(live 口写向已断/已撕裂的 SSE socket,或 durable 口的账本写同步抛)。放行的最坏后果 = **这一条通告的这一个终点**缺席:①日志终点在分流之前已经逐字打过(事实一条不丢);②两个终点注册成两只独立 sink ⇒ live 抛不牵连 durable(断连后仍看得见的那半保住);③同会话其余口照投。故 F 类。不放行的代价是把异常回抛给 core 的 `deliverEngineNotice` —— 它会吞掉,于是同一次失败**既没有留痕也没有第二只口**,正是本 tag 要根除的形。留痕是承重的:静默吞掉之后「wire 腿为什么总有几条通告不到」在遥测里与「core 本来就没发」同形。",
|
|
110
114
|
},
|
|
115
|
+
"server.hooks.notice-sink-threw": {
|
|
116
|
+
cls: "F",
|
|
117
|
+
note: "#281:hook 观测回调(`onHookNotice`,部署把它接到 fleet 流)自己抛了。放行的最坏后果 = **这一条观测帧**缺席;补偿是结构性的:①日志终点在投递之前就已逐字落定(`hook_command_failed` / `hook_command_timeout` / `hook_command_spawn_failed` 一族),运维面零回退;②观测帧不参与任何门/裁决(纯 observe,#281 全族判据里「裁决面一个字节不动」是承重条款),故 F 类。不放行的代价严重不对称:异常会掀回 core 的 hook 回调栈,把一次**观察动作**变成一次**工具调用失败** —— 「观察一件事的动作反过来改变了那件事」正是本 tag 要根除的形,而它伤的恰恰是 hook 已经坏掉、用户最需要任务照跑的那一刻。留痕承重:静默吞掉之后「这台机器为什么一条 hook 通知都没有」在遥测里与「hook 从来没坏过」同形。",
|
|
118
|
+
},
|
|
111
119
|
"server.park.toolcallid-read-failed": {
|
|
112
120
|
cls: "F",
|
|
113
121
|
note: "[4913]:durable park 投影腿在 strip 之前拿结果里的 checkpointToken 换 `pendingAction.toolCallId`(cs.get 一次读),那次读**抛了**(store 抖动 / 滚动升级期 checkpoint 格式 version guard / 行已被并发消费)⇒ 本次 park 的 wire 投影**缺 `toolCallId` 键**,其余键(gate/checkpointId)照旧。放行的最坏后果 = 消费方(cli 判别子 v3 写者②)退回该键到货前的行为——同族多兄弟 durable park 分不出主角、诚实全不标(cli 侧 S3 局限,成文的旧现状),纯展示/关联面,不参与任何门/CAS/resume 判定,故 F 类。绝不挡终局路径是承重的:park 投影点全在 done/suspended 事件写链上,让它抛会把一次 store 抖动升级成整条 run 终局写失败。必须留痕:键缺席与「tool-less park 本就无键」([1995]② OMITTED 契约)在 wire 上同形,不计数则「投影为什么总缺」在遥测里永不显形。",
|
|
@@ -62,6 +62,8 @@ export class InMemoryApprovalAskStore {
|
|
|
62
62
|
gateBoundInputHash: null,
|
|
63
63
|
idempotencyKey: null,
|
|
64
64
|
cardJson: row.cardJson,
|
|
65
|
+
ruleCommand: row.ruleCommand ?? null,
|
|
66
|
+
ruleScopeRoot: row.ruleScopeRoot ?? null,
|
|
65
67
|
schemaVersion: row.schemaVersion,
|
|
66
68
|
expiresAtMs: row.expiresAtMs,
|
|
67
69
|
createdAtMs: row.createdAtMs,
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
40
40
|
import type { Pool as PgPool } from "pg";
|
|
41
41
|
import type { PgQueryFn } from "./pg-query.js";
|
|
42
|
-
import { type SqlDriver } from "./sql-driver.js";
|
|
42
|
+
import { type SqlDriver, type SqlDialect } from "./sql-driver.js";
|
|
43
43
|
import { type AskState, type BatchState } from "../approval-ask-machine.js";
|
|
44
44
|
export type AskDecision = "approve" | "deny";
|
|
45
45
|
export interface AskRow {
|
|
@@ -94,6 +94,26 @@ export interface AskRow {
|
|
|
94
94
|
idempotencyKey: string | null;
|
|
95
95
|
/** 重放腿的帧载荷(args 已帽已洗后的投影)。 */
|
|
96
96
|
cardJson: unknown;
|
|
97
|
+
/**
|
|
98
|
+
* #363 —— **规则车道素材**两列之一:这只 ask 被裁决的**命令**(已过基础脱敏,见 `tool-approval.ts`
|
|
99
|
+
* 的落盘点)。`null` = 这只 ask 从没进过规则车道(治理档 / 无属主 / 引擎没铸候选 / args 读不出命令),
|
|
100
|
+
* 或它由**加这两列之前**的构建落的行。
|
|
101
|
+
*
|
|
102
|
+
* 🔴 **为什么必须是行上的一列,而不是从 `card_json` 里挖**:卡上有的是 `ruleOffers[].command`
|
|
103
|
+
* ——那是**每条候选**的去规范化命令(batch 臂上是**分段**),不是被裁决的那一条整命令;而
|
|
104
|
+
* `persistCardRule` 的覆盖门与候选重铸吃的正是后者。拿分段当整命令喂进去 = 让一条**我们自己切过**
|
|
105
|
+
* 的命令去决定一条常驻放行规则的形状。
|
|
106
|
+
*
|
|
107
|
+
* 🔴 **LONGTEXT/TEXT,不是 VARCHAR**(与 `permission_rule_approval.command` 同款同理由):命令行没有
|
|
108
|
+
* 入口上限,VARCHAR 会造一个静默截断面,而截断过的命令与原命令是两条不同的命令。
|
|
109
|
+
*/
|
|
110
|
+
ruleCommand: string | null;
|
|
111
|
+
/**
|
|
112
|
+
* #363 —— 规则车道素材两列之二:**授权发生地**的 project root(`ruleScopeRootFor` 在**铸卡时**咨询
|
|
113
|
+
* 的那一份,#295 F-1 的快照语义)。`null` = 解析不出 root ⇒ 兑付时**不铸 scope**,core 落 global
|
|
114
|
+
* (= live 腿逐字同形;绝不在坐标系不明时编一个 root)。
|
|
115
|
+
*/
|
|
116
|
+
ruleScopeRoot: string | null;
|
|
97
117
|
schemaVersion: number;
|
|
98
118
|
/** 一次铸定永不重算。 */
|
|
99
119
|
expiresAtMs: number;
|
|
@@ -114,6 +134,11 @@ export interface NewAskRow {
|
|
|
114
134
|
/** 车5 §9 C2 的对账 join 键(语义见 `AskRow.boundInputHash`)。缺席 ⇒ 该行结构上永不满足判据 1。 */
|
|
115
135
|
boundInputHash?: string | null;
|
|
116
136
|
cardJson: unknown;
|
|
137
|
+
/** #363 规则车道素材(语义与「为什么是行上的列」见 {@link AskRow.ruleCommand})。缺席 ⇒ 列 NULL ⇒
|
|
138
|
+
* 这条行上的迟到决议对 `persistRule` 响亮拒(`rule_material_absent`),绝不猜命令。 */
|
|
139
|
+
ruleCommand?: string | null;
|
|
140
|
+
/** #363 授权发生地(语义见 {@link AskRow.ruleScopeRoot})。 */
|
|
141
|
+
ruleScopeRoot?: string | null;
|
|
117
142
|
schemaVersion: number;
|
|
118
143
|
expiresAtMs: number;
|
|
119
144
|
createdAtMs: number;
|
|
@@ -337,6 +362,30 @@ export declare const TIDB_APPROVAL_ASK_STATEMENTS: readonly string[];
|
|
|
337
362
|
* (那里展开了同一份数组),本函数留给只需要这两张表的集成测试。 */
|
|
338
363
|
export declare function ensureTiDBApprovalAskSchema(pool: MySqlPool): Promise<void>;
|
|
339
364
|
export declare function ensurePgApprovalAskSchema(q: PgQueryFn): Promise<void>;
|
|
365
|
+
/**
|
|
366
|
+
* #363 升级前置断言 —— **拒启**,不是 warn(形照 #340 的 `assertPermissionRuleApprovalSchema` /
|
|
367
|
+
* #119 的 `assertToolResultProvenanceSchema` 两条先例)。
|
|
368
|
+
*
|
|
369
|
+
* 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升上来
|
|
370
|
+
* 的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `approval_ask` 是空操作 —— 本车新加的
|
|
371
|
+
* `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `ASK_COLS` 的每一条读
|
|
372
|
+
* **无条件**引用它们。后果不是「少一个新功能」,是**整条流内审批协议**在那台机器上是坏的:
|
|
373
|
+
* · 每一只 ask 的 `ensureAsk` 撞 unknown column ⇒ 被 `noteStoreError` 吞掉 ⇒ 整只 ask 退回 park 路由
|
|
374
|
+
* (卡不落库、`GET /v1/approvals` 的重放面恒空、迟到决议腿恒 404);
|
|
375
|
+
* · 而 `/v1/capabilities` 的 `streamApproval` 仍然报 active ⇒「says yes ⟺ route works」整句为假。
|
|
376
|
+
* 没有这道断言,运维得不到**任何**「这次升级必须重建表」的信号 —— 正是 #157「禁静默降级」要挡的形。
|
|
377
|
+
*
|
|
378
|
+
* 判据 = **能力探测**(恒空的 `WHERE 1=0` 读)+ **方言的缺列错误码**(判据属主 `sql-errors.ts` 的
|
|
379
|
+
* {@link isMissingColumnError});其余错误**原样 rethrow**,一个字都不加工 —— 一句破坏性的
|
|
380
|
+
* 「去 DROP TABLE」指路比它要挡的缺陷更贵(#340 那两轮收窄的结论逐字继承)。
|
|
381
|
+
*
|
|
382
|
+
* 🔴 位置与两条先例同款:**不在** `ensureSchema` 里(那条通道的契约是「只发 CREATE」,有运行时门看着),
|
|
383
|
+
* 放在 DDL 之后、任何路由装配之前;且**只在流内审批协议真会上场时**跑(判据 = 与协调器同一个符号
|
|
384
|
+
* `resolveStreamApprovalGate`,见调用点)——协议不上场的部署根本不碰这张表,为它拒启是纯误伤。
|
|
385
|
+
*/
|
|
386
|
+
export declare function assertApprovalAskRuleMaterialSchema(query: (sql: string) => Promise<{
|
|
387
|
+
rows: Record<string, unknown>[];
|
|
388
|
+
}>, dialect: SqlDialect): Promise<void>;
|
|
340
389
|
/** 双方言 `ApprovalAskStore`。见文件头注的 CAS 铁则 + 方言差异清单。 */
|
|
341
390
|
export declare class SqlApprovalAskStore implements ApprovalAskStore {
|
|
342
391
|
private readonly db;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
2
2
|
import { canAskTransition } from "../approval-ask-machine.js";
|
|
3
|
-
import { isDupKeyError } from "./sql-errors.js";
|
|
3
|
+
import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
|
|
4
4
|
export function assertIdempotencyKeyShape(key) {
|
|
5
5
|
if (key.length === 0 || key.length > 255 || key.trim() !== key) {
|
|
6
6
|
throw new Error(`invalid idempotency key: must be 1-255 chars with no leading/trailing whitespace (got ${JSON.stringify(key.slice(0, 32))}${key.length > 32 ? "…" : ""}, length ${key.length})`);
|
|
@@ -79,6 +79,13 @@ export const TIDB_APPROVAL_ASK_STATEMENTS = [
|
|
|
79
79
|
idempotency_key VARCHAR(255) NULL,
|
|
80
80
|
-- LONGTEXT 照 image-bake/background-agent 的 pending_steer 先例:重放帧载荷可能超 64K TEXT 墙。
|
|
81
81
|
card_json LONGTEXT NOT NULL,
|
|
82
|
+
-- #363 规则车道素材两列(语义/类型理由逐字见 AskRow.ruleCommand / ruleScopeRoot 的顶注)。
|
|
83
|
+
-- rule_command 与 permission_rule_approval.command 同款 LONGTEXT:命令行没有入口上限,VARCHAR
|
|
84
|
+
-- 会造一个静默截断面,而截断过的命令与原命令是两条不同的命令(覆盖门会据一条我们自己改过的命令判)。
|
|
85
|
+
rule_command LONGTEXT NULL,
|
|
86
|
+
-- rule_scope_root:root 的真上界 = task-cwd.ts 的 MAX_CWD_CHARS(4096,入口 isValidCwd 硬拒更长者),
|
|
87
|
+
-- 宽度与那把尺同源 —— 与 MAX_RULE_SCOPE_CHARS 的取值法(由铸侧的尺派生)逐字同精神。
|
|
88
|
+
rule_scope_root VARCHAR(4096) NULL,
|
|
82
89
|
schema_version INT NOT NULL,
|
|
83
90
|
expires_at_ms BIGINT NOT NULL,
|
|
84
91
|
created_at_ms BIGINT NOT NULL,
|
|
@@ -140,6 +147,9 @@ export async function ensurePgApprovalAskSchema(q) {
|
|
|
140
147
|
gate_bound_input_hash VARCHAR(255) COLLATE "C",
|
|
141
148
|
idempotency_key VARCHAR(255) COLLATE "C",
|
|
142
149
|
card_json TEXT COLLATE "C" NOT NULL,
|
|
150
|
+
-- #363 规则车道素材两列 —— MySQL 孪生逐字同形(类型/宽度的取值理由写在那侧的行内注)。
|
|
151
|
+
rule_command TEXT COLLATE "C",
|
|
152
|
+
rule_scope_root VARCHAR(4096) COLLATE "C",
|
|
143
153
|
schema_version INT NOT NULL,
|
|
144
154
|
expires_at_ms BIGINT NOT NULL,
|
|
145
155
|
created_at_ms BIGINT NOT NULL,
|
|
@@ -153,9 +163,30 @@ export async function ensurePgApprovalAskSchema(q) {
|
|
|
153
163
|
await q(`CREATE INDEX IF NOT EXISTS idx_ask_batch ON ${APPROVAL_ASK_TABLE} (batch_id)`);
|
|
154
164
|
await q(`CREATE INDEX IF NOT EXISTS idx_ask_state ON ${APPROVAL_ASK_TABLE} (state)`);
|
|
155
165
|
}
|
|
166
|
+
export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
|
|
167
|
+
try {
|
|
168
|
+
await query(`SELECT rule_command, rule_scope_root FROM ${APPROVAL_ASK_TABLE} WHERE 1=0`);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (!isMissingColumnError(err, dialect))
|
|
172
|
+
throw err;
|
|
173
|
+
throw new Error(`${APPROVAL_ASK_TABLE} is missing the #363 rule-material columns (rule_command / rule_scope_root) — refusing to start. ` +
|
|
174
|
+
`A parked approval's late decision can now carry \`persistRule\` ("don't ask again"), and the row is where the ` +
|
|
175
|
+
`adjudicated command and the project root of the authorization site live; every ask-row write and read names ` +
|
|
176
|
+
`both columns unconditionally, so on this table the whole in-stream approval protocol degrades silently (no card ` +
|
|
177
|
+
`is persisted, the replay list stays empty, late decisions 404) while /v1/capabilities still reports it active. ` +
|
|
178
|
+
`This repository ships no ALTER TABLE migrations, so the table must be recreated: run ` +
|
|
179
|
+
`\`DROP TABLE ${APPROVAL_ASK_TABLE}; DROP TABLE ${APPROVAL_BATCH_TABLE};\` on the ` +
|
|
180
|
+
`${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and restart — the schema is recreated at boot. ` +
|
|
181
|
+
`The cost is bounded: these two tables hold IN-FLIGHT approval asks (cards awaiting an answer) plus settled ` +
|
|
182
|
+
`bookkeeping; the durable park face (checkpoint rows) and the persisted rules themselves are untouched, so an ` +
|
|
183
|
+
`in-flight card falls back to the durable gate (\`POST /v1/approvals/:sessionId/decide\`). ` +
|
|
184
|
+
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
156
187
|
const ASK_COLS = "ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, " +
|
|
157
188
|
"provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, " +
|
|
158
|
-
"gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, " +
|
|
189
|
+
"gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, " +
|
|
159
190
|
"created_at_ms, updated_at_ms";
|
|
160
191
|
const BATCH_COLS = "batch_id, task_id, state, bound_ask_id, rev, created_at_ms, updated_at_ms";
|
|
161
192
|
function parseJsonColumn(v) {
|
|
@@ -195,6 +226,8 @@ function mapAskRow(r) {
|
|
|
195
226
|
gateBoundInputHash: strOrNull(r.gate_bound_input_hash),
|
|
196
227
|
idempotencyKey: strOrNull(r.idempotency_key),
|
|
197
228
|
cardJson: parseJsonColumn(r.card_json),
|
|
229
|
+
ruleCommand: strOrNull(r.rule_command),
|
|
230
|
+
ruleScopeRoot: strOrNull(r.rule_scope_root),
|
|
198
231
|
schemaVersion: Number(r.schema_version),
|
|
199
232
|
expiresAtMs: Number(r.expires_at_ms),
|
|
200
233
|
createdAtMs: Number(r.created_at_ms),
|
|
@@ -266,9 +299,9 @@ export class SqlApprovalAskStore {
|
|
|
266
299
|
try {
|
|
267
300
|
await conn.begin();
|
|
268
301
|
await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_BATCH_TABLE} (batch_id, task_id, state, rev, created_at_ms, updated_at_ms) VALUES (?, ?, 'OPEN', 0, ?, ?)`, `INSERT INTO ${APPROVAL_BATCH_TABLE} (batch_id, task_id, state, rev, created_at_ms, updated_at_ms) VALUES ($1, $2, 'OPEN', 0, $3, $4) ON CONFLICT (batch_id) DO NOTHING`), [row.batchId, row.taskId, now, now]);
|
|
269
|
-
const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
|
|
270
|
-
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
|
|
271
|
-
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15) ON CONFLICT (ask_id) DO NOTHING"), [
|
|
302
|
+
const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
|
|
303
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
|
|
304
|
+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15, $16, $17) ON CONFLICT (ask_id) DO NOTHING"), [
|
|
272
305
|
row.askId,
|
|
273
306
|
row.taskId,
|
|
274
307
|
row.sourceTaskId,
|
|
@@ -280,6 +313,8 @@ export class SqlApprovalAskStore {
|
|
|
280
313
|
row.parentToolCallId ?? null,
|
|
281
314
|
row.boundInputHash ?? null,
|
|
282
315
|
this.json(row.cardJson),
|
|
316
|
+
row.ruleCommand ?? null,
|
|
317
|
+
row.ruleScopeRoot ?? null,
|
|
283
318
|
row.schemaVersion,
|
|
284
319
|
row.expiresAtMs,
|
|
285
320
|
now,
|
|
@@ -34,6 +34,39 @@ export declare const TIDB_PERMISSION_RULE_STATEMENTS: readonly string[];
|
|
|
34
34
|
/** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`;
|
|
35
35
|
* 本函数留给只需要这三张表的集成测试)。 */
|
|
36
36
|
export declare function ensureTiDBPermissionRuleSchema(pool: MySqlPool): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* #340 升级前置断言 —— **拒启**,不是 warn(codex 对抗复审 [high],验真后修;形照 #119 的
|
|
39
|
+
* `assertToolResultProvenanceSchema` 先例)。
|
|
40
|
+
*
|
|
41
|
+
* 病:本仓不发 `ALTER TABLE` 迁移(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升
|
|
42
|
+
* 上来的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `permission_rule_approval` 是空操作 —— 本车新加的
|
|
43
|
+
* `command` / `edited_json` 两列不在,而 `get` / `create` / `cas` 三条语句**无条件**引用它们。
|
|
44
|
+
* 后果不是「少一个新功能」,是**整条审批记录面**在那台机器上是坏的:
|
|
45
|
+
* · 卡道「不再询问」(**修前就有**的候选臂也一样)每次撞 unknown column,被回决腿的 catch 吞成
|
|
46
|
+
* `rule_store_error` —— 裁决照常 200,服务看起来完全健康;
|
|
47
|
+
* · CC 导入的 prepare 连 pending 记录都落不下;
|
|
48
|
+
* · 而 `/v1/capabilities` 的 `permissionRules` 仍然报 true ⇒ 本仓「says yes ⟺ route works」整句为假。
|
|
49
|
+
* 没有这道断言,运维得不到**任何**「这次升级必须重建表」的信号 —— 那正是 #157「禁静默降级」要挡的形态。
|
|
50
|
+
*
|
|
51
|
+
* 判据用**能力探测**而不是版本号/information_schema:发一条恒空的 `WHERE 1=0` 读,列不在就报错。
|
|
52
|
+
* 只判「两列在不在」,不判宽度(两列都是 TEXT 族,没有截断轴)。
|
|
53
|
+
*
|
|
54
|
+
* 🔴 **只有可证的缺列才给破坏性指路**(codex 对抗复审 round2/round3 [high],两轮收窄后的终形):
|
|
55
|
+
* 「探针失败」**不等于**「列不在」。超时、连接被重置、取消、资源不足、列级权限被拒、表整个不存在 ——
|
|
56
|
+
* 每一种都会让这条读抛错,而把它们诊断成「去 DROP TABLE」是一条**会真的毁掉审计记录**的建议(比它要
|
|
57
|
+
* 挡的缺陷更贵)。round2 的两步探(先读一列老列)也不够:两次读之间同样可以插进一次瞬时故障。
|
|
58
|
+
* 终形判据 = **方言的缺列错误码**,一行一方言的闭集(见 {@link isMissingColumnError});其余错误
|
|
59
|
+
* **原样 rethrow**,一个字都不加工。真库两条腿各自跑过这条路径(db-integration 的存量表格),所以
|
|
60
|
+
* 这张码表不是抄手册抄来的,是实测钉住的。
|
|
61
|
+
* 🔴 位置与 #119 同款:**不在** `ensureSchema` 里(那条通道的契约是「只发 CREATE」,有运行时门看着),
|
|
62
|
+
* 放在 DDL 之后、任何路由装配之前 —— 还没开始服务,拒启的意义仍在;且**只在规则车道真会被装配时**跑
|
|
63
|
+
* (`PERMISSION_RULES_ENABLED=false` 的部署根本不碰这张表,为它拒启是纯误伤,见调用点)。
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* 「这个错误**是**『列不存在』吗」——判据属主自 #363 起收在 `sql-errors.ts`(与 `isDupKeyError` 同族、
|
|
67
|
+
* 同理由:识别集复制一次就会漂,而这条谓词守的是一句**破坏性**指路)。本文件此前那份逐字实现已下车,
|
|
68
|
+
* 语义一个字不变(闭集与 fail-safe 方向逐字见那边的头注)。
|
|
69
|
+
*/
|
|
37
70
|
export declare function assertPermissionRuleApprovalSchema(query: (sql: string) => Promise<{
|
|
38
71
|
rows: Record<string, unknown>[];
|
|
39
72
|
}>, dialect: "tidb" | "pg"): Promise<void>;
|
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
|
|
4
4
|
import { dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
5
|
+
import { isMissingColumnError } from "./sql-errors.js";
|
|
5
6
|
export function writerOfSqlRuleStore(store) {
|
|
6
7
|
const w = Reflect.get(store, PERMISSION_RULE_WRITER);
|
|
7
8
|
if (w === null || typeof w !== "object")
|
|
@@ -160,14 +161,6 @@ export async function ensureTiDBPermissionRuleSchema(pool) {
|
|
|
160
161
|
for (const stmt of TIDB_PERMISSION_RULE_STATEMENTS)
|
|
161
162
|
await pool.query(stmt);
|
|
162
163
|
}
|
|
163
|
-
function isMissingColumnError(err, dialect) {
|
|
164
|
-
if (err === null || typeof err !== "object")
|
|
165
|
-
return false;
|
|
166
|
-
const code = Reflect.get(err, "code");
|
|
167
|
-
if (dialect === "pg")
|
|
168
|
-
return code === "42703";
|
|
169
|
-
return Reflect.get(err, "errno") === 1054 || code === "ER_BAD_FIELD_ERROR";
|
|
170
|
-
}
|
|
171
164
|
export async function assertPermissionRuleApprovalSchema(query, dialect) {
|
|
172
165
|
try {
|
|
173
166
|
await query(`SELECT command, edited_json, version, offers_json, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
|
|
@@ -38,4 +38,22 @@ export declare function isMysqlDupKeyError(err: unknown): boolean;
|
|
|
38
38
|
export declare function isPgUniqueViolation(err: unknown): boolean;
|
|
39
39
|
/** 方言分派口——双方言店的调用形(`isDupKeyError(this.db.dialect, e)`)。 */
|
|
40
40
|
export declare function isDupKeyError(dialect: SqlDialect, err: unknown): boolean;
|
|
41
|
+
/** PostgreSQL `undefined_column` 的 SQLSTATE。⚠️ 与 `42P01`(`undefined_table`)刻意分开。 */
|
|
42
|
+
export declare const PG_UNDEFINED_COLUMN_SQLSTATE = "42703";
|
|
43
|
+
/** MySQL/TiDB `ER_BAD_FIELD_ERROR`(未知列)的数字 errno。 */
|
|
44
|
+
export declare const MYSQL_ER_BAD_FIELD_ERRNO = 1054;
|
|
45
|
+
/** MySQL/TiDB `ER_BAD_FIELD_ERROR` 的字符串错误码。 */
|
|
46
|
+
export declare const MYSQL_ER_BAD_FIELD_CODE = "ER_BAD_FIELD_ERROR";
|
|
47
|
+
/**
|
|
48
|
+
* 「这个错误**是**『列不存在』吗」——升级前置断言(`assert*Schema` 族)的**唯一**判据。
|
|
49
|
+
*
|
|
50
|
+
* 🔴 单一属主的理由与 {@link isDupKeyError} 逐字同源(本文件顶注的 single-semantic-multi-site-drift):
|
|
51
|
+
* 判据一旦复制,两处的识别集就会各自漂,而这条谓词的错判方向**特别贵** —— 它守的是一句**破坏性**的
|
|
52
|
+
* 错误指路(「去 DROP TABLE」)。超时、连接重置、取消、资源不足、列级权限被拒、表整个不存在,每一种
|
|
53
|
+
* 都会让探针抛错;把它们诊断成「删表重建」比它要挡的缺陷更贵(#340 codex 对抗复审 round2/round3
|
|
54
|
+
* [high] 两轮收窄后的终形)。认不出的一律**不是**缺列(fail-safe:宁可把一次真缺列报成原始错误)。
|
|
55
|
+
*
|
|
56
|
+
* MySQL 侧两个归因键都认(mysql2 的 `code`/`errno` 是同一件事的两种写法),与 dup-key 那条同姿势。
|
|
57
|
+
*/
|
|
58
|
+
export declare function isMissingColumnError(err: unknown, dialect: SqlDialect): boolean;
|
|
41
59
|
//# sourceMappingURL=sql-errors.d.ts.map
|
|
@@ -15,4 +15,14 @@ export function isPgUniqueViolation(err) {
|
|
|
15
15
|
export function isDupKeyError(dialect, err) {
|
|
16
16
|
return dialect === "tidb" ? isMysqlDupKeyError(err) : isPgUniqueViolation(err);
|
|
17
17
|
}
|
|
18
|
+
export const PG_UNDEFINED_COLUMN_SQLSTATE = "42703";
|
|
19
|
+
export const MYSQL_ER_BAD_FIELD_ERRNO = 1054;
|
|
20
|
+
export const MYSQL_ER_BAD_FIELD_CODE = "ER_BAD_FIELD_ERROR";
|
|
21
|
+
export function isMissingColumnError(err, dialect) {
|
|
22
|
+
if (err === null || typeof err !== "object")
|
|
23
|
+
return false;
|
|
24
|
+
if (dialect === "pg")
|
|
25
|
+
return keyOf(err, "code") === PG_UNDEFINED_COLUMN_SQLSTATE;
|
|
26
|
+
return keyOf(err, "errno") === MYSQL_ER_BAD_FIELD_ERRNO || keyOf(err, "code") === MYSQL_ER_BAD_FIELD_CODE;
|
|
27
|
+
}
|
|
18
28
|
//# sourceMappingURL=sql-errors.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [3833] 件 S-4 —— **running 态跨片用量投影**的数据面(`GET /v1/runs/:id` 的 `crossSliceUsage`)。
|
|
3
|
+
*
|
|
4
|
+
* 【为什么需要它】S-3 把三条跨片总额旋钮(`RESOURCE_SUSPEND_TOTAL_TOKENS` / `_BUDGET_USD` /
|
|
5
|
+
* `_MAX_SLICES`)供到了 `TaskSpec.resourceSuspend`,core 那本跨片账(`ResourceLedger`)也确实在记 ——
|
|
6
|
+
* 但**跑的过程中 wire 上一个数都读不到**。运维第一次知道「这条 run 逼近过上限」是在**爆窗那一刻**
|
|
7
|
+
* (行翻 `suspended` + `resource_limit` gate),想提前介入无读面。[3833] 的 75 万 token 就是这样烧完的。
|
|
8
|
+
*
|
|
9
|
+
* 【两半各自的真源(刻意不自建第三本账)】
|
|
10
|
+
* · **上限半**:本模块的进程内登记册 —— 记的是**这条 run 真正铸给 core 的那只 `resourceSuspend`**
|
|
11
|
+
* (装配点 `boot/resolve-spec.ts` 的 `resourceSuspendOptIn`),不是读时现取 env。差别是承重的:
|
|
12
|
+
* ① verify/cascade 腿被 `resourceSuspendOptIn` **整只排除**(opt-in 不成立)⇒ 登记册里没有它 ⇒
|
|
13
|
+
* 整键缺席,绝不显示一个没人执行的天花板;② 滚动改 env 之后,已在跑的 run 报的仍是它自己那份。
|
|
14
|
+
* · **已耗半**:run 自己的 durable 账本行 `model_usage`(`trace/project.ts` 的 `aggregateModelUsage`)——
|
|
15
|
+
* 与 `TaskStats.modelUsage` 回声**同一份账**,append-only、跨 slice、跨进程(每条腿只追加自己的
|
|
16
|
+
* delta,和跨越 park 边界)。**本模块不累加任何东西**:读时求和,不落第二本账(两本账必漂移)。
|
|
17
|
+
*
|
|
18
|
+
* 【与 core `ResourceLedger` 的关系(诚实登记,别读成「就是那本账」)】core 执法读的是挂在
|
|
19
|
+
* `resource_limit` checkpoint 行上的 `ResourceLedger`,它对本仓在 running 期**结构性不可达**(没有
|
|
20
|
+
* 「按 taskId 找 checkpoint」的面,而且跑的时候那行是 `resolved` 的)。两边量的是**同一个量**:
|
|
21
|
+
* core 的 `spentTokens` 轴 = 每片 `TaskStats.tokens`(= Σ 每次调用 `usage.totalTokens`,**不含**委派子代,
|
|
22
|
+
* 子代在 `stats.nested`),而 `model_usage` 行正是同一批 `brain.call` 的逐 turn delta、同样只收顶层 run。
|
|
23
|
+
* 已知差:① **滞后至多一个 turn**(delta 在 turn 边界 drain,进行中的那一 turn 还没入账);② 账本行
|
|
24
|
+
* 写失败(F 类)会少计。⇒ 本投影是**分诊/预警**读面,**不是**任何门/CAS/resume 判定的输入。
|
|
25
|
+
*
|
|
26
|
+
* 【登记册的语义边界(与 `turn-activity.ts` 的 `msSinceLastActivity` 逐字同族)】
|
|
27
|
+
* · **同副本 best-effort**:只活在本进程。跨副本 poll(run 跑在别的副本)或本副本重启后 ⇒ 读不到 ⇒
|
|
28
|
+
* **整键诚实缺席**。缺席 = 「本副本无法证明这条 run 有跨片窗」,不是「它没有窗」——绝不铸零上限
|
|
29
|
+
* (`0` 在 core 那边是**合法且极紧**的上限,与「没配」两义必须可判别,S-3 顶注同一条纪律)。
|
|
30
|
+
* · 不落库、不进 SQL 面。有界性由插入序近似 LRU 兜底(CAP 条,每条 ≈ 100B)。
|
|
31
|
+
* · **新世代必须清**:session purge 后客户端合法复用自带 taskId 重提交 —— {@link recordResourceWindow}
|
|
32
|
+
* 每次先删后写,于是「新腿没窗」不会读到旧腿的窗(`turn-activity` codex S1-F2 同款陷阱)。
|
|
33
|
+
*
|
|
34
|
+
* 【已声明的代价:每次 poll 一次全量账本读(codex 对抗复审 R2-[medium],如实登记、本批不修)】
|
|
35
|
+
* {@link buildCrossSliceUsage} 要对**全部** `model_usage` 行求和 ⇒ 调用点(`routes/runs.ts` 的 poll)在
|
|
36
|
+
* 窗已登记时每拍读一次 `getEvents(taskId, 0)`,工作量随「轮询次数 × 已积累事件数」增长。三条限幅是**结构性**
|
|
37
|
+
* 的,不是「应该很少见」:① **只有登记过窗的 run 才读**(没开三旋钮的部署、以及 verify/cascade 腿,一次都不读);
|
|
38
|
+
* ② 只在 `running` 且非 stale 时读;③ 同一条路上早已存在同形读(终局 + 配了 infra 价目表时的 `needCost` 臂,
|
|
39
|
+
* 同样是 `getEvents(taskId, 0)`)—— 本批加的是**跑动期**这一档,不是新病种。
|
|
40
|
+
* 🔴 真正的收口在**存储面**(不在本模块):给 run store 加一条「累计用量 + resource_limit park 计数」的
|
|
41
|
+
* 聚合投影/游标,让 poll 的工作量与账本长度解耦。缓存一份读时结果是**不采纳**的方向——那正是本模块顶注
|
|
42
|
+
* 拒绝的「第二本账」(它会与账本漂移,且缺席语义会被缓存住)。
|
|
43
|
+
*/
|
|
44
|
+
import type { TaskSpec } from "@sema-agent/core";
|
|
45
|
+
import type { RunEvent } from "./plugins/store-contracts.js";
|
|
46
|
+
/** 这条 run 铸给 core 的**跨片上限**(三轴各自可缺席;至少一根在场本记录才存在)。
|
|
47
|
+
* $ 轴用 micro-USD —— 与 core `ResourceLedger.totalBudgetMicroUsd` 同单位、整数,避免浮点累积误差
|
|
48
|
+
* (core 自己的换算就是 `Math.round(totalBudgetUsd * 1e6)`,此处逐字同式)。 */
|
|
49
|
+
export interface ResourceWindowTotals {
|
|
50
|
+
totalTokens?: number;
|
|
51
|
+
totalBudgetMicroUsd?: number;
|
|
52
|
+
maxSlices?: number;
|
|
53
|
+
}
|
|
54
|
+
/** wire 上的投影体(`GET /v1/runs/:id` 顶层 `crossSliceUsage`)。**三轴各自成对**:上限键不在 ⇒ 它那根
|
|
55
|
+
* 的已耗键也不在(没有天花板就没有「逼近天花板」这件事可读)。`spentMicroUsd` 另有一条缺席理由 ——
|
|
56
|
+
* unpriced 部署下用量行不带 `costMicroUsd` = **未知**,折 0 会谎报「还没花钱」(RB-368 未知传染)。 */
|
|
57
|
+
export interface CrossSliceUsage {
|
|
58
|
+
totalTokens?: number;
|
|
59
|
+
spentTokens?: number;
|
|
60
|
+
totalBudgetMicroUsd?: number;
|
|
61
|
+
spentMicroUsd?: number;
|
|
62
|
+
maxSlices?: number;
|
|
63
|
+
sliceCount?: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 登记这条 run 的跨片窗(装配点 = 三个 `createRun` 成功分支,与 `clearTurnActivity` 同位同理由)。
|
|
67
|
+
*
|
|
68
|
+
* `rs` 就是 `TaskSpec.resourceSuspend` 本身 —— 缺席、或三根总额一根都没有(`resourceSuspendOptIn` 只在
|
|
69
|
+
* `> 0` 时才铸键,所以「有键」即「有真上限」)⇒ **不留记录**(并清掉同 taskId 的旧世代残留)。
|
|
70
|
+
*/
|
|
71
|
+
export declare function recordResourceWindow(taskId: string, rs: TaskSpec["resourceSuspend"] | undefined): void;
|
|
72
|
+
/**
|
|
73
|
+
* **续跑腿**的登记(与 {@link recordResourceWindow} 分家,codex 对抗复审 R2-[high] 验真后修)。
|
|
74
|
+
*
|
|
75
|
+
* 🔴 三根旋钮在续跑腿上的下场**不同**,所以登记法也必须不同(S-3 已成文的上游契约,此处是它的读面孪生):
|
|
76
|
+
* · `totalTokens` / `totalBudgetUsd` —— core **冻在账本上**(`debitLedger` 逐字 `prior ?? total`)。
|
|
77
|
+
* 续跑腿的 spec 里那两个值是**当期 env 现读**的,与这条链真正在被执法的值**可以不同**(运维在两片
|
|
78
|
+
* 之间把 500k 改成 1M:core 仍按 500k 停,而现读值会让人以为还剩一倍余量)。⇒ **一律不取**:
|
|
79
|
+
* 已有记录就保留(那是首片登记的、也就是被冻住的那份),没有记录就诚实缺这一轴。
|
|
80
|
+
* · `maxSlices` —— core **不冻结**,每片现读当期部署值去比账本上的 `sliceCount`。⇒ **取当期值**才是真的。
|
|
81
|
+
*
|
|
82
|
+
* 缺席语义不变:两条 totals 缺、`maxSlices` 也缺 ⇒ 不留记录(整键缺席)。
|
|
83
|
+
*/
|
|
84
|
+
export declare function recordResumeResourceWindow(taskId: string, rs: TaskSpec["resourceSuspend"] | undefined): void;
|
|
85
|
+
/**
|
|
86
|
+
* 本副本记得的跨片窗;无记录 ⇒ `undefined`(缺席语义见顶注:「无法证明」,不是「没有窗」)。
|
|
87
|
+
*
|
|
88
|
+
* 🔴 **读命中会刷新插入序**(codex 对抗复审 R1-[high] 的逐出半场,验真后修):终局的 run **不主动清**
|
|
89
|
+
* (与 `turn-activity.ts` 同理由:主动清会制造「done 已打点、行还没翻终态」窗口里的假缺席),于是纯
|
|
90
|
+
* 写序 FIFO 下,一条长跑的 run 会被它之后的 8192 条**早已终局**的记录挤掉 —— 恰好挤掉唯一还需要这份
|
|
91
|
+
* 数据的那一条。改成「按读刷新」= 真 LRU:被 poll 的(=活着且有人看的)那条永远是最年轻的。
|
|
92
|
+
* 代价如实:本函数因此**有副作用**(只改顺序、不改值),调用点是 poll 路径,幂等无碍。
|
|
93
|
+
*/
|
|
94
|
+
export declare function readResourceWindow(taskId: string): ResourceWindowTotals | undefined;
|
|
95
|
+
/** 测试隔离用(生产路径不调 —— 终局不主动清,理由与 `turn-activity.ts` 同:清反而制造假缺席窗口)。 */
|
|
96
|
+
export declare function clearResourceWindow(taskId: string): void;
|
|
97
|
+
/**
|
|
98
|
+
* 投影体的**纯**构造:上限来自登记册,已耗来自这条 run 的 durable 账本行。
|
|
99
|
+
*
|
|
100
|
+
* 三轴的已耗读法:
|
|
101
|
+
* · `spentTokens` = 全账本 `model_usage` 行逐模型四分量求和(input MISS + output + cacheRead + cacheWrite
|
|
102
|
+
* = 与 `usage-analytics` 读侧同口径,也就是 core `stats.tokens` 量的那个总量)。零行 = 真的还没记到
|
|
103
|
+
* 账 ⇒ `0`(而不是缺席:token 轴没有「未知」这一格,append-only 保证它单调不减)。
|
|
104
|
+
* · `spentMicroUsd` = 同样逐模型求和;**任一模型的成本未知 ⇒ 整个和未知 ⇒ 键缺席**
|
|
105
|
+
* (`aggregateModelUsage` 已按模型做了未知传染,这里只要看键在不在)。
|
|
106
|
+
* · `sliceCount` = 账本上 `resource_limit` park 行的条数 —— core 的 `ResourceLedger.sliceCount` 正是在
|
|
107
|
+
* 每次 resource 挂起时 +1,而每一次那样的挂起在本仓都恰好落一行 `suspended{gate.kind:"resource_limit"}`
|
|
108
|
+
* (审批 park / plan_review park 的 gate.kind 不同,不计)。
|
|
109
|
+
*/
|
|
110
|
+
export declare function buildCrossSliceUsage(totals: ResourceWindowTotals, events: readonly RunEvent[]): CrossSliceUsage | undefined;
|
|
111
|
+
//# sourceMappingURL=resource-window.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { aggregateModelUsage } from "./trace/project.js";
|
|
2
|
+
const CAP = 8192;
|
|
3
|
+
const windowByTask = new Map();
|
|
4
|
+
const positive = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
5
|
+
export function recordResourceWindow(taskId, rs) {
|
|
6
|
+
if (!taskId)
|
|
7
|
+
return;
|
|
8
|
+
windowByTask.delete(taskId);
|
|
9
|
+
const totalTokens = positive(rs?.totalTokens);
|
|
10
|
+
const totalBudgetUsd = positive(rs?.totalBudgetUsd);
|
|
11
|
+
const maxSlices = positive(rs?.maxSlices);
|
|
12
|
+
if (totalTokens === undefined && totalBudgetUsd === undefined && maxSlices === undefined)
|
|
13
|
+
return;
|
|
14
|
+
if (windowByTask.size >= CAP) {
|
|
15
|
+
const oldest = windowByTask.keys().next().value;
|
|
16
|
+
if (oldest !== undefined)
|
|
17
|
+
windowByTask.delete(oldest);
|
|
18
|
+
}
|
|
19
|
+
windowByTask.set(taskId, {
|
|
20
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
21
|
+
...(totalBudgetUsd !== undefined ? { totalBudgetMicroUsd: Math.round(totalBudgetUsd * 1e6) } : {}),
|
|
22
|
+
...(maxSlices !== undefined ? { maxSlices } : {}),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function recordResumeResourceWindow(taskId, rs) {
|
|
26
|
+
if (!taskId)
|
|
27
|
+
return;
|
|
28
|
+
const prior = windowByTask.get(taskId);
|
|
29
|
+
const maxSlices = positive(rs?.maxSlices);
|
|
30
|
+
const merged = {
|
|
31
|
+
...(prior?.totalTokens !== undefined ? { totalTokens: prior.totalTokens } : {}),
|
|
32
|
+
...(prior?.totalBudgetMicroUsd !== undefined ? { totalBudgetMicroUsd: prior.totalBudgetMicroUsd } : {}),
|
|
33
|
+
...(maxSlices !== undefined ? { maxSlices } : {}),
|
|
34
|
+
};
|
|
35
|
+
windowByTask.delete(taskId);
|
|
36
|
+
if (merged.totalTokens === undefined && merged.totalBudgetMicroUsd === undefined && merged.maxSlices === undefined)
|
|
37
|
+
return;
|
|
38
|
+
if (windowByTask.size >= CAP) {
|
|
39
|
+
const oldest = windowByTask.keys().next().value;
|
|
40
|
+
if (oldest !== undefined)
|
|
41
|
+
windowByTask.delete(oldest);
|
|
42
|
+
}
|
|
43
|
+
windowByTask.set(taskId, merged);
|
|
44
|
+
}
|
|
45
|
+
export function readResourceWindow(taskId) {
|
|
46
|
+
const hit = windowByTask.get(taskId);
|
|
47
|
+
if (hit !== undefined) {
|
|
48
|
+
windowByTask.delete(taskId);
|
|
49
|
+
windowByTask.set(taskId, hit);
|
|
50
|
+
}
|
|
51
|
+
return hit;
|
|
52
|
+
}
|
|
53
|
+
export function clearResourceWindow(taskId) {
|
|
54
|
+
windowByTask.delete(taskId);
|
|
55
|
+
}
|
|
56
|
+
export function buildCrossSliceUsage(totals, events) {
|
|
57
|
+
if (totals.totalTokens === undefined && totals.totalBudgetMicroUsd === undefined && totals.maxSlices === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const perModel = totals.totalTokens !== undefined || totals.totalBudgetMicroUsd !== undefined ? aggregateModelUsage([...events]) : undefined;
|
|
60
|
+
let spentTokens = 0;
|
|
61
|
+
let spentMicroUsd = 0;
|
|
62
|
+
for (const d of Object.values(perModel ?? {})) {
|
|
63
|
+
spentTokens += d.inputTokens + d.outputTokens + d.cacheReadTokens + d.cacheWriteTokens;
|
|
64
|
+
if (d.costMicroUsd === undefined)
|
|
65
|
+
spentMicroUsd = undefined;
|
|
66
|
+
else if (spentMicroUsd !== undefined)
|
|
67
|
+
spentMicroUsd += d.costMicroUsd;
|
|
68
|
+
}
|
|
69
|
+
const sliceCount = totals.maxSlices === undefined
|
|
70
|
+
? undefined
|
|
71
|
+
: events.filter((e) => e.type === "suspended" && e.data?.gate?.kind === "resource_limit").length;
|
|
72
|
+
return {
|
|
73
|
+
...(totals.totalTokens !== undefined ? { totalTokens: totals.totalTokens, spentTokens } : {}),
|
|
74
|
+
...(totals.totalBudgetMicroUsd !== undefined
|
|
75
|
+
? { totalBudgetMicroUsd: totals.totalBudgetMicroUsd, ...(spentMicroUsd !== undefined ? { spentMicroUsd } : {}) }
|
|
76
|
+
: {}),
|
|
77
|
+
...(totals.maxSlices !== undefined ? { maxSlices: totals.maxSlices, sliceCount } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=resource-window.js.map
|
package/dist/rules-consent.d.ts
CHANGED
|
@@ -137,6 +137,18 @@ export type CardRuleRedemption =
|
|
|
137
137
|
kind: "batch";
|
|
138
138
|
}>;
|
|
139
139
|
};
|
|
140
|
+
/**
|
|
141
|
+
* P-39 —— 一条已落地批成员的**归属锚**(offer 空间)。
|
|
142
|
+
*
|
|
143
|
+
* `memberIndex` = 这条规则在**人看到的那只 batch offer** 的 `rules[]` 里的下标。它与请求体的
|
|
144
|
+
* `persistRule.batchOfferIndex`(= 帧上 `ruleOffers` 的下标)是**同一个空间**的两级坐标:
|
|
145
|
+
* 「第几只 offer / 那只 offer 的第几个成员」。core 的 `RedeemedBatchMember.candidateIndex`
|
|
146
|
+
* (candidate 空间)**刻意不透**:wire 面只见一个下标空间是 cli [5263] 的定形理由(三端零映射)。
|
|
147
|
+
*/
|
|
148
|
+
export interface RedeemedBatchAnchor {
|
|
149
|
+
memberIndex: number;
|
|
150
|
+
rule: string;
|
|
151
|
+
}
|
|
140
152
|
/** 卡道兑付的结果。`canonical` = **真正落盘**的那条规则文本(编辑臂上它可能与人敲的原字节不同 ——
|
|
141
153
|
* core 会把 `Bash(adb *)` 规范成 `Bash(adb:*)`;界面要回显的是这一份,不是输入框里的那一份)。 */
|
|
142
154
|
export type CardRulePersisted = {
|
|
@@ -147,11 +159,27 @@ export type CardRulePersisted = {
|
|
|
147
159
|
alreadyRedeemed: boolean;
|
|
148
160
|
}
|
|
149
161
|
/** design/377 批臂:全体成员落地(persisted/deduped 都计——等价规则已在店=同意已生效)。
|
|
150
|
-
* `rules` = 规范文本,**展示序**(= batch offer 的成员序,壳逐条回显)。
|
|
162
|
+
* `rules` = 规范文本,**展示序**(= 兑付时递进来的那只 batch offer 的成员序,壳逐条回显)。
|
|
163
|
+
*
|
|
164
|
+
* 🔴 **P-39(cli [5254] 请托 / [5263] 定形):展示序自本版起是承诺,不再是碰巧。**
|
|
165
|
+
* 修前 `rules` 直接是 `redeemRuleBatch` 的 members 走序 = **重铸批**(下面 `prepareCardApproval`
|
|
166
|
+
* 在兑付这一刻重铸的那只)的成员序;而人看到的是**呈卡那一刻**的批,两侧的等式是**集合**等式
|
|
167
|
+
* (防漂等式逐字「序无关」,见 batch 分支行注)⇒ 序从来不在等式里,「回执序=展示序」只是当下
|
|
168
|
+
* 两次 `suggestRulesForCommand` 恰好同序的副产物。现在按 {@link CardRuleRedemption} 批臂递进来的
|
|
169
|
+
* 那只 offer 的成员序**显式重排**,于是它与壳渲的那张卡逐条对得上,不依赖引擎两次铸形同序。
|
|
170
|
+
*
|
|
171
|
+
* `members` = 逐条**归属锚**(index 与 `rules` 对齐):`memberIndex` = 这条规则在那只展示批
|
|
172
|
+
* `rules[]` 里的位置 —— **offer 空间**,candidate 空间刻意不出现在本类型上(cli [5263] 裁词:
|
|
173
|
+
* 三端零映射,candidate 空间留在 core 内部)。
|
|
174
|
+
*
|
|
175
|
+
* ⚠️ **可选 = 诚实缺席**:落地成员与展示批成员对不上号(集合等式过了却出现 core 契约级的成员
|
|
176
|
+
* 漂移 —— dist/core 版本不同步那一形)时本键**整键缺席**,`rules` 退回 members 走序(= 修前字节)。
|
|
177
|
+
* 编不出锚就别编一个,消费方读到缺席即知「这次归属证不出来」;这条缺席**响亮**(warn 一条)。 */
|
|
151
178
|
| {
|
|
152
179
|
ok: true;
|
|
153
180
|
kind: "batch";
|
|
154
181
|
rules: readonly string[];
|
|
182
|
+
members?: readonly RedeemedBatchAnchor[];
|
|
155
183
|
rev: number;
|
|
156
184
|
} | {
|
|
157
185
|
ok: false;
|