@sema-agent/server 7.37.0 → 7.38.0-rc.1
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 +2 -1
- package/dist/adoption/plan.js +16 -0
- package/dist/boot/budget-tracing.d.ts +1 -1
- package/dist/boot/budget-tracing.js +12 -5
- package/dist/boot/config-center.d.ts +27 -2
- package/dist/boot/config-center.js +148 -15
- package/dist/boot/limit-sync.d.ts +32 -0
- package/dist/boot/limit-sync.js +35 -0
- package/dist/boot/reapers.d.ts +68 -0
- package/dist/boot/reapers.js +63 -4
- package/dist/boot/resolve-spec.js +2 -0
- package/dist/boot/runner-deps.d.ts +1 -1
- package/dist/boot/runner-deps.js +4 -0
- package/dist/boot/stores.js +13 -0
- package/dist/config-center/apply-effective.d.ts +38 -3
- package/dist/config-center/apply-effective.js +75 -5
- package/dist/config-center/apply-ledger.d.ts +67 -0
- package/dist/config-center/apply-ledger.js +83 -0
- package/dist/config-center/facade.d.ts +1 -1
- package/dist/config-center/hot-keys-registry.d.ts +62 -0
- package/dist/config-center/hot-keys-registry.js +54 -0
- package/dist/config-center/http-client.js +12 -2
- package/dist/config-center/restart-signal.js +5 -2
- package/dist/config-center/stage-limits.d.ts +59 -0
- package/dist/config-center/stage-limits.js +188 -0
- package/dist/config-center/types.d.ts +60 -0
- package/dist/config-invariants.d.ts +65 -0
- package/dist/config-invariants.js +139 -0
- package/dist/config-provider.js +11 -0
- package/dist/config-types.d.ts +10 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +37 -5
- package/dist/http/routes/admin-config-refresh.d.ts +20 -0
- package/dist/http/routes/admin-config-refresh.js +43 -0
- package/dist/http/routes/diagnostics.js +4 -0
- package/dist/http/server.d.ts +8 -0
- package/dist/http/server.js +23 -0
- package/dist/main.js +49 -6
- package/dist/memory-sync.js +50 -1
- package/dist/observability/cost-quota.d.ts +23 -3
- package/dist/observability/cost-quota.js +27 -4
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/rate-limit.d.ts +11 -1
- package/dist/observability/rate-limit.js +8 -2
- package/dist/plan-cache-probe.d.ts +5 -7
- package/dist/plan-cache-probe.js +27 -5
- package/dist/plugins/caching-session-store.d.ts +20 -4
- package/dist/plugins/caching-session-store.js +43 -2
- package/dist/plugins/fork-routing-session-store.d.ts +26 -4
- package/dist/plugins/fork-routing-session-store.js +134 -9
- package/dist/plugins/memory-engine-pg.d.ts +1 -0
- package/dist/plugins/memory-engine-pg.js +34 -4
- package/dist/plugins/memory-engine-tidb.d.ts +1 -0
- package/dist/plugins/memory-engine-tidb.js +17 -2
- package/dist/plugins/memory-origin-law.d.ts +69 -0
- package/dist/plugins/memory-origin-law.js +118 -1
- package/dist/plugins/pg-cost-quota.d.ts +7 -2
- package/dist/plugins/pg-cost-quota.js +25 -2
- package/dist/plugins/pg-rate-limiter.d.ts +7 -2
- package/dist/plugins/pg-rate-limiter.js +20 -2
- package/dist/plugins/pg-session-storage.d.ts +30 -7
- package/dist/plugins/pg-session-storage.js +145 -23
- package/dist/plugins/retention-store-sql.js +10 -1
- package/dist/plugins/session-placement.d.ts +113 -0
- package/dist/plugins/session-placement.js +153 -0
- package/dist/plugins/session-store.js +7 -1
- package/dist/plugins/tidb-cost-quota.d.ts +7 -2
- package/dist/plugins/tidb-cost-quota.js +26 -2
- package/dist/plugins/tidb-pool.js +15 -1
- package/dist/plugins/tidb-rate-limiter.d.ts +9 -2
- package/dist/plugins/tidb-rate-limiter.js +22 -2
- package/dist/plugins/tidb-session-storage.js +10 -2
- package/dist/plugins/tidb-session-store.d.ts +55 -7
- package/dist/plugins/tidb-session-store.js +174 -24
- package/dist/plugins/write-behind-counter.d.ts +17 -2
- package/dist/plugins/write-behind-counter.js +19 -1
- package/dist/runs.d.ts +3 -1
- package/dist/runs.js +5 -2
- package/dist/trace/engine-notice-wire.d.ts +1 -1
- package/dist/trace/engine-notice-wire.js +24 -6
- package/package.json +3 -3
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
export class CostQuota {
|
|
2
|
-
limitMicroUsd;
|
|
3
|
-
windowMs;
|
|
4
2
|
now;
|
|
5
3
|
windows = new Map();
|
|
6
|
-
/**
|
|
4
|
+
/** 🔴 不再 `readonly`:限额/窗长是比较参数,换代只动它们,窗内已累计的花费一个不动(design/322 §5)。 */
|
|
5
|
+
limitMicroUsd;
|
|
6
|
+
windowMs;
|
|
7
|
+
/** @param limitMicroUsd ceiling per window (integer micro-USD; `<=0` = OFF sentinel). @param windowMs rolling window. */
|
|
7
8
|
constructor(limitMicroUsd, windowMs, now = () => Date.now()) {
|
|
9
|
+
this.now = now;
|
|
8
10
|
this.limitMicroUsd = limitMicroUsd;
|
|
9
11
|
this.windowMs = windowMs;
|
|
10
|
-
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 值换代。两件事**语义不同**,别混:
|
|
15
|
+
* · **限额**换代 = 纯比较参数 ⇒ 窗内已累计的花费一个不动(design/322 §5 定案:调小当场更严)。
|
|
16
|
+
* · **窗长**换代 = 换**记账周期** ⇒ 当代累计重开。
|
|
17
|
+
*
|
|
18
|
+
* 🔴 后者是 codex R1-F4(真 finding,红先):SQL 腿的窗长是**桶键的一部分**(`floor(now/windowMs)`),
|
|
19
|
+
* 换窗长后当代桶天然重算 ⇒ 已累计不跟着搬家;内存腿修前却保留原窗口累计。于是**同一条发布**在
|
|
20
|
+
* 内存部署上继续拒绝超额 principal、在 PG/TiDB 部署上重新放行一整个额度 —— 后端相关的成本 fail-open。
|
|
21
|
+
* 统一取 SQL 腿那一侧(唯一能在两条腿上都实现的语义:SQL 侧无法把旧桶累计搬进新桶)。
|
|
22
|
+
*/
|
|
23
|
+
setLimits(next) {
|
|
24
|
+
this.limitMicroUsd = next.limitMicroUsd;
|
|
25
|
+
if (next.windowMs !== this.windowMs) {
|
|
26
|
+
this.windowMs = next.windowMs;
|
|
27
|
+
this.windows.clear();
|
|
28
|
+
}
|
|
11
29
|
}
|
|
12
30
|
/** Is this principal under quota right now? (Reads only — does not consume.) */
|
|
13
31
|
check(key) {
|
|
32
|
+
// 关断哨兵:零窗表访问,如实报 `limitMicroUsd: 0`(消费方据此知道「本部署此刻没有 ceiling」)。
|
|
33
|
+
if (this.limitMicroUsd <= 0)
|
|
34
|
+
return { allowed: true, usedMicroUsd: 0, limitMicroUsd: 0, retryAfterSec: 0 };
|
|
14
35
|
const t = this.now();
|
|
15
36
|
const w = this.windows.get(key);
|
|
16
37
|
const live = w && t - w.start < this.windowMs;
|
|
@@ -21,6 +42,8 @@ export class CostQuota {
|
|
|
21
42
|
}
|
|
22
43
|
/** Add spend for a principal, starting a fresh window if the prior one expired. */
|
|
23
44
|
add(key, micro) {
|
|
45
|
+
if (this.limitMicroUsd <= 0)
|
|
46
|
+
return; // 关断哨兵:不为一个不存在的门攒内存(0→N 打开时是全新的窗口)
|
|
24
47
|
if (micro <= 0)
|
|
25
48
|
return;
|
|
26
49
|
const t = this.now();
|
|
@@ -13,6 +13,14 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
13
13
|
readonly cls: "P-DEBT";
|
|
14
14
|
readonly note: "件6(#43 案 [4281]):`FileRunStore.hydrate()` 的 `readdirSync(runs/)` 失败 ⇒ 引擎以**空账**起动。旧形是空 catch + return:data root 不可读(权限/挂载丢失/路径被换走)时零告警,此后每一次 getRun 恒 miss —— 而「恒 miss」正是 #43 现场看到的形,一次环境故障被伪装成「本来就没有这条 run」。方向问题在于吞掉之后被读成的是「证不出有行 ⇒ 没有行」,两句话不等价。**不拒启是刻意保留的行为**(拒启是行为变更,另立),所以这条留作债:响亮化(error 级启动日志带 runs 绝对路径+异常文案)+ 本计数,让它在遥测里显形而不是只活在一行日志里。终局=给 file 后端一条可选的 fail-closed 启动门。";
|
|
15
15
|
};
|
|
16
|
+
readonly "server.fork-routing.placed-probe-host-error": {
|
|
17
|
+
readonly cls: "F";
|
|
18
|
+
readonly note: "#292 placement 半场:hostPlacedProbe 对 host 店的 requireExisting 探针失败且**不是 not_found**(host 读故障)时折叠为 absent —— 路由回旧规则(claim-create 会在 transient 建新会话)。放行的是「host 暂不可达时 transient 腿保持独立可用」;最坏后果=故障窗内对一个真 placed id 铸出 transient 影子(host 恢复后双身,requireExisting 冷探针会重新指回 host 真身)。not_found 是常态路径不计数;只有真故障臂进本计数。";
|
|
19
|
+
};
|
|
20
|
+
readonly "server.fork-routing.placed-roster-meta-unreadable": {
|
|
21
|
+
readonly cls: "F";
|
|
22
|
+
readonly note: "#292 placement 半场:host-fallback 命中后顺手读 meta 入 placed 名册失败(stub 会话无 getMetadata / 读故障)。名册只是省探针的缓存,不是判定 —— 缺一条=该 id 下次 acquire 多付一次 hostPlacedProbe,正确性零影响。计数留痕是为了让「meta 读一直在失败」这种环境病显形。";
|
|
23
|
+
};
|
|
16
24
|
readonly "server.hitl.frame-undelivered-stream-closed": {
|
|
17
25
|
readonly cls: "P-DEBT";
|
|
18
26
|
readonly note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。";
|
|
@@ -36,6 +36,14 @@ export const FAIL_OPEN_TAGS = {
|
|
|
36
36
|
cls: "P-DEBT",
|
|
37
37
|
note: "件6(#43 案 [4281]):`FileRunStore.hydrate()` 的 `readdirSync(runs/)` 失败 ⇒ 引擎以**空账**起动。旧形是空 catch + return:data root 不可读(权限/挂载丢失/路径被换走)时零告警,此后每一次 getRun 恒 miss —— 而「恒 miss」正是 #43 现场看到的形,一次环境故障被伪装成「本来就没有这条 run」。方向问题在于吞掉之后被读成的是「证不出有行 ⇒ 没有行」,两句话不等价。**不拒启是刻意保留的行为**(拒启是行为变更,另立),所以这条留作债:响亮化(error 级启动日志带 runs 绝对路径+异常文案)+ 本计数,让它在遥测里显形而不是只活在一行日志里。终局=给 file 后端一条可选的 fail-closed 启动门。",
|
|
38
38
|
},
|
|
39
|
+
"server.fork-routing.placed-probe-host-error": {
|
|
40
|
+
cls: "F",
|
|
41
|
+
note: "#292 placement 半场:hostPlacedProbe 对 host 店的 requireExisting 探针失败且**不是 not_found**(host 读故障)时折叠为 absent —— 路由回旧规则(claim-create 会在 transient 建新会话)。放行的是「host 暂不可达时 transient 腿保持独立可用」;最坏后果=故障窗内对一个真 placed id 铸出 transient 影子(host 恢复后双身,requireExisting 冷探针会重新指回 host 真身)。not_found 是常态路径不计数;只有真故障臂进本计数。",
|
|
42
|
+
},
|
|
43
|
+
"server.fork-routing.placed-roster-meta-unreadable": {
|
|
44
|
+
cls: "F",
|
|
45
|
+
note: "#292 placement 半场:host-fallback 命中后顺手读 meta 入 placed 名册失败(stub 会话无 getMetadata / 读故障)。名册只是省探针的缓存,不是判定 —— 缺一条=该 id 下次 acquire 多付一次 hostPlacedProbe,正确性零影响。计数留痕是为了让「meta 读一直在失败」这种环境病显形。",
|
|
46
|
+
},
|
|
39
47
|
"server.hitl.frame-undelivered-stream-closed": {
|
|
40
48
|
cls: "P-DEBT",
|
|
41
49
|
note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。",
|
|
@@ -10,18 +10,28 @@ export interface RateDecision {
|
|
|
10
10
|
retryAfterSec: number;
|
|
11
11
|
remaining: number;
|
|
12
12
|
}
|
|
13
|
+
/** #322 批1:限额的**值换代**入参(六实现类同一形)。语义定案(design/322 §5):窗口内计数**不清零**,
|
|
14
|
+
* 新限额即时用于比较 —— 调小 = 窗内立刻更严(可能当场 429,符合运营意图),调大 = 立刻放宽。 */
|
|
15
|
+
export interface RateLimits {
|
|
16
|
+
/** 每窗请求上限;`<= 0` = **关断哨兵**(恒放行、不记账、SQL 腿不起刷新环)。 */
|
|
17
|
+
rateLimitPerMin: number;
|
|
18
|
+
}
|
|
13
19
|
/** The surface the HTTP layer uses — satisfied by the in-memory {@link RateLimiter} (single replica)
|
|
14
20
|
* and the cross-replica {@link import("../plugins/tidb-rate-limiter.js").TiDBRateLimiter}. */
|
|
15
21
|
export interface RateGate {
|
|
16
22
|
check(key: string): RateDecision;
|
|
17
23
|
sweep(now?: number): void;
|
|
24
|
+
/** #322:值换代座位。**接口级**声明(不是各类自选):新实现漏掉它 = 编译红,而不是热更新在那条腿上静默失效。 */
|
|
25
|
+
setLimits(next: RateLimits): void;
|
|
18
26
|
}
|
|
19
27
|
export declare class RateLimiter implements RateGate {
|
|
20
|
-
private readonly limit;
|
|
21
28
|
private readonly windowMs;
|
|
22
29
|
private readonly windows;
|
|
30
|
+
/** 🔴 不再 `readonly`:限额是**比较参数**,不是窗表状态 —— 换代只动它,窗内已计的数一个不动(§5)。 */
|
|
31
|
+
private limit;
|
|
23
32
|
constructor(limit: number, windowMs?: number);
|
|
24
33
|
check(key: string): RateDecision;
|
|
34
|
+
setLimits(next: RateLimits): void;
|
|
25
35
|
/** Drop expired windows (call periodically). */
|
|
26
36
|
sweep(now?: number): void;
|
|
27
37
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
export class RateLimiter {
|
|
2
|
-
limit;
|
|
3
2
|
windowMs;
|
|
4
3
|
windows = new Map();
|
|
4
|
+
/** 🔴 不再 `readonly`:限额是**比较参数**,不是窗表状态 —— 换代只动它,窗内已计的数一个不动(§5)。 */
|
|
5
|
+
limit;
|
|
5
6
|
constructor(limit, windowMs = 60_000) {
|
|
6
|
-
this.limit = limit;
|
|
7
7
|
this.windowMs = windowMs;
|
|
8
|
+
this.limit = limit;
|
|
8
9
|
}
|
|
9
10
|
check(key) {
|
|
11
|
+
// 关断哨兵:零比较、零窗表写入。off 期间不攒计数是刻意的——0→N 打开时应该是一个完整的新窗口,
|
|
12
|
+
// 而不是「上来就超」(那段时间根本没有 ceiling,补记账等于追溯执法)。
|
|
10
13
|
if (this.limit <= 0)
|
|
11
14
|
return { allowed: true, retryAfterSec: 0, remaining: Number.POSITIVE_INFINITY };
|
|
12
15
|
const now = Date.now();
|
|
@@ -21,6 +24,9 @@ export class RateLimiter {
|
|
|
21
24
|
}
|
|
22
25
|
return { allowed: true, retryAfterSec: 0, remaining: this.limit - w.count };
|
|
23
26
|
}
|
|
27
|
+
setLimits(next) {
|
|
28
|
+
this.limit = next.rateLimitPerMin;
|
|
29
|
+
}
|
|
24
30
|
/** Drop expired windows (call periodically). */
|
|
25
31
|
sweep(now = Date.now()) {
|
|
26
32
|
for (const [k, w] of this.windows)
|
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
* does an agent's traffic actually re-pose near-duplicate objectives per tenant? (arXiv 2602.18922:
|
|
9
9
|
* heterogeneous queries can hit 0–12%.) We hold the real multi-tenant traffic, so this is ours to count.
|
|
10
10
|
*
|
|
11
|
-
* **The token criterion is copied verbatim from core `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* is
|
|
11
|
+
* **The token criterion is copied verbatim from core `strategy-store` (`compileStrategyQuery`)** —
|
|
12
|
+
* otherwise we'd measure something other than what plan-cache retrieval would actually use. Note the
|
|
13
|
+
* **asymmetry**: a stored problem is matched on its full `tokens()` set, the query on its
|
|
14
|
+
* `significant()` set. A hit = `significant(new) ⊆ tokens(historical)` OR (5.49 legacy acceptance
|
|
15
|
+
* arm) the non-CJK subset `qAlnum` is non-empty and fully contained (`qAlnum ⊆ tokens(historical)`).
|
|
16
16
|
*
|
|
17
17
|
* Fidelity choices (stated to search AI for confirmation): the history ring holds only **completed**
|
|
18
18
|
* tasks (plan-cache only stores plans from successful runs), while the hit test runs for **every**
|
|
@@ -21,9 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { Metrics } from "./observability/metrics.js";
|
|
23
23
|
import type { Logger } from "./observability/logger.js";
|
|
24
|
-
/** Lowercased word tokens (core口径). */
|
|
25
24
|
export declare function tokens(s: string): string[];
|
|
26
|
-
/** Meaningful tokens: ≥3 chars, not a stopword (core口径). */
|
|
27
25
|
export declare function significant(s: string): string[];
|
|
28
26
|
/** Per-scope view for the 2-week handoff back to search AI. */
|
|
29
27
|
export interface ScopeRecurrence {
|
package/dist/plan-cache-probe.js
CHANGED
|
@@ -8,12 +8,30 @@ const STOP = new Set([
|
|
|
8
8
|
"of", "to", "in", "on", "is", "it", "or", "as", "at", "by", "be", "do", "if", "so", "no", "up", "we",
|
|
9
9
|
]);
|
|
10
10
|
/** Lowercased word tokens (core口径). */
|
|
11
|
+
/** core 5.49 G2(CJK 检索修)同形:拉丁/数字按非字母数字切段 + CJK 按单字与相邻二元组入集
|
|
12
|
+
* (core `memory-vector.termSet` 逐字搬)。旧形 `match(/[a-z0-9]+/g)` 对纯中文任务名恒空 ⇒
|
|
13
|
+
* probe 测出的 recurrence 与 plan-cache 真命中脱钩——同源钉(plan-cache-probe.test.ts)就是
|
|
14
|
+
* 为这次换形红的。 */
|
|
15
|
+
const CJK_RUN = /[-ヿㇰ-ㇿ㐀-䶿一-鿿가-힣豈-]+/g;
|
|
11
16
|
export function tokens(s) {
|
|
12
|
-
|
|
17
|
+
const lower = s.toLowerCase();
|
|
18
|
+
const out = new Set();
|
|
19
|
+
for (const run of lower.split(/[^a-z0-9]+/))
|
|
20
|
+
if (run)
|
|
21
|
+
out.add(run);
|
|
22
|
+
for (const m of lower.matchAll(CJK_RUN)) {
|
|
23
|
+
const chars = [...m[0]];
|
|
24
|
+
for (const c of chars)
|
|
25
|
+
out.add(c);
|
|
26
|
+
for (let i = 0; i + 1 < chars.length; i++)
|
|
27
|
+
out.add(chars[i] + chars[i + 1]);
|
|
28
|
+
}
|
|
29
|
+
return [...out];
|
|
13
30
|
}
|
|
14
|
-
/**
|
|
31
|
+
/** CJK 词条豁免长度/停词判(core 5.49 `CJK_LEAD` 同形:单字即显著);拉丁保持 ≥3 且非停词。 */
|
|
32
|
+
const CJK_LEAD = /^[-ヿㇰ-ㇿ㐀-䶿一-鿿가-힣豈-]/;
|
|
15
33
|
export function significant(s) {
|
|
16
|
-
return tokens(s).filter((t) => t.length >= 3 && !STOP.has(t));
|
|
34
|
+
return tokens(s).filter((t) => (CJK_LEAD.test(t) ? true : t.length >= 3 && !STOP.has(t)));
|
|
17
35
|
}
|
|
18
36
|
const RING = 1000; // most-recent completed tasks per scope (= search AI's N=1000)
|
|
19
37
|
const MAX_SCOPES = 2000; // LRU cap on tracked scopes — bounds memory under high tenant cardinality
|
|
@@ -38,12 +56,16 @@ export class PlanCacheProbe {
|
|
|
38
56
|
const scope = scopeRaw && scopeRaw.length > 0 ? scopeRaw : "anon";
|
|
39
57
|
const qSig = significant(objective);
|
|
40
58
|
const st = this.touch(scope);
|
|
41
|
-
// Hit = some historical (completed) task's full token set ⊇ this task's significant tokens
|
|
59
|
+
// Hit = some historical (completed) task's full token set ⊇ this task's significant tokens,
|
|
60
|
+
// OR(core 5.49 compileStrategyQuery 的 legacy acceptance 臂,合并窗重扫 medium)⊇ 其拉丁/数字
|
|
61
|
+
// 子集 qAlnum(非空时)——changelog 原句 "old hits preserved via a legacy acceptance arm"。
|
|
62
|
+
// 少这条臂时「CJK 团不全匹配但拉丁标识符全匹配」的真命中被判 miss ⇒ recurrence 系统性低估。
|
|
42
63
|
// qSig empty → never a hit (mirrors find()'s `qSig.length===0 → []`, no injection).
|
|
43
64
|
let hit = false;
|
|
44
65
|
if (qSig.length > 0) {
|
|
66
|
+
const qAlnum = qSig.filter((t) => !CJK_LEAD.test(t));
|
|
45
67
|
for (const hist of st.ring) {
|
|
46
|
-
if (qSig.every((t) => hist.has(t))) {
|
|
68
|
+
if (qSig.every((t) => hist.has(t)) || (qAlnum.length > 0 && qAlnum.every((t) => hist.has(t)))) {
|
|
47
69
|
hit = true;
|
|
48
70
|
break;
|
|
49
71
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { AcquiredSession, RetentionDeclaration, SessionStore, SessionTreeEntry } from "@sema-agent/core";
|
|
1
|
+
import type { AcquiredSession, PlacedSessionRow, RetentionDeclaration, SessionPlacementRecord, SessionStore, SessionTreeEntry } from "@sema-agent/core";
|
|
2
2
|
import type { SessionListItem } from "../security.js";
|
|
3
3
|
import type { StagingHandle } from "../session-sync-kernel.js";
|
|
4
|
+
import { type PlacementAcquireOpts } from "./session-placement.js";
|
|
4
5
|
export interface CachingOptions {
|
|
5
6
|
/** Idle seconds a woken session is kept warm. 0 disables caching (pure passthrough). */
|
|
6
7
|
ttlSec: number;
|
|
@@ -105,10 +106,25 @@ export declare class CachingSessionStore implements SessionStore {
|
|
|
105
106
|
* 构造期常量,一个会变的声明本身就是缺陷。缺席则不设(保持 fail-closed 的缺席读法)。
|
|
106
107
|
*/
|
|
107
108
|
readonly retention?: RetentionDeclaration;
|
|
109
|
+
/**
|
|
110
|
+
* #292 C18 —— 内层店的 **placement 声明**转发。
|
|
111
|
+
*
|
|
112
|
+
* 🔴 这一格与 {@link retention} 是同一条教训的同一族(而且更狠):生产默认 `SESSION_CACHE_TTL_SEC=300`
|
|
113
|
+
* 把真店包进本类,于是 boot 递给 core 的就是**包装层**。core 的铸造门判「row store ∧ **session 店声明**」
|
|
114
|
+
* ⇒ 不转发 = 整条 subagent 转录持久化在默认部署上**静默不生效**(placement 参数被合法忽略、子代转录
|
|
115
|
+
* 退回普通会话、声明的 durable tier 变成一句空话),而且没有任何一处会报错。
|
|
116
|
+
* 只读快照而非 getter:声明是店的构造期常量(会变的声明本身就是缺陷)。
|
|
117
|
+
*/
|
|
118
|
+
readonly placements?: SessionStore["placements"];
|
|
119
|
+
/** #292:placed 分区的两只读面(reap 分区腿 / 准入探针)——同款转发理由:reaper 与准入判据拿到的
|
|
120
|
+
* 是包装层,不转发 = 分区腿静默不可用、暖命中的 claim 关门静默失效。缺席则不设(诚实降级)。 */
|
|
121
|
+
readonly listPlaced?: (kind: "subagent", opts?: {
|
|
122
|
+
olderThanMs?: number;
|
|
123
|
+
scope?: string;
|
|
124
|
+
}) => Promise<PlacedSessionRow[]>;
|
|
125
|
+
readonly placementOf?: (sessionId: string) => Promise<SessionPlacementRecord | undefined>;
|
|
108
126
|
constructor(inner: SessionStore, opts: CachingOptions);
|
|
109
|
-
acquire(sessionId?: string, opts?:
|
|
110
|
-
requireExisting?: boolean;
|
|
111
|
-
}): Promise<AcquiredSession>;
|
|
127
|
+
acquire(sessionId?: string, opts?: PlacementAcquireOpts): Promise<AcquiredSession>;
|
|
112
128
|
touch(sessionId: string): Promise<void>;
|
|
113
129
|
release(sessionId: string): Promise<void>;
|
|
114
130
|
/** Drop a session from the warm cache (e.g. after a cross-instance write conflict). */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertPlacementAdmission } from "./session-placement.js";
|
|
1
2
|
/**
|
|
2
3
|
* Warm/sticky cache in front of a durable {@link SessionStore} (Option A).
|
|
3
4
|
*
|
|
@@ -79,12 +80,34 @@ export class CachingSessionStore {
|
|
|
79
80
|
* 构造期常量,一个会变的声明本身就是缺陷。缺席则不设(保持 fail-closed 的缺席读法)。
|
|
80
81
|
*/
|
|
81
82
|
retention;
|
|
83
|
+
/**
|
|
84
|
+
* #292 C18 —— 内层店的 **placement 声明**转发。
|
|
85
|
+
*
|
|
86
|
+
* 🔴 这一格与 {@link retention} 是同一条教训的同一族(而且更狠):生产默认 `SESSION_CACHE_TTL_SEC=300`
|
|
87
|
+
* 把真店包进本类,于是 boot 递给 core 的就是**包装层**。core 的铸造门判「row store ∧ **session 店声明**」
|
|
88
|
+
* ⇒ 不转发 = 整条 subagent 转录持久化在默认部署上**静默不生效**(placement 参数被合法忽略、子代转录
|
|
89
|
+
* 退回普通会话、声明的 durable tier 变成一句空话),而且没有任何一处会报错。
|
|
90
|
+
* 只读快照而非 getter:声明是店的构造期常量(会变的声明本身就是缺陷)。
|
|
91
|
+
*/
|
|
92
|
+
placements;
|
|
93
|
+
/** #292:placed 分区的两只读面(reap 分区腿 / 准入探针)——同款转发理由:reaper 与准入判据拿到的
|
|
94
|
+
* 是包装层,不转发 = 分区腿静默不可用、暖命中的 claim 关门静默失效。缺席则不设(诚实降级)。 */
|
|
95
|
+
listPlaced;
|
|
96
|
+
placementOf;
|
|
82
97
|
constructor(inner, opts) {
|
|
83
98
|
this.inner = inner;
|
|
84
99
|
this.ttlMs = Math.max(0, opts.ttlSec) * 1000;
|
|
85
100
|
const declared = inner.retention;
|
|
86
101
|
if (declared !== undefined)
|
|
87
102
|
this.retention = declared;
|
|
103
|
+
// #292:声明 + 两只读面逐字转发(理由见各自的字段注)。
|
|
104
|
+
if (inner.placements !== undefined)
|
|
105
|
+
this.placements = inner.placements;
|
|
106
|
+
if (typeof inner.listPlaced === "function")
|
|
107
|
+
this.listPlaced = (kind, o) => inner.listPlaced(kind, o);
|
|
108
|
+
const innerPlacementOf = inner.placementOf;
|
|
109
|
+
if (typeof innerPlacementOf === "function")
|
|
110
|
+
this.placementOf = (id) => innerPlacementOf.call(inner, id);
|
|
88
111
|
const ownerAware = inner;
|
|
89
112
|
if (ownerAware.ownerOf)
|
|
90
113
|
this.ownerOf = ownerAware.ownerOf.bind(inner);
|
|
@@ -187,6 +210,13 @@ export class CachingSessionStore {
|
|
|
187
210
|
return fresh;
|
|
188
211
|
}
|
|
189
212
|
const hit = this.cache.get(sessionId);
|
|
213
|
+
// 🔴 #292 义务 6(claim 关门)在**暖命中**上的半场:一次 claim 形 acquire 撞上缓存里的 placed 会话,
|
|
214
|
+
// 若按下面的「命中即返回」放行,包装层就成了义务的一个**旁路**——同一条 id 在冷缓存上被拒、在暖
|
|
215
|
+
// 缓存上被接受,而生产默认恰恰是暖的。判据仍吃**持久化元组**(一次 PK 探针,只在声明了分区的内层
|
|
216
|
+
// 店 + 非 requireExisting 的形上付),绝不用缓存里的影子事实当准入依据。
|
|
217
|
+
if (hit && opts?.requireExisting !== true && this.placements !== undefined && this.placementOf !== undefined) {
|
|
218
|
+
assertPlacementAdmission(sessionId, await this.placementOf(sessionId), opts);
|
|
219
|
+
}
|
|
190
220
|
if (hit) {
|
|
191
221
|
// A warm cache hit means the session EXISTS → it satisfies `requireExisting` (no need to re-open the durable
|
|
192
222
|
// log). design/114 Phase3: the fail-loud only ever fires on a genuinely-missing (uncached) session below.
|
|
@@ -208,8 +238,19 @@ export class CachingSessionStore {
|
|
|
208
238
|
return a;
|
|
209
239
|
}
|
|
210
240
|
const inflight = this.pending.get(sessionId);
|
|
211
|
-
if (inflight)
|
|
212
|
-
|
|
241
|
+
if (inflight) {
|
|
242
|
+
// 🔴 #292(codex 对抗复审 R1-[high],验真后修):**in-flight 合并也要逐调用者复判**准入。
|
|
243
|
+
// 病:两个并发调用一冷一后到,后到者直接拿走**首个**调用者的 promise —— 于是
|
|
244
|
+
// · 可信 placement 创建先到 + claim 形后到 ⇒ 后者当场附着到一条 placed 转录上(义务 6 被绕过);
|
|
245
|
+
// · 顺序反过来 ⇒ placement 创建方拿到一只**普通**会话(镜像方向的 squat 判本该拒,却被合并吞掉)。
|
|
246
|
+
// 内层 SQL 店自己有逐调用者复判(它的 pending 分支同款),但**默认生产形是本包装层**,而本层在
|
|
247
|
+
// 到达内层之前就把第二个调用者截断了 —— 判据被一层缓存挡在门外,正是本类反复吃过的那一类亏。
|
|
248
|
+
const acquired = await inflight;
|
|
249
|
+
if (this.placements !== undefined && this.placementOf !== undefined) {
|
|
250
|
+
assertPlacementAdmission(sessionId, await this.placementOf(sessionId), opts);
|
|
251
|
+
}
|
|
252
|
+
return acquired;
|
|
253
|
+
}
|
|
213
254
|
const p = this.inner
|
|
214
255
|
.acquire(sessionId, opts)
|
|
215
256
|
.then((a) => {
|
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
* 路由表 LRU 上限 {@link ROUTE_MAX}:满额逐最老(refresh-on-use);被逐后下次 `acquire` 按上述规则重derive
|
|
24
24
|
* (requireExisting 的 host-first 重试仍落对店),中途 touch/noteTaskRun 缺路由=落 transient(no-op 级)。
|
|
25
25
|
*/
|
|
26
|
-
import type
|
|
26
|
+
import { type SessionStore, type AcquiredSession, type SessionStoreSummary, type SessionPlacement, type PlacedSessionRow } from "@sema-agent/core";
|
|
27
|
+
/** acquire 选项形(core 5.48 SessionStore 契约):#292 placement=受信 spawner 的分区建会话声明。 */
|
|
28
|
+
type AcquireOpts = {
|
|
29
|
+
requireExisting?: boolean;
|
|
30
|
+
placement?: SessionPlacement;
|
|
31
|
+
};
|
|
27
32
|
export declare class ForkRoutingSessionStore implements SessionStore {
|
|
28
33
|
private readonly host;
|
|
29
34
|
private readonly transient;
|
|
@@ -35,12 +40,28 @@ export declare class ForkRoutingSessionStore implements SessionStore {
|
|
|
35
40
|
* 亲读),逐出后 transient-first 的 requireExisting 会把陈旧快照端回给 revive。本集不逐出(上界=
|
|
36
41
|
* parked 子代数,天然小);pick/acquire 首查。 */
|
|
37
42
|
private readonly promoted;
|
|
43
|
+
/** #292 placement 半场:placed id 名册(与 promoted 同姿势——不随路由 LRU 逐出)。placed 会话的
|
|
44
|
+
* 真居所恒=host(分区在 durable 店),任何形的 acquire/touch/release 都不得落 transient——
|
|
45
|
+
* 否则 transient 的 claim-create 会铸出同 id 影子会话,requireExisting 的 transient-first 顺序
|
|
46
|
+
* 会让影子遮蔽真转录(F-7 顺序对 placed 反向成害)。 */
|
|
47
|
+
private readonly placed;
|
|
48
|
+
/** #292:host 店的 placements 声明透传(声明制——包装层不 duck-type,host 声明了才武装 placed
|
|
49
|
+
* 路由;host 不声明时本店全行为与 5.47 前逐字节同)。 */
|
|
50
|
+
readonly placements?: SessionStore["placements"];
|
|
51
|
+
/** #292:分区枚举透传(host 有才有——恒在的空实现会把「不能枚举」谎报成「没有 placed 会话」)。 */
|
|
52
|
+
readonly listPlaced?: (kind: "subagent", opts?: {
|
|
53
|
+
olderThanMs?: number;
|
|
54
|
+
scope?: string;
|
|
55
|
+
}) => Promise<PlacedSessionRow[]>;
|
|
38
56
|
constructor(host: SessionStore, transient: SessionStore);
|
|
39
57
|
private remember;
|
|
40
58
|
private pick;
|
|
41
|
-
acquire
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
/** #292:host 声明分区时的冷缓存判位探针。判据=host `requireExisting` acquire 的 `SessionMetadata.placement`
|
|
60
|
+
* (5.48 additive,File/SQL/Ttl 三形都带——比 `placementOf` 面更普适:Ttl 缓存层不透传 placementOf)。
|
|
61
|
+
* placed 命中把 acquired 会话一并交回(调用方正需要它时零二次 acquire);非 placed/absent 的探针
|
|
62
|
+
* acquire 一律 **forget** 收尾([4743] 义务①:验存在的 acquire 禁 release——release(placed)=真删)。 */
|
|
63
|
+
private hostPlacedProbe;
|
|
64
|
+
acquire(sessionId?: string, opts?: AcquireOpts): Promise<AcquiredSession>;
|
|
44
65
|
touch(sessionId: string): void | Promise<void>;
|
|
45
66
|
release(sessionId: string): Promise<void>;
|
|
46
67
|
forget(sessionId: string): void | Promise<void>;
|
|
@@ -70,4 +91,5 @@ export declare class ForkRoutingSessionStore implements SessionStore {
|
|
|
70
91
|
get size(): number;
|
|
71
92
|
dispose(): void | Promise<void>;
|
|
72
93
|
}
|
|
94
|
+
export {};
|
|
73
95
|
//# sourceMappingURL=fork-routing-session-store.d.ts.map
|
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ForkRoutingSessionStore — the subRunner's session backing after core 1.350's fork 终修([1364]①/[1367]①).
|
|
3
|
+
*
|
|
4
|
+
* 问题形:core 的 `hostSessionFork` 在 HOST runner 的店里 fork,fork 子任务带 `requireExistingSession:true`
|
|
5
|
+
* 在 opts.runner(subRunner)上执行——subRunner 拆店时子任务查不到刚 fork 出的会话,响亮失败
|
|
6
|
+
* `resume.session_not_found`(cli 真机复现)。
|
|
7
|
+
*
|
|
8
|
+
* 但把 host durable 店**整个**给 subRunner(第一版修)被 codex 判两 high:普通子任务(council 镜头/
|
|
9
|
+
* team/子代理)会把每次运行都铸成 committed durable 会话——sweepStagingSessions 只扫**无 session_meta**
|
|
10
|
+
* 的 staging 弃置行,committed 子会话没有 reaper=无界增长;且进 owner 过滤的会话枚举(零 run 的内部
|
|
11
|
+
* 会话污染 /v1/sessions)。
|
|
12
|
+
*
|
|
13
|
+
* 此包装店按**会话的真居所**路由,两边语义都保住:
|
|
14
|
+
* - `acquire(id, {requireExisting})`(fork 子 + retained-child resume 的形)→ **host 店优先**,miss 落
|
|
15
|
+
* transient(retained 瞬态子的 resume 仍走私店);两边都 miss=抛 host 侧错误(fail-loud 原语义)。
|
|
16
|
+
* - `acquire()`(无 id=普通子任务铸新会话)→ **transient**(throwaway 姿势保留:council/team 子会话
|
|
17
|
+
* 不落 durable、不进枚举、TTL 自清)。
|
|
18
|
+
* - `acquire(id)`(带 id 不 requireExisting)→ 已有路由按路由走;未知 id 落 transient(claim-create
|
|
19
|
+
* 绝不打到 durable 店——那是 authorizer 的职权)。
|
|
20
|
+
* - `fork(sourceId)` → 源在哪店在哪店 fork(路由未知时 host 先试,null 再 transient);新 id 记路由。
|
|
21
|
+
* - `list`/`size`/`dispose` = transient 侧(host 店归 main runner 所有;不双列、不双清)。
|
|
22
|
+
*
|
|
23
|
+
* 路由表 LRU 上限 {@link ROUTE_MAX}:满额逐最老(refresh-on-use);被逐后下次 `acquire` 按上述规则重derive
|
|
24
|
+
* (requireExisting 的 host-first 重试仍落对店),中途 touch/noteTaskRun 缺路由=落 transient(no-op 级)。
|
|
25
|
+
*/
|
|
26
|
+
import { SessionError } from "@sema-agent/core";
|
|
27
|
+
import { recordFailOpen } from "../observability/fail-open.js";
|
|
1
28
|
const ROUTE_MAX = 8192;
|
|
2
29
|
export class ForkRoutingSessionStore {
|
|
3
30
|
host;
|
|
@@ -10,9 +37,26 @@ export class ForkRoutingSessionStore {
|
|
|
10
37
|
* 亲读),逐出后 transient-first 的 requireExisting 会把陈旧快照端回给 revive。本集不逐出(上界=
|
|
11
38
|
* parked 子代数,天然小);pick/acquire 首查。 */
|
|
12
39
|
promoted = new Set();
|
|
40
|
+
/** #292 placement 半场:placed id 名册(与 promoted 同姿势——不随路由 LRU 逐出)。placed 会话的
|
|
41
|
+
* 真居所恒=host(分区在 durable 店),任何形的 acquire/touch/release 都不得落 transient——
|
|
42
|
+
* 否则 transient 的 claim-create 会铸出同 id 影子会话,requireExisting 的 transient-first 顺序
|
|
43
|
+
* 会让影子遮蔽真转录(F-7 顺序对 placed 反向成害)。 */
|
|
44
|
+
placed = new Set();
|
|
45
|
+
/** #292:host 店的 placements 声明透传(声明制——包装层不 duck-type,host 声明了才武装 placed
|
|
46
|
+
* 路由;host 不声明时本店全行为与 5.47 前逐字节同)。 */
|
|
47
|
+
placements;
|
|
48
|
+
/** #292:分区枚举透传(host 有才有——恒在的空实现会把「不能枚举」谎报成「没有 placed 会话」)。 */
|
|
49
|
+
listPlaced;
|
|
13
50
|
constructor(host, transient) {
|
|
14
51
|
this.host = host;
|
|
15
52
|
this.transient = transient;
|
|
53
|
+
// #292 声明/枚举面:host 声明什么透传什么(transient 永不声明 placements——分区住 durable 店)。
|
|
54
|
+
if (host.placements !== undefined)
|
|
55
|
+
this.placements = host.placements;
|
|
56
|
+
const hostListPlaced = host.listPlaced;
|
|
57
|
+
if (typeof hostListPlaced === "function") {
|
|
58
|
+
this.listPlaced = (kind, o) => hostListPlaced.call(host, kind, o);
|
|
59
|
+
}
|
|
16
60
|
// backlog(hasSessionFork 恒真):两 backing 都无 fork 面时,包装店的 fork 方法**摘除**——core 的
|
|
17
61
|
// fork 门(hasSessionFork(opts.runner.sessions))才能诚实报 fork.unavailable,而不是放行后折叠成
|
|
18
62
|
// 误导性的「source not found」。现三 backing 均有 fork,此臂为接入无 fork durable backend 备。
|
|
@@ -30,29 +74,71 @@ export class ForkRoutingSessionStore {
|
|
|
30
74
|
}
|
|
31
75
|
}
|
|
32
76
|
pick(id) {
|
|
33
|
-
if (this.promoted.has(id))
|
|
34
|
-
return this.host; //
|
|
77
|
+
if (this.promoted.has(id) || this.placed.has(id))
|
|
78
|
+
return this.host; // 名册优先(路由可被 LRU 逐出,名册不逐;placed 同姿势 #292)
|
|
35
79
|
const r = this.route.get(id);
|
|
36
80
|
if (r !== undefined)
|
|
37
81
|
this.remember(id, r); // refresh recency
|
|
38
82
|
return r === "host" ? this.host : this.transient;
|
|
39
83
|
}
|
|
84
|
+
/** #292:host 声明分区时的冷缓存判位探针。判据=host `requireExisting` acquire 的 `SessionMetadata.placement`
|
|
85
|
+
* (5.48 additive,File/SQL/Ttl 三形都带——比 `placementOf` 面更普适:Ttl 缓存层不透传 placementOf)。
|
|
86
|
+
* placed 命中把 acquired 会话一并交回(调用方正需要它时零二次 acquire);非 placed/absent 的探针
|
|
87
|
+
* acquire 一律 **forget** 收尾([4743] 义务①:验存在的 acquire 禁 release——release(placed)=真删)。 */
|
|
88
|
+
async hostPlacedProbe(id) {
|
|
89
|
+
if (this.placed.has(id)) {
|
|
90
|
+
const acquired = await this.host.acquire(id, { requireExisting: true });
|
|
91
|
+
return { state: "placed", acquired };
|
|
92
|
+
}
|
|
93
|
+
if (this.placements?.subagent === undefined)
|
|
94
|
+
return { state: "absent" };
|
|
95
|
+
let acquired;
|
|
96
|
+
try {
|
|
97
|
+
acquired = await this.host.acquire(id, { requireExisting: true });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
// not_found=常态(绝大多数 id 非 placed),静默折 absent;**真故障**(host 不可达/读错)同样折
|
|
101
|
+
// absent 但计数留痕(#191 门②:F 类兜底必走 recordFailOpen)——放行的是 transient 腿在 host
|
|
102
|
+
// 故障窗内保持独立可用,代价与恢复路径见 tag 注册表。
|
|
103
|
+
if (!(err instanceof SessionError && err.code === "not_found")) {
|
|
104
|
+
recordFailOpen("server.fork-routing.placed-probe-host-error", err instanceof Error ? err.message : String(err));
|
|
105
|
+
}
|
|
106
|
+
return { state: "absent" };
|
|
107
|
+
}
|
|
108
|
+
const meta = await acquired.session.getMetadata();
|
|
109
|
+
if (meta.placement !== undefined) {
|
|
110
|
+
this.placed.add(id);
|
|
111
|
+
return { state: "placed", acquired };
|
|
112
|
+
}
|
|
113
|
+
await this.host.forget?.(id); // 只为验居所:丢缓存引用,内容不动(禁 release)
|
|
114
|
+
return { state: "unplaced" };
|
|
115
|
+
}
|
|
40
116
|
async acquire(sessionId, opts) {
|
|
41
117
|
if (sessionId === undefined) {
|
|
42
|
-
const a = await this.transient.acquire();
|
|
118
|
+
const a = await this.transient.acquire(undefined, opts);
|
|
43
119
|
this.remember(a.sessionId, "transient");
|
|
44
120
|
return a;
|
|
45
121
|
}
|
|
46
|
-
|
|
122
|
+
// #292 建会话入分区形(受信 spawner:带 placement、不 requireExisting)——分区住 host durable 店;
|
|
123
|
+
// host 不声明 placements 时按旧行为落 transient(core 铸造门本就不会给这种部署铸 placement,
|
|
124
|
+
// 这臂只是防御性一致)。
|
|
125
|
+
if (opts?.placement !== undefined && opts.requireExisting !== true && this.placements?.subagent !== undefined) {
|
|
126
|
+
const a = await this.host.acquire(sessionId, opts);
|
|
127
|
+
this.placed.add(sessionId);
|
|
128
|
+
this.remember(sessionId, "host");
|
|
129
|
+
return a;
|
|
130
|
+
}
|
|
131
|
+
if (this.promoted.has(sessionId) || this.placed.has(sessionId)) {
|
|
47
132
|
// [1593] 晋升名册:恒走 host——绝不能让 transient-first 把 forget 后仍活在私店 repo 里的陈旧
|
|
48
|
-
// 快照端给 revive(探针 Finding A)。
|
|
133
|
+
// 快照端给 revive(探针 Finding A)。placed 名册同规则(#292:影子遮蔽真转录的反向 F-7)。
|
|
49
134
|
const a = await this.host.acquire(sessionId, opts);
|
|
50
135
|
this.remember(sessionId, "host");
|
|
51
136
|
return a;
|
|
52
137
|
}
|
|
53
138
|
const routed = this.route.get(sessionId);
|
|
54
|
-
if (routed
|
|
55
|
-
const a = await this.
|
|
139
|
+
if (routed === "host") {
|
|
140
|
+
const a = await this.host.acquire(sessionId, opts);
|
|
141
|
+
this.remember(sessionId, "host");
|
|
56
142
|
return a;
|
|
57
143
|
}
|
|
58
144
|
if (opts?.requireExisting) {
|
|
@@ -60,9 +146,19 @@ export class ForkRoutingSessionStore {
|
|
|
60
146
|
// transient(miss)→ fallback host 命中,语义不变。顺序反转封住 F-7 组合链:同 id 在两店并存时
|
|
61
147
|
// (child uuid 被同租户误提交到主 API 而 host 店 claim-create 出空壳),真子会话(transient)赢,
|
|
62
148
|
// 空壳不遮蔽。双 miss 抛 host 侧错误(响亮,fail-closed 到「真不存在」)。
|
|
149
|
+
// #292 例外:transient 命中且 host 声明分区时,还要过一次 placed 判位——同 id 若在 host 是 placed
|
|
150
|
+
// 会话,transient 那份是影子(claim-create 铸的),真转录必须赢(F-7 顺序对 placed 反向成害)。
|
|
63
151
|
let transientErr;
|
|
64
152
|
try {
|
|
65
153
|
const a = await this.transient.acquire(sessionId, opts);
|
|
154
|
+
if (this.placements?.subagent !== undefined) {
|
|
155
|
+
const probe = await this.hostPlacedProbe(sessionId);
|
|
156
|
+
if (probe.state === "placed" && probe.acquired !== undefined) {
|
|
157
|
+
await this.transient.forget?.(sessionId); // 影子只丢引用(私店 repo 行本就 sweep 不到,名册是真防线)
|
|
158
|
+
this.remember(sessionId, "host");
|
|
159
|
+
return probe.acquired;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
66
162
|
this.remember(sessionId, "transient");
|
|
67
163
|
return a;
|
|
68
164
|
}
|
|
@@ -71,6 +167,20 @@ export class ForkRoutingSessionStore {
|
|
|
71
167
|
}
|
|
72
168
|
try {
|
|
73
169
|
const a = await this.host.acquire(sessionId, opts);
|
|
170
|
+
// 顺手:host 端回的会话若带 placement 元组,入名册(后续零探针直达)。只在声明分区的部署上读
|
|
171
|
+
// (非声明形零行为差),且 meta 读失败不阻断 acquire(名册只是省探针的缓存,不是判定)。
|
|
172
|
+
if (this.placements?.subagent !== undefined) {
|
|
173
|
+
try {
|
|
174
|
+
const meta = await a.session.getMetadata();
|
|
175
|
+
if (meta.placement !== undefined)
|
|
176
|
+
this.placed.add(sessionId);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// 名册只是省探针的缓存,不是判定——缺一条=该 id 下次多付一次 hostPlacedProbe,正确性零影响;
|
|
180
|
+
// 计数留痕(#191 门②)让「meta 一直读失败」的环境病在遥测显形。
|
|
181
|
+
recordFailOpen("server.fork-routing.placed-roster-meta-unreadable", err instanceof Error ? err.message : String(err));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
74
184
|
this.remember(sessionId, "host");
|
|
75
185
|
return a;
|
|
76
186
|
}
|
|
@@ -80,6 +190,17 @@ export class ForkRoutingSessionStore {
|
|
|
80
190
|
}
|
|
81
191
|
}
|
|
82
192
|
// 带 id 但不要求已存在:claim-create 只允许落 transient(durable 店的 claim-create 是 authorizer 的职权)。
|
|
193
|
+
// #292 claim closure(义务⑥过墙形):id 若已是 host 分区的 placed 会话,claim-create 落 transient 会
|
|
194
|
+
// 铸同 id 影子——委托给 host claim 形,让其 placement_refused 响亮拒(绝不静默铸影子)。
|
|
195
|
+
if (this.placements?.subagent !== undefined) {
|
|
196
|
+
const probe = await this.hostPlacedProbe(sessionId);
|
|
197
|
+
if (probe.state === "placed") {
|
|
198
|
+
await this.host.forget?.(sessionId); // 探针引用丢弃(refusal 路径不把会话交出)
|
|
199
|
+
const a = await this.host.acquire(sessionId, opts); // claim 形打声明店 ⇒ placement_refused(义务⑥)
|
|
200
|
+
this.remember(sessionId, "host");
|
|
201
|
+
return a; // 不可达(上一行必抛);保留形以防 host 语义演化时静默吞
|
|
202
|
+
}
|
|
203
|
+
}
|
|
83
204
|
const a = await this.transient.acquire(sessionId, opts);
|
|
84
205
|
this.remember(sessionId, "transient");
|
|
85
206
|
return a;
|
|
@@ -151,7 +272,9 @@ export class ForkRoutingSessionStore {
|
|
|
151
272
|
catch {
|
|
152
273
|
// 私店无此会话:可能已在 host(直查确认=幂等),否则如实抛(host 侧错误响亮上浮)
|
|
153
274
|
const probe = await this.host.acquire(sessionId, { requireExisting: true });
|
|
154
|
-
|
|
275
|
+
// #292 preflight 纪律([4743] 义务①):**只为验存在**的 acquire 收尾一律 forget —— host 店的
|
|
276
|
+
// release 对 placed 会话是真删除(core 5.48 义务 4),用它给一次探活收尾就是拿删除当探针。
|
|
277
|
+
await this.host.forget?.(probe.sessionId);
|
|
155
278
|
this.promoted.add(sessionId);
|
|
156
279
|
this.remember(sessionId, "host");
|
|
157
280
|
return;
|
|
@@ -179,7 +302,9 @@ export class ForkRoutingSessionStore {
|
|
|
179
302
|
throw new Error(`session "${sessionId}" promotion verification failed (leaf mismatch after import)`);
|
|
180
303
|
}
|
|
181
304
|
finally {
|
|
182
|
-
|
|
305
|
+
// #292:晋升写完只需**丢引用**(内容必须留下,那正是晋升的目的)⇒ forget,不是 release
|
|
306
|
+
// (同上:release 对 placed 会话已是真删除,而这条 finally 连失败路径都会走)。
|
|
307
|
+
await this.host.forget?.(sessionId);
|
|
183
308
|
}
|
|
184
309
|
try {
|
|
185
310
|
await this.transient.forget?.(sessionId);
|
|
@@ -64,6 +64,7 @@ export declare class PgMemoryEngineBackend implements MemoryBackend {
|
|
|
64
64
|
getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
|
|
65
65
|
search(query: string, scopes: readonly string[], opts?: {
|
|
66
66
|
limit?: number;
|
|
67
|
+
exposureBands?: boolean;
|
|
67
68
|
}): Promise<ScoredMemoryEntry[]>;
|
|
68
69
|
applyPatches(patches: readonly NotePatch[]): Promise<PatchReport>;
|
|
69
70
|
private applyOne;
|