@sema-agent/server 7.36.0 → 7.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/USAGE.md +9 -0
  2. package/dist/boot/resolve-spec.js +6 -1
  3. package/dist/boot/runner-deps.d.ts +21 -3
  4. package/dist/boot/runner-deps.js +31 -4
  5. package/dist/boot/session-faces.d.ts +3 -0
  6. package/dist/boot/session-faces.js +11 -2
  7. package/dist/boot/side-query-lane.d.ts +77 -54
  8. package/dist/boot/side-query-lane.js +102 -66
  9. package/dist/boot/stores.d.ts +1 -0
  10. package/dist/boot/stores.js +19 -1
  11. package/dist/boot/task-list-lane.d.ts +92 -0
  12. package/dist/boot/task-list-lane.js +63 -0
  13. package/dist/bounded-session-map.d.ts +3 -0
  14. package/dist/bounded-session-map.js +5 -0
  15. package/dist/capabilities/scenarios.d.ts +30 -2
  16. package/dist/capabilities/scenarios.js +16 -3
  17. package/dist/config-types.d.ts +21 -1
  18. package/dist/config.js +8 -1
  19. package/dist/hooks/branch-transcript.d.ts +3 -2
  20. package/dist/hooks/branch-transcript.js +7 -1
  21. package/dist/http/active-run-conflict.d.ts +43 -0
  22. package/dist/http/active-run-conflict.js +8 -0
  23. package/dist/http/route-ctx.d.ts +22 -1
  24. package/dist/http/routes/approvals-assistant.js +13 -1
  25. package/dist/http/routes/notify-wake.js +6 -0
  26. package/dist/http/routes/runs.js +1 -1
  27. package/dist/http/routes/tasks.js +30 -2
  28. package/dist/http/server.js +103 -7
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +5 -0
  31. package/dist/main.js +11 -3
  32. package/dist/memory-posture.d.ts +12 -1
  33. package/dist/memory-posture.js +2 -0
  34. package/dist/observability/fail-open.d.ts +9 -1
  35. package/dist/observability/fail-open.js +9 -1
  36. package/dist/plugins/memory-engine-pg.js +42 -4
  37. package/dist/plugins/memory-engine-tidb.js +38 -4
  38. package/dist/plugins/memory-origin-law.d.ts +69 -0
  39. package/dist/plugins/memory-origin-law.js +98 -0
  40. package/dist/plugins/retention-store-sql.d.ts +7 -0
  41. package/dist/plugins/retention-store-sql.js +24 -0
  42. package/dist/plugins/task-list-store-sql.d.ts +36 -25
  43. package/dist/plugins/task-list-store-sql.js +102 -0
  44. package/dist/run-local.js +14 -5
  45. package/dist/runs.js +17 -0
  46. package/dist/trace/engine-notice-wire.d.ts +128 -0
  47. package/dist/trace/engine-notice-wire.js +256 -0
  48. package/dist/trace/ledger-events.d.ts +11 -1
  49. package/dist/trace/ledger-sink.d.ts +17 -0
  50. package/dist/trace/ledger-sink.js +25 -0
  51. package/package.json +2 -2
@@ -2,38 +2,43 @@
2
2
  * #303([4610] test 报「/model 中途切换校验探针经 POST /v1/side-query 对有效 key 返回上游 401」)——
3
3
  * side-query 面的 **per-model key plane** 装配。
4
4
  *
5
- * ## 病灶(亲验 core 5.43.0 dist 实现行)
5
+ * ## 病灶(#303 立案时亲验 core 5.43.0 dist 实现行)
6
6
  *
7
- * core 的 side-query verb **结构性没有 key 座位**:
7
+ * core 的 side-query verb 当时**结构性没有 key 座位**:
8
8
  * · `core/side-query.d.ts` 的 `SideQuerySpec` 无 `apiKey` / `getApiKeyAndHeaders` 字段;
9
9
  * · `SideQueryDeps` = `{ brain, models?, roles? }`;
10
10
  * · `core/side-query.js` 铸 brain `options` 时只放 `signal/maxTokens/reasoning`,**不含 apiKey**。
11
- * 而 openai brain 的 `buildRequest`(`brain/openai.js:281-282`)是这两行:
11
+ * 而 openai brain 的 `buildRequest`(`brain/openai.js`)是这两行:
12
12
  * `const apiKey = options?.apiKey ?? config.apiKey;` ← 缺席即回落 **brain 构造时的网关 key**
13
13
  * `const root = (model.baseUrl || config.baseUrl || "")` ← 却**按模型**选路
14
14
  * ⇒ 对带 per-model `baseUrl` + `apiKeyEnv`/`sealedApiKey` 的模型,side-query 把**主网关 key 发到
15
15
  * 外部模型 URL**:用户看到上游 401,同时这是一次凭据外泄(安全轴)。
16
- *
17
- * 主推理链没有这个病:core 每次 brain 调用都调 `TaskSpec.getApiKeyAndHeaders`(server 座位=
18
- * `boot/resolve-spec.ts` 的 `getKeyResolver()` 活取);hook-llm 面自己解析并对 off-route 模型
19
- * fail-closed。side-query key plane 三面里**唯一**缺席的那一面。
20
- *
21
- * ## 修形(窄、可撤)
22
- *
23
- * side-query 消费面**脱离 `Runner.sideQuery`**,直调 core 公开导出的 `runSideQuery`,并给它一只
24
- * **key 注入 brain wrapper**:每次调用按本次 spec 解析出的 `Model` 现解 per-model key,有则注入
25
- * `options.apiKey`(core 那行 `options?.apiKey ?? config.apiKey` 于是走前一支),没有则**不注入**
26
- * (回落网关 key = 主推理链逐字同语义)。wrapper 只包 side-query 专用的这一只引用,主推理链的
27
- * `RunnerDeps.brain` 零扰动。
16
+ * 当时的窄修 = 一只 **key 注入 brain wrapper**(`createPerModelKeyBrain`),并在顶注写明「core 一旦
17
+ * `SideQuerySpec` 补上 key 座位,本 wrapper 即可撤」,撤除信号挂机器钉。
18
+ *
19
+ * ## #341 换装(core 5.46.0 提货,[4615] 座位到货 —— 撤除条件已满足)
20
+ *
21
+ * core 5.46.0 的 `SideQuerySpec` 有了 `getApiKeyAndHeaders?: TaskSpec["getApiKeyAndHeaders"]`,
22
+ * `runSideQuery` 每次调用按**解析后的** `Model` 调它,并把 `{apiKey, headers}` 铸进 brain options
23
+ * (`dist/core/side-query.js`:`const auth = await spec.getApiKeyAndHeaders?.(resolved.model);`)——
24
+ * 与主推理链逐字同一只座位形。于是 side-query 席**撤掉 brain wrapper**,key 供给改走 spec 座位:
25
+ * · 本席不再在 side-query 路上包 `ctx.brain`,`RunnerDeps.brain` 的零扰动从「wrapper 只包一只引用」
26
+ * 降级成**结构性事实**(这条路上根本没有 wrapper);
27
+ * · `headers` 半边从此有出路(wrapper 形只注得进 `apiKey`,core 的座位 `{apiKey, headers?}` 两半都收);
28
+ * · 判据(`gatewayBaseUrl`)与 resolver 一样**按调用现取** —— 见下面 `createSideQueryLane` 的注。
29
+ * `createPerModelKeyBrain` **不删**:它仍是 WebFetch 摘要面(`boot/webfetch-summarize-lane.ts`)的
30
+ * key plane —— core 的 `WebFetchSummarizerOptions` 至今只有 `maxContentChars`,那一面没有座位可换
31
+ * (亲验 core 5.46.0 `dist/tools/web.d.ts`)。
28
32
  *
29
33
  * 🔴 **poison(sealed key 不可解)上抛,绝不静默回落网关 key** —— `resolveModelApiKey` 抛
30
34
  * `SealedKeyPoisonedError` 是「这个模型配了托管密钥但解不开」的响亮态;吞成 undefined 等于用共享
31
- * 网关账号去打这只模型的上游,正是毒丸机制存在的理由(CLAUDE.md #157 安全轴 fail-closed)
35
+ * 网关账号去打这只模型的上游,正是毒丸机制存在的理由(CLAUDE.md #157 安全轴 fail-closed)。换装后
36
+ * 这条语义由 core 承载得更彻底:座位抛 ⇒ `runSideQuery` 的 `await` 直接上抛,brain 一个字节都没发。
32
37
  *
33
38
  * ## 具名残余:**无 per-model key 的 off-route 模型仍收网关 key**(codex R1-F1,逐条评估后如实留)
34
39
  *
35
- * 判据本身没变:`undefined` ⇒ 不注入 ⇒ core 用网关 key,即使这只模型的 `baseUrl` 指向别家。为什么
36
- * **不**在本批把它改成 fail-closed(逐条,而不是"先不管"):
40
+ * 判据本身没变(座位到货也没改它):`undefined` ⇒ 座位不给凭据 ⇒ core 用网关 key,即使这只模型的
41
+ * `baseUrl` 指向别家。为什么**不**在本批把它改成 fail-closed(逐条,而不是"先不管"):
37
42
  * ① **与主推理链同语义**才是当前的正确性基准 —— 任务面(core 每次调 `getApiKeyAndHeaders`,返回
38
43
  * `undefined` 即网关 key)在**同一个** catalog、**同一批**模型上就是这个姿势。只把 side-query 收严,
39
44
  * 等于让同一只模型「跑任务能用、问一句就 401」,而任务面才是量大的那一面 —— 半边收严的覆盖比
@@ -46,14 +51,6 @@
46
51
  * ⇒ 本批**不改行为**,把它作为**跨面语义问题**上报(要收严就三面一起收严、带旋钮、走表态制),
47
52
  * 而不是在这一面偷偷收严。真要收严时的正解:按 `normUrl` 比对 `gatewayBaseUrl`(+fallback 列表)
48
53
  * 与 anthropic 路由,off-route ∧ 无 per-model key ⇒ 拒发,并同批改任务面。
49
- *
50
- * ## 撤除条件(设计成可撤形)
51
- *
52
- * core 一旦给 `SideQuerySpec` 补上 key 座位(`apiKey` 或 `getApiKeyAndHeaders`),本模块的 wrapper
53
- * 即可撤:装配点改成把 `getKeyResolver()` 直接塞进 spec,`createPerModelKeyBrain` 整只删除。
54
- * 撤除信号有机器钉:`test/side-query-key-plane.test.ts` 的「core 座位缺席钉」在 core 补座位那天先红。
55
- * 在那之前,本模块**不是** upstream 语义的旁路重写 —— 它只在 server 自己的装配层给自己的 brain
56
- * 引用补上 server 自己的凭据平面,core 的 side-query 语义(选路/归一/结果形)原样由 `runSideQuery` 决定。
57
54
  */
58
55
  import { expandTiers, runSideQuery } from "@sema-agent/core";
59
56
  import { recordFailOpen } from "../observability/fail-open.js";
@@ -64,7 +61,7 @@ import { recordFailOpen } from "../observability/fail-open.js";
64
61
  *
65
62
  * 参照系缺席(`gatewayBaseUrl` 未给)⇒ 回 `false`:判不了就不判,别猜。
66
63
  *
67
- * ⚠️ `hooks/hook-llm.ts:118` 有一只逐字同形的内联归一器(它用同一判据做 **fail-closed 拒发**,本函数只
64
+ * ⚠️ `hooks/hook-llm.ts` 有一只逐字同形的内联归一器(它用同一判据做 **fail-closed 拒发**,本函数只
68
65
  * 做**计数**)。两处合一属跨面收严件 #309 的射程(那时三面同批换判据),本批刻意不动 hook 面的 fail-closed
69
66
  * 门 —— 在只改可观测性的批里重写另一条安全轴的门,风险与收益不对等。
70
67
  */
@@ -79,40 +76,71 @@ export function isOffRouteBaseUrl(modelBaseUrl, gatewayBaseUrl) {
79
76
  return norm(modelBaseUrl) !== norm(gatewayBaseUrl);
80
77
  }
81
78
  /**
82
- * per-model key 注入 wrapper
79
+ * per-model auth 座(#341,core 5.46.0 `SideQuerySpec.getApiKeyAndHeaders` 的 server 侧填充物)
80
+ *
81
+ * 语义(逐条对齐主推理链 `getApiKeyAndHeaders`,也逐条对齐它取代的 wrapper 那三条臂):
82
+ * · `upstream`(spec 自带的座位)**解出凭据** ⇒ 原样透传,含 `headers`(上游/装饰器已经定了凭据,
83
+ * 本层不抢 —— wrapper 时代的「`options.apiKey` 已在场」臂);判别按**每次调用**的返回值,不是
84
+ * 「座位在场就整只让位」:上游对这只模型没意见时,本席照常按模型解 key;
85
+ * · resolver 缺席(部署根本没配 per-model key)⇒ 回 `undefined` ⇒ core 用网关 key(今日行为逐字不变);
86
+ * · resolver 返回 undefined(这只模型没有自己的 key)⇒ 回 `undefined` ⇒ 网关 key(additive 契约);
87
+ * · resolver 抛(sealed key 中毒)⇒ **上抛**,`runSideQuery` 的 `await` 直接把它扔给调用方,
88
+ * 本次调用整体失败,一个字节都不发。
89
+ *
90
+ * 🔴 A-057.59 [PARTIAL high](2026-08-19;#341 换装后**处置不变**)—— 「无 per-model key 的 off-route
91
+ * 模型仍收网关 key」这条具名残余**行为不变**(逐条理由见模块顶注「具名残余」段;core 的座位是
92
+ * 「absent ⇒ construction-time credentials apply」,与主推理链
93
+ * `dist/engine/harness/agent-harness.js` 的 `auth?.apiKey !== undefined` 臂逐字同语义,座位到货并没有
94
+ * 改变这条残余,所以 `P-DEBT` 计数**不可销**),但**每次调用零留痕**这一半是要清偿的存量欠账:
95
+ * config-apply 期那条 `sema_registry_model_key_env_missing` warn 只覆盖「声明了 env 名却没设」,覆盖不到
96
+ * 「压根没配 per-model key 的 off-route 模型」,而后者才是真发生外泄的那一形。按 CLAUDE.md #157
97
+ * 「一时改不动的 P 类记 `P-DEBT`」补计数:命中条件 = **解不出 per-model key ∧ 该模型的 baseUrl 不是本
98
+ * 部署网关** ⇒ 这一次调用真把网关凭据发给了别家主机。`gatewayBaseUrl` 缺席(调用方没给参照系)⇒ 不计
99
+ * (不猜),与「宁可漏计不可错计」同向。
100
+ */
101
+ export function createPerModelAuthSeat(getKeyResolver, opts = {}) {
102
+ return async (model) => {
103
+ // 🔴 **同代配对**(codex R1-[high],验真后采纳):resolver 引用必须在**第一个 await 之前**取。
104
+ // core 在同一个同步段里解析出 `model`(含它那一代的 `baseUrl`)后立刻调本席,所以本函数体的**第一条
105
+ // 同步语句**与模型解析同代。`await opts.upstream?.(model)` 是一道真 await 边界(`opts.upstream`
106
+ // 缺席时 `await undefined` 照样让出一个微任务),config-center 的 apply 提交段(#303 codex R1-F2 修成
107
+ // 「目录与密钥表零 await 窗成对换代」)可以整段落在那个缝里 ⇒ 缝之后再取 resolver 就会拿到**下一代**
108
+ // 的密钥表,配上**上一代**的模型/端点:新家的 key 发到旧家的 URL —— 正是本席存在要消灭的那个形。
109
+ // 取引用是纯读、无副作用,即使上游随后胜出也不多花什么。
110
+ const resolve = getKeyResolver();
111
+ const given = await opts.upstream?.(model);
112
+ if (given?.apiKey !== undefined)
113
+ return given; // 已有凭据:不抢
114
+ // 🔴 不 try/catch:poison 必须上抛(静默回落 = 拿共享网关账号打这只模型的上游)。
115
+ // resolver 缺席(部署根本没配 per-model key 平面)与解析回 undefined(这只模型没有自己的 key)
116
+ // 两形都落到同一条缺席语义:回 undefined ⇒ core 用网关 key(additive 契约)。
117
+ const resolved = resolve ? await resolve({ name: model.name }) : undefined;
118
+ if (resolved === undefined && isOffRouteBaseUrl(model.baseUrl, opts.gatewayBaseUrl)) {
119
+ recordFailOpen("server.model-key.off-route-model-uses-gateway-key", model.name);
120
+ }
121
+ return resolved;
122
+ };
123
+ }
124
+ /**
125
+ * per-model key 注入 brain wrapper —— **#341 后只服务 WebFetch 摘要面**
126
+ * (`boot/webfetch-summarize-lane.ts`)。side-query 面已换到 core 的 spec 座位(见顶注「#341 换装」);
127
+ * core 的 `WebFetchSummarizerOptions` 至今只有 `maxContentChars`(亲验 5.46.0 `dist/tools/web.d.ts`),
128
+ * 那一面**没有座位可换**,只能继续在 brain 层补凭据。
83
129
  *
84
- * 语义(逐条对齐主推理链 `getApiKeyAndHeaders`):
130
+ * 语义( `createPerModelAuthSeat` 同一张表,只是注入位在 `options.apiKey`):
85
131
  * · `options.apiKey` **已在场** ⇒ 原样透传(上游/装饰器已经定了凭据,本层不抢);
86
- * · resolver 缺席(部署根本没配 per-model key)⇒ 不注入 ⇒ 网关 key(今日行为逐字不变);
87
- * · resolver 返回 undefined(这只模型没有自己的 key)⇒ 不注入 ⇒ 网关 key(additive 契约);
132
+ * · resolver 缺席 / 返回 undefined ⇒ 不注入 ⇒ 网关 key(additive 契约);
88
133
  * · resolver 抛(sealed key 中毒)⇒ **上抛**,本次调用整体失败,一个字节都不发。
89
134
  *
90
- * `complete` 在场时同样包一层:core 的 `Brain.complete` 是 compaction/摘要腿的直调口,漏包等于给
91
- * 同一个病留第二条路(side-query 今天不走它,但 wrapper 的透明性不该依赖调用方今天的用法)
92
- *
93
- * 🔴 A-057.59 [PARTIAL high](2026-08-19)—— 「无 per-model key 的 off-route 模型仍收网关 key」这条
94
- * 具名残余**行为不变**(逐条理由见模块顶注「具名残余」段:与主推理链
95
- * `dist/engine/harness/agent-harness.js` 的 `auth?.apiKey !== undefined` 臂逐字同语义,单面收严会造成
96
- * 「同一模型跑任务能用、问一句 401」;收严件 = 跨面 #309,带旋钮走表态制),但**每次调用零留痕**这一半
97
- * 被判为要清偿的存量欠账:config-apply 期那条 `sema_registry_model_key_env_missing` warn 只覆盖
98
- * 「声明了 env 名却没设」,覆盖不到「压根没配 per-model key 的 off-route 模型」,而后者才是真发生外泄
99
- * 的那一形。按 CLAUDE.md #157「一时改不动的 P 类记 `P-DEBT`」补计数:命中条件 =
100
- * **解不出 per-model key ∧ 该模型的 baseUrl 不是本部署网关** ⇒ 这一次调用真把网关凭据发给了别家主机。
101
- * `gatewayBaseUrl` 缺席(调用方没给参照系)⇒ 不计(不猜),与「宁可漏计不可错计」同向。
135
+ * `complete` 在场时同样包一层:core 的 `Brain.complete` 是 compaction/摘要腿的直调口(WebFetch 摘要器
136
+ * 正是**优先**走它),漏包等于给同一个病留第二条路。
102
137
  */
103
138
  export function createPerModelKeyBrain(brain, getKeyResolver, opts = {}) {
139
+ const seat = createPerModelAuthSeat(getKeyResolver, opts);
104
140
  const keyFor = async (model, given) => {
105
141
  if (given !== undefined)
106
142
  return given; // 已有凭据:不抢
107
- const resolve = getKeyResolver();
108
- // 🔴 不 try/catch:poison 必须上抛(静默回落 = 拿共享网关账号打这只模型的上游)。
109
- // resolver 缺席(部署根本没配 per-model key 平面)与解析回 undefined(这只模型没有自己的 key)
110
- // 两形都落到同一条缺席语义:不注入 ⇒ core 用网关 key(additive 契约)。
111
- const apiKey = resolve ? (await resolve({ name: model.name }))?.apiKey : undefined;
112
- if (apiKey === undefined && isOffRouteBaseUrl(model.baseUrl, opts.gatewayBaseUrl)) {
113
- recordFailOpen("server.model-key.off-route-model-uses-gateway-key", model.name);
114
- }
115
- return apiKey;
143
+ return (await seat(model))?.apiKey;
116
144
  };
117
145
  const stream = async (model, context, options) => {
118
146
  const apiKey = await keyFor(model, options?.apiKey);
@@ -121,8 +149,6 @@ export function createPerModelKeyBrain(brain, getKeyResolver, opts = {}) {
121
149
  // 🔴 A-057.2:`.bind(brain)` **保留接收者** —— 裸摘引用(`const complete = brain.complete`)在 ESM
122
150
  // 严格模式下让 `this` = undefined,任何读 `this` 的 Brain 实现经本 wrapper 后当场 TypeError,
123
151
  // 而两行之上的 `stream` 臂是带接收者的 `brain.stream(…)`,同一只 wrapper 两条臂不该有两种调用形。
124
- // (今日是潜伏:core 5.43.0 的七只 brain 工厂全 `return { stream }`,`complete` 恒缺席;顶注自己
125
- // 写着「wrapper 的透明性不该依赖调用方今天的用法」。)
126
152
  const complete = brain.complete?.bind(brain);
127
153
  return {
128
154
  stream,
@@ -137,20 +163,30 @@ export function createPerModelKeyBrain(brain, getKeyResolver, opts = {}) {
137
163
  };
138
164
  }
139
165
  /**
140
- * 装配 side-query 执行席 = 真 `runSideQuery` + 注入式 brain,models/roles 与 Runner 同源。
141
- *
142
- * `models` **按调用**取 `expandTiers(config.models, config.tiers)`:
143
- * · 档位词 / CC 别名(`flash`/`opus`/…)在 side-query 上照常可选路 —— core 的 `Runner` 构造时做同一件事
144
- * (`runtask.js:1432`),不做等于把档位词从这条路上悄悄拿掉;
145
- * · 取「按调用」而不是「boot 一次」是因为 `config.models` config-center 就地热应用,而 `Runner` 那次
146
- * 展开是**构造时快照**(`expandTiers` tiers 非空时返回新对象)——本路由的模型白名单门
147
- * (`routes/side-query.ts` 的 `isModelAllowlisted`)本来就按请求现算同一张增广目录,两边取同一份
148
- * 才不会出现「名单放行、选路说不认识」的分歧。
166
+ * 装配 side-query 执行席 = 真 `runSideQuery` + core 的 per-model auth 座,models/roles 与 Runner 同源。
167
+ *
168
+ * **按调用**取三件活值(`models` / `getKeyResolver` / `gatewayBaseUrl`),因为它们是**同一次** center
169
+ * 下发里成对换代的(#303 codex R1-F2:目录与密钥表零 await 窗成对换代):
170
+ * · `models` = `expandTiers(config.models, config.tiers)` —— 档位词 / CC 别名(`flash`/`opus`/…)
171
+ * side-query 上照常可选路(core `Runner` 构造时做同一件事),且 `Runner` 那次展开是**构造时快照**,
172
+ * 而本路由的模型白名单门(`routes/side-query.ts` `isModelAllowlisted`)按请求现算同一张增广目录 ——
173
+ * 两边取同一份才不会出现「名单放行、选路说不认识」的分歧;
174
+ * · `getKeyResolver()` —— 热应用整个换 resolver 引用,活取才跟得上(装配时快照 = 旧 key 用到重启);
175
+ * · `config.gatewayBaseUrl` —— off-route 判据的参照系。装配时快照会让「换网关那一刻起,这次调用把网关
176
+ * 凭据发给了别家」在遥测里永久失明(钉:`test/side-query-key-plane.test.ts` 的活取钉)。
177
+ *
178
+ * `spec` 自带的 `getApiKeyAndHeaders`(嵌入方/装饰器)**不被覆盖** —— 它作为 `upstream` 折进本席,
179
+ * 每次调用先问它,它没意见时才由本席按模型解(见 `createPerModelAuthSeat` 顶注第一条臂)。
149
180
  */
150
181
  export function createSideQueryLane(ctx) {
151
- const brain = createPerModelKeyBrain(ctx.brain, ctx.getKeyResolver, { gatewayBaseUrl: ctx.config.gatewayBaseUrl });
152
- return (spec) => runSideQuery(spec, {
153
- brain,
182
+ return (spec) => runSideQuery({
183
+ ...spec,
184
+ getApiKeyAndHeaders: createPerModelAuthSeat(ctx.getKeyResolver, {
185
+ ...(ctx.config.gatewayBaseUrl !== undefined ? { gatewayBaseUrl: ctx.config.gatewayBaseUrl } : {}),
186
+ ...(spec.getApiKeyAndHeaders !== undefined ? { upstream: spec.getApiKeyAndHeaders } : {}),
187
+ }),
188
+ }, {
189
+ brain: ctx.brain,
154
190
  models: expandTiers(ctx.config.models, ctx.config.tiers) ?? ctx.config.models,
155
191
  roles: ctx.config.roles,
156
192
  });
@@ -30,5 +30,6 @@ export declare function openStores(ctx: OpenStoresCtx): Promise<{
30
30
  breakerState: import("../plugins/breaker-state-sql.js").TiDBBreakerState | import("../plugins/breaker-state-sql.js").PgBreakerState | undefined;
31
31
  usageWindowStore: import("@sema-agent/core").UsageWindowStore | undefined;
32
32
  memoryPosture: import("../memory-posture.js").MemoryPosture;
33
+ taskListLane: import("./task-list-lane.js").TaskListLane;
33
34
  }>;
34
35
  //# sourceMappingURL=stores.d.ts.map
@@ -29,6 +29,8 @@ import { PgRosterStore, TiDBRosterStore, ensurePgRosterSchema, ensureTiDBRosterS
29
29
  import { LocalTaskAttachmentStore } from "../plugins/local-task-attachment-store.js";
30
30
  import { PgTaskAttachmentStore, TiDBTaskAttachmentStore, ensurePgTaskAttachmentSchema, ensureTiDBTaskAttachmentSchema } from "../plugins/task-attachment-store.js";
31
31
  import { createSessionStore } from "../plugins/session-store.js";
32
+ import { createTaskListLane } from "./task-list-lane.js";
33
+ import { ensurePgTaskListSchema, ensureTiDBTaskListSchema } from "../plugins/task-list-store-sql.js";
32
34
  import { assertCloudSnapshotBlobPosture, openStoreBackendWithFallback } from "../plugins/store-backend.js";
33
35
  import { buildMemoryRemoteLaneWarn, memoryEngineBackendFor, memoryEngineRemoteLanePosture } from "../memory-scope.js";
34
36
  import { assertToolResultProvenanceSchema } from "../plugins/tool-result-store-sql.js";
@@ -476,6 +478,22 @@ export async function openStores(ctx) {
476
478
  logger.warn("memory_sync_configured_but_memory_dark", { note: "MEMORY_SYNC_URL is set but the file memory engine is dark (multi-tenant / MEMORY_ENGINE=off / remote exec lane) — no sync rounds will run" });
477
479
  }
478
480
  }
481
+ // #318([4659] F2 → [4692] 定谳):会话级任务清单车道。形态与 roster/mailbox/background-agent 同族
482
+ // (pg/tidb = SQL twins;无 SQL 后端 = 进程级 per-session 内存店 —— 这一支**刻意不缺席**:缺席就是
483
+ // 修前那个「每 turn 一只新私店」的病本身,而 local/内存形至少要把同一会话的跨 turn 连续性拿回来)。
484
+ // 全部判据(listKey 派生、LRU、为什么 SQL 形也缓存实例)见 boot/task-list-lane.ts 顶注。
485
+ // ⚠️ 建表在**本文件**发 —— `test/schema-baseline-generated.test.ts` 判据 ③b 拿「boot/stores.ts 里真被
486
+ // await 的 ensure 名」与 schema 基线生成器的驱动清单做集合相等对账(消灭「两条方言同时漏一族表」的
487
+ // 对称盲区,`usage_window` 曾整族漏在基线外)。DDL 藏进车道文件 = 那道门看不见它。
488
+ const taskListLane = await (async () => {
489
+ const mysqlPool = backend?.mysqlPool?.();
490
+ const pgPool = backend?.pgPool?.();
491
+ if (pgPool)
492
+ await ensurePgTaskListSchema(async (text, params) => pgPool.query(text, params));
493
+ if (mysqlPool)
494
+ await ensureTiDBTaskListSchema(mysqlPool);
495
+ return createTaskListLane({ mysqlPool, pgPool, logger });
496
+ })();
479
497
  const sessionStore = createSessionStore(config, backend, metrics); // S25: stale-affinity evict fingerprint
480
498
  // 🪦 S6 启动门(旧):判据曾是 `!sessionStore.ownerOf`,core 2.11.0 给 TtlSessionStore 补了 owner 面后**恒假**。
481
499
  // 2026-08-01 复审一度把判据改成「归属能否跨重启存活」以复活它 —— **那是错的,已撤**(cli [C46] 的
@@ -531,7 +549,7 @@ export async function openStores(ctx) {
531
549
  return {
532
550
  backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore,
533
551
  taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState,
534
- usageWindowStore, memoryPosture,
552
+ usageWindowStore, memoryPosture, taskListLane,
535
553
  };
536
554
  }
537
555
  //# sourceMappingURL=stores.js.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 会话级任务清单店车道(#318;黑板 [4659] F2 症状 → [4692] 定谳)。
3
+ *
4
+ * core 的 task-list 家族(TaskCreate/TaskGet/TaskUpdate/TaskList)把持久化做成了注入缝:
5
+ * `assembleCodeTools({ taskList: true, taskListStore })` —— **不传** `taskListStore` 时 core 落
6
+ * `store ?? createMemoryTaskListStore()` 的私有臂。而本仓的场景工厂是**每请求**调用的
7
+ * (`boot/resolve-spec.ts` 的 `selectScenario(...)(body, …)`;`http/route-ctx.ts` 逐字「resume 会用
8
+ * 同一条 resolveSpec 重解析场景」),于是修前每个 turn 都铸一只新私店:同一会话的 TaskList 每
9
+ * turn 清空、TaskCreate 从 #1 重新铸号(已经发给用户的任务回执从此指向别的任务)。
10
+ *
11
+ * 本文件就是那条缝的属主 —— **一个 sessionId ↦ 一只店**的解析器,boot 期构造一次,注入
12
+ * `ScenarioDeps.taskListStoreFor`。
13
+ *
14
+ * ## 分区语义:listKey = 会话
15
+ * 任务清单是**会话级待办**:同 session 跨 turn / 跨 resume 连续,不同 session 互不可见。
16
+ * (design/147 §6 D2 的「团队共享清单」是同一张表的另一族分区键 —— 见 {@link taskListKeyFor}
17
+ * 的 `session:` 命名空间位。)
18
+ *
19
+ * ## 后端三态(与 roster / mailbox / background-agent 同姿势)
20
+ * - `pg` / `tidb`:对应的 SQL twin(`plugins/task-list-store-sql.ts`)。跨副本、跨重启都真续得上;
21
+ * 高水位(next_id)与插入序(next_sort)都由 meta 行的事务计数器持有,`mutate` 是真后端事务。
22
+ * - `memory`:无 SQL 后端(local/file 形、纯内存 dev)⇒ **进程级** per-session 内存店。跨 turn
23
+ * 存活(这就是 F2 的修点),但**跨进程重启丢失**,且 LRU 溢出(> `maxSessions`)时最老的会话
24
+ * 会被逐出=那条会话的待办清空。诚实登记在 USAGE(「local 形重启丢待办」),不伪造持久性:
25
+ * 这与 local 形其余会话态设施(perSessionCwd / TtlSessionStore)是同一姿势。
26
+ *
27
+ * ## 为什么 SQL 形也进同一只缓存
28
+ * SQL twin 是池上的无状态薄壳,每 turn 新铸一只在**行**的层面完全等价(真源在表里,`[4692]`
29
+ * 的 T2 三条就钉这个)。仍然缓存实例的理由是 core 的另一半契约:`createTaskListTools` 对
30
+ * **同一只 store 实例**维护一条 per-store promise 链来串行 read-modify-write。同副本内复用实例
31
+ * ⇒ 本进程内的串行化真生效,跨副本仍由 SQL twin 自己的分区锁兜底;缓存被逐出也只是回到「靠
32
+ * 数据库锁」这一层,没有正确性损失。
33
+ */
34
+ import type { Pool as MySqlPool } from "mysql2/promise";
35
+ import type { Pool as PgPool } from "pg";
36
+ import { type TaskListStore } from "@sema-agent/core";
37
+ import type { Logger } from "../observability/logger.js";
38
+ /** 同时活着的会话清单实例上限(LRU,逐最老)。与 perSessionCwd/perSessionShellEnv 同量级。 */
39
+ export declare const MAX_TASK_LIST_SESSIONS = 4096;
40
+ /** 车道实际落到哪个后端(启动日志 + 测试对表)。 */
41
+ export type TaskListLaneBackend = "tidb" | "pg" | "memory";
42
+ export interface TaskListLane {
43
+ /** `sessionId ↦ 该会话的任务清单店`。同 sessionId 恒解析到同一份清单(SQL 形 = 同一批行;
44
+ * memory 形 = 同一只实例)。 */
45
+ storeFor: (sessionId: string) => TaskListStore;
46
+ /**
47
+ * 会话删除级联(E21;codex R1-[high] 验真后加)。抹掉该会话的全部任务行**与高水位 meta 行**,
48
+ * 并逐出进程内缓存条目。
49
+ *
50
+ * 🔴 为什么必须有:会话 id 由调用方自选、会话删除后可被**别人** `register` 重新登记(security.ts
51
+ * 的 claim-create),而本车道的 listKey 是 `sessionId` 的确定性派生 ⇒ 不级联的话,新化身第一次
52
+ * 打开清单就读到上一位主人的任务标题/描述,`TaskCreate` 还会从上一位的高水位续号(「这里以前
53
+ * 建过 47 条」本身也是泄漏)。同族腿(anchors / approval-exemption / session-policy / attachment /
54
+ * snapshot / scratchpad)全部走同一条级联,本条与它们同 scoping 理由:路由已在 DELETE 门证过会话
55
+ * 所有权,故按 sessionId 单键删。
56
+ *
57
+ * 幂等(没写过任何任务的会话删 0 行);错误如实上抛 —— E21 的次序契约要求子腿失败就中止,让
58
+ * session_meta 留着、幂等重试收敛(session-faces.ts 顶注)。
59
+ *
60
+ * 📌 **残余,如实登记**(codex R2-[high] 判真、**射程外**):删除进行中若有一次并发 `storeFor`,
61
+ * 它会拿到一只新铸的可写店,写入可能落在 purge 事务提交**之后** ⇒ 行被重建、留给下一个化身。
62
+ * 这不是本腿独有的窗口,而是**整条 E21 级联共有的**残余(anchors / exemption / policy / attachment /
63
+ * snapshot / task_list 全是按 session_id 单键删、且各自开始时刻不同),`session-faces.ts` 的 purge
64
+ * 协调器里那段「残余,如实登记」逐字记着它与它的两条收口候选。真正消灭它要么让整条级联化身原子
65
+ * (单事务横跨十余张表 + 对象存储),要么引入**跨副本 durable 的会话化身号**并让每次写校验它 ——
66
+ * 两者都是设计级改动(属主裁决 + 三问),给 task-list 单独发明一套围栏只会在同一语义面上多一个
67
+ * 写者。⇒ 归入既有的那条后续件,不在本批自作主张。
68
+ */
69
+ deleteBySession: (sessionId: string) => Promise<void>;
70
+ backend: TaskListLaneBackend;
71
+ }
72
+ export { taskListKeyFor } from "../plugins/task-list-store-sql.js";
73
+ export interface TaskListLaneOpts {
74
+ /** MySQL 协议池(`StoreBackend.mysqlPool()`)。与 `pgPool` 互斥——两者都在时 PG 赢(与 roster/
75
+ * mailbox/background-agent 三处的既有分支序逐字一致,不新造第二种优先序)。 */
76
+ mysqlPool?: MySqlPool | undefined;
77
+ pgPool?: PgPool | undefined;
78
+ /** LRU 上限(默认 {@link MAX_TASK_LIST_SESSIONS});测试用小值钉逐出语义。 */
79
+ maxSessions?: number;
80
+ logger?: Logger | undefined;
81
+ }
82
+ /**
83
+ * 车道构造(boot 期一次)。
84
+ *
85
+ * ⚠️ **建表不在这里**:`ensure{TiDB,Pg}TaskListSchema` 由 `boot/stores.ts` 在构造之前 await
86
+ * (与 roster/mailbox/background-agent/usage-window 四族逐字同姿势)。理由不是风格——
87
+ * `test/schema-baseline-generated.test.ts` 判据 ③b 拿「`boot/stores.ts` 里真被 await 的 ensure 名」
88
+ * 与「生成器驱动的清单」做**集合相等**对账,那是消灭「两条方言同时漏一族表」这个对称盲区的唯一
89
+ * 不对称真源(`usage_window` 曾整族漏在基线外)。DDL 藏在本文件里 = 那道门看不见它。
90
+ */
91
+ export declare function createTaskListLane(opts?: TaskListLaneOpts): TaskListLane;
92
+ //# sourceMappingURL=task-list-lane.d.ts.map
@@ -0,0 +1,63 @@
1
+ import { createMemoryTaskListStore } from "@sema-agent/core";
2
+ import { BoundedSessionMap } from "../bounded-session-map.js";
3
+ import { createPgTaskListStore, createTiDBTaskListStore, deletePgTaskList, deleteTiDBTaskList, taskListKeyFor, } from "../plugins/task-list-store-sql.js";
4
+ /** 同时活着的会话清单实例上限(LRU,逐最老)。与 perSessionCwd/perSessionShellEnv 同量级。 */
5
+ export const MAX_TASK_LIST_SESSIONS = 4096;
6
+ // listKey 派生式的**属主是 store 文件**(`plugins/task-list-store-sql.ts` 的 {@link taskListKeyFor}
7
+ // ——字节约束在那边,留存腿也按同一只函数批量删行;派生有两份 = 在线删得掉、留存腿删不掉)。
8
+ // 这里只是把它一并导出,消费方(index.ts / 测试)不必知道它住在哪一层。
9
+ export { taskListKeyFor } from "../plugins/task-list-store-sql.js";
10
+ /**
11
+ * 车道构造(boot 期一次)。
12
+ *
13
+ * ⚠️ **建表不在这里**:`ensure{TiDB,Pg}TaskListSchema` 由 `boot/stores.ts` 在构造之前 await
14
+ * (与 roster/mailbox/background-agent/usage-window 四族逐字同姿势)。理由不是风格——
15
+ * `test/schema-baseline-generated.test.ts` 判据 ③b 拿「`boot/stores.ts` 里真被 await 的 ensure 名」
16
+ * 与「生成器驱动的清单」做**集合相等**对账,那是消灭「两条方言同时漏一族表」这个对称盲区的唯一
17
+ * 不对称真源(`usage_window` 曾整族漏在基线外)。DDL 藏在本文件里 = 那道门看不见它。
18
+ */
19
+ export function createTaskListLane(opts = {}) {
20
+ const { mysqlPool, pgPool, logger } = opts;
21
+ const maxSessions = opts.maxSessions ?? MAX_TASK_LIST_SESSIONS;
22
+ const cache = new BoundedSessionMap(maxSessions);
23
+ let backend;
24
+ let mint;
25
+ /** 后端侧的整份清单抹除(memory 形无后端 ⇒ 只靠下面的缓存逐出)。 */
26
+ let purge;
27
+ if (pgPool) {
28
+ backend = "pg";
29
+ mint = (sessionId) => createPgTaskListStore(pgPool, taskListKeyFor(sessionId));
30
+ purge = (sessionId) => deletePgTaskList(pgPool, taskListKeyFor(sessionId));
31
+ }
32
+ else if (mysqlPool) {
33
+ backend = "tidb";
34
+ mint = (sessionId) => createTiDBTaskListStore(mysqlPool, taskListKeyFor(sessionId));
35
+ purge = (sessionId) => deleteTiDBTaskList(mysqlPool, taskListKeyFor(sessionId));
36
+ }
37
+ else {
38
+ backend = "memory";
39
+ mint = () => createMemoryTaskListStore();
40
+ purge = async () => { };
41
+ }
42
+ const storeFor = (sessionId) => {
43
+ const hit = cache.get(sessionId);
44
+ // 命中即回写 = 刷新 MRU(BoundedSessionMap 的 `get` 刻意不动顺序,逐出闩只看 `set` 的插入序)。
45
+ if (hit !== undefined) {
46
+ cache.set(sessionId, hit);
47
+ return hit;
48
+ }
49
+ const fresh = mint(sessionId);
50
+ cache.set(sessionId, fresh);
51
+ return fresh;
52
+ };
53
+ const deleteBySession = async (sessionId) => {
54
+ // 先逐出缓存再删后端:反过来的话,两者之间的窗口里一次并发解析会拿到**旧实例**(memory 形
55
+ // 是旧数据本身;SQL 形是刚被删空的分区,新写会把行插回去)。缓存先走 = 那扇窗里的解析看到的
56
+ // 是一份新铸的空清单,与「会话已删」一致。
57
+ cache.delete(sessionId);
58
+ await purge(sessionId);
59
+ };
60
+ logger?.info("task_list_store_enabled", { backend, maxSessions });
61
+ return { storeFor, deleteBySession, backend };
62
+ }
63
+ //# sourceMappingURL=task-list-lane.js.map
@@ -21,5 +21,8 @@ export declare class BoundedSessionMap<V> {
21
21
  set(sessionId: string, value: V): void;
22
22
  /** `undefined` sessionId or an unregistered session both → `undefined` (non-removing read). */
23
23
  get(sessionId: string | undefined): V | undefined;
24
+ /** #318:显式逐出(会话删除级联要它 —— 会话没了,它的进程内缓存条目也必须跟着没)。
25
+ * 返回是否真删掉了一条(幂等:未登记的 session 返回 false)。 */
26
+ delete(sessionId: string): boolean;
24
27
  }
25
28
  //# sourceMappingURL=bounded-session-map.d.ts.map
@@ -33,5 +33,10 @@ export class BoundedSessionMap {
33
33
  get(sessionId) {
34
34
  return sessionId === undefined ? undefined : this.map.get(sessionId);
35
35
  }
36
+ /** #318:显式逐出(会话删除级联要它 —— 会话没了,它的进程内缓存条目也必须跟着没)。
37
+ * 返回是否真删掉了一条(幂等:未登记的 session 返回 false)。 */
38
+ delete(sessionId) {
39
+ return this.map.delete(sessionId);
40
+ }
36
41
  }
37
42
  //# sourceMappingURL=bounded-session-map.js.map
@@ -1,4 +1,4 @@
1
- import { type WebFetchConfig, type BackgroundAgentStore, type CheckpointStore, type PromptProvider, type Runner, type SkillSpec, type SubagentSpawnContext, type ToolSpec, type WebSearchConfig } from "@sema-agent/core";
1
+ import { type WebFetchConfig, type BackgroundAgentStore, type CheckpointStore, type PromptProvider, type Runner, type SkillSpec, type SubagentSpawnContext, type TaskListStore, type ToolSpec, type WebSearchConfig } from "@sema-agent/core";
2
2
  import type { Metrics } from "../observability/metrics.js";
3
3
  import type { Logger } from "../observability/logger.js";
4
4
  import { type RepoReadClient } from "./repo-tools.js";
@@ -33,7 +33,24 @@ export interface ScenarioBundle {
33
33
  * 多加一轮收官验证,绝不收窄 caller 意图),壳/caller 不感知。 */
34
34
  finalVerification?: true;
35
35
  }
36
- export type Scenario = (req: ScenarioRequest, principal?: string) => ScenarioBundle;
36
+ /**
37
+ * #318:场景工厂的**服务端事实**上下文 —— 与 `req`(= 调用方请求体,不可信)分开的第三个入参。
38
+ * 这里的每一位都由鉴权信道解析,调用方无法自报;新增位一律走本接口,绝不往 `req` 上加。
39
+ */
40
+ export interface ScenarioContext {
41
+ /**
42
+ * 本请求的会话 id。属主 = `boot/resolve-spec.ts` 的 `auth.sessionId`(authorizer 解析 + 归属校验
43
+ * 过的那一只),**绝不是 `body.sessionId`** —— 后者是调用方自报串,拿它当会话级设施的分区键
44
+ * 等于让一个租户读另一个租户的清单(同 spec 组装处那条「sessionId comes from `auth`, NEVER the
45
+ * body」的逐字纪律)。
46
+ *
47
+ * 缺席 = 这条调用形本来就没有会话(`main.ts` 的 parkedReviveTool 只取裸 Agent 工具、
48
+ * `builtinScenarioDetails` 的工具名探针)。此时会话级设施退化为 per-call 实例 —— 不为它发明
49
+ * 一个假 session。
50
+ */
51
+ sessionId?: string;
52
+ }
53
+ export type Scenario = (req: ScenarioRequest, principal?: string, ctx?: ScenarioContext) => ScenarioBundle;
37
54
  export interface ScenarioDeps {
38
55
  runner: Runner;
39
56
  /** In-memory runner for ephemeral sub-tasks (council lenses/arbiter) — keeps them out of TiDB. */
@@ -104,6 +121,17 @@ export interface ScenarioDeps {
104
121
  */
105
122
  checkpointStore?: CheckpointStore;
106
123
  ensureChildSessionDurable?: (sessionId: string) => Promise<void>;
124
+ /**
125
+ * #318([4659] F2 症状 → [4692] 定谳):**会话级**任务清单店解析器,属主 =
126
+ * `boot/task-list-lane.ts` 的 {@link import("../boot/task-list-lane.js").createTaskListLane}
127
+ * (boot 期构造一次,SQL 后端在场接对应 twin、否则进程级 per-session 内存店)。
128
+ *
129
+ * 🔴 这是 F2 的修点:core 的 `assembleCodeTools` 不收 `taskListStore` 时落私有 per-run 内存店,
130
+ * 而本仓的场景工厂是**每请求**调用的 ⇒ 同一会话每个 turn 一只新店(TaskList 中途清空、
131
+ * TaskCreate 从 #1 重铸号)。缺席(嵌入式装配自己造 deps、未接本车道)⇒ 逐字回到修前行为,
132
+ * additive 不回归。
133
+ */
134
+ taskListStoreFor?: (sessionId: string) => TaskListStore;
107
135
  }
108
136
  /**
109
137
  * Sema product identity, prepended to the core default base for the `default` scenario when
@@ -104,7 +104,7 @@ export function buildScenarios(deps) {
104
104
  // unknown scenario to "default" for tools/prompt/mcp too (council finding — else default-tagged drop).
105
105
  // 抽成具名工厂:`code` 直接复用它取 bundle(工具/技能面与 default 恒等由构造保证,不靠两处
106
106
  // 各写一份 assembleCodeTools 参数再祈祷不漂移)。
107
- const defaultScenario = (req) => {
107
+ const defaultScenario = (req, _principal, ctx) => {
108
108
  // gap-audit rank-1 BLOCKER: the default agent is the CC agent loop — it MUST have the
109
109
  // full-body roster, not just nowTool. core's `assembleCodeTools` (1.161) ships TodoWrite (planning) +
110
110
  // the subagent/Task delegation tool (runs on the sub-runner, child ⊆ parent — no escalation) + WebFetch.
@@ -135,6 +135,17 @@ export function buildScenarios(deps) {
135
135
  // stable ids, status transitions, dependencies); core defaults it OFF, the full-body deployment opts
136
136
  // in. Under CC 201 this family is the ONLY planning leg (see todoWrite above).
137
137
  taskList: true,
138
+ // #318([4659] F2 → [4692] 定谳):**会话级**清单店。缺这一键时 core 落
139
+ // `store ?? createMemoryTaskListStore()` 的私有 per-run 臂,而本工厂是**每请求**调用的
140
+ // (resolve-spec 的 selectScenario;resume 同链重解析)⇒ 同一会话每 turn 一只新店:
141
+ // TaskList 中途报空、TaskCreate 从 #1 重铸号,已发给用户的任务回执从此指向别的任务。
142
+ // 分区键 = 会话(同 session 跨 turn/跨 resume 连续,跨 session 互不可见);sessionId 只从
143
+ // 鉴权解析的 {@link ScenarioContext} 取,绝不从 req(= 调用方自报的 body)取。
144
+ // 无 sessionId 的调用形(parkedReviveTool 的裸 Agent 取用、能力详情探针)整键省略 ⇒
145
+ // per-call 私店 = 修前字节形(那两条腿都不消费 task-list 家族)。
146
+ ...(ctx?.sessionId !== undefined && deps.taskListStoreFor
147
+ ? { taskListStore: deps.taskListStoreFor(ctx.sessionId) }
148
+ : {}),
138
149
  // core 1.206 (design/115 P3): `background` exposes CC's `run_in_background` on the Agent tool — the
139
150
  // child runs async under an `a*` task id (TaskOutput/TaskStop; ONE "later" completion notification;
140
151
  // parent teardown aborts orphans). Same trust envelope as the sync path (child ⊆ parent roster, same
@@ -191,8 +202,10 @@ export function buildScenarios(deps) {
191
202
  // caller 显式声明,交互态不多烧终验轮)。这是 [891] 起的出厂缺省场景(config DEFAULT_SCENARIO
192
203
  // 缺省 "code")——编码产品出厂态对标 CC 出厂态;通用域中性 persona 的 default 保留为显式可选。
193
204
  // 刻意走内建、不走 center overlay:overlay 已无 prompt 通道([1053] ScenarioEntry.prompt 直删,场景级提示词=center prompts binding)。
194
- code: (req, principal) => ({
195
- ...defaultScenario(req, principal),
205
+ // #318:`ctx` 必须原样转给 default 工厂 —— 漏转 = 出厂缺省场景(DEFAULT_SCENARIO 缺省即 code)
206
+ // 拿不到会话级清单店,F2 在最常用的那条腿上原地复活([4659] 症状③ 钉的就是这一格)
207
+ code: (req, principal, ctx) => ({
208
+ ...defaultScenario(req, principal, ctx),
196
209
  promptProvider: codePromptProvider(deps.brandIdentity === true),
197
210
  }),
198
211
  // [891]→[2400] CAPS-OPS-11(clay 裁 2026-08-03,不留任何过渡):`autonomous` 过渡 alias 已退役。
@@ -429,6 +429,26 @@ export interface ServiceConfigFlat {
429
429
  * 异常收尾(crash/salvage)的前台子代同样不按静态面标记(链上仍记 `incomplete`)。已标记的会话永不
430
430
  * 回滚清洗,本键只管**新**标记。 */
431
431
  memoryDelegationEvidence?: "static-face" | "attested-only";
432
+ /** #307 件4(core 5.46.0 design/336 §13-3 的部署半场,[4652]①/[4677]②):记忆 **provenance 总开关** ——
433
+ * 一个会话被判为「已暴露」之后,它的记忆写要怎么处置。env `MEMORY_PROVENANCE`,两词闭集:
434
+ * · `carry` —— core 缺省行为逐字:**普通**记忆写照常提交,但带上引擎铸的 `origin` 标记(随条目走
435
+ * 后端/同步/导出包);指令形文件(feedback / pinned / triggers / applies-when)仍被扣下隔离;
436
+ * 派生索引的会话散文照回滚;内容扫描门原样跑(**标记不是豁免**);
437
+ * · `off` —— design/336 之前的行为:不铸 origin 标记,已暴露会话的 harvest **一条都不收**(整体
438
+ * 隔离候人审)。⚠️ 已提交的标记在编辑时照样带下去 —— `off` 停的是**铸**,从不抹掉已记的事实。
439
+ *
440
+ * 与 {@link memoryDelegationEvidence} **正交**(core 顶注明写,四种组合全合法):那一键决定
441
+ * **什么时候**把会话判为已暴露(证据标准),本键决定判为暴露之后**对写做什么**(带标收录 vs 整体隔离)。
442
+ *
443
+ * 🔴 **缺席 = 不铸键**(不复制上游缺省;`readFace` / `memoryDelegationEvidence` 同姿态)。
444
+ * 坏值 ⇒ 启动期响亮拒(#210 + 「部署级旋钮坏值禁静默回默认」);core 侧 `prepareConfigDoors` 另有
445
+ * 一道同判的门(`config.memory_provenance`,精确拼写、绝不真值判),两道门同向不冲突。
446
+ *
447
+ * ⚠️ **部署席 ONLY**(与 `memoryDelegationEvidence` 同判,core 侧的设计):TaskSpec 上没有同名键,
448
+ * 也不在受治 workflow 白名单里 —— 任务作者/受治脚本拿不到通道把 provenance 姿态改到部署之下。
449
+ * ⚠️ **不冻进 checkpoint**:resume 的那条腿跟**当前**部署配置走(core 成文)。
450
+ * 运维读面 = `GET /v1/diagnostics/wiring` 的 `memoryPosture.provenance`(`null`=本部署未设)。 */
451
+ memoryProvenance?: "off" | "carry";
432
452
  /** 件A 单机形目录:env `MEMORY_ORG_DIRECTORY_JSON` 原文(`Record<principal, Record<org:scope, {write?}>>`)。
433
453
  * config 解析期即整段校验(parseOrgDirectoryStatic,fail-loud);此处存**原文**(config=纯数据,
434
454
  * Map 在装配点重建)。与 config-center 目录腿互斥的裁决在装配点(两者都在=center 腿胜,env 表忽略
@@ -1363,7 +1383,7 @@ export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" |
1363
1383
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
1364
1384
  export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
1365
1385
  /** 组:memory(记忆面 + TOC 同步腿)。 */
1366
- export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryDelegationEvidence" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1386
+ export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1367
1387
  /** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析
1368
1388
  * 函数不产出它,但它与 `autonomy` 是同一根治理棒的两半,归本组。
1369
1389
  * design/170 的三件部署治理声明(`compliancePosture`/`lockedConfigKeys`/`retentionPolicy`)同归本组:
package/dist/config.js CHANGED
@@ -1632,6 +1632,13 @@ function parseMemoryDomain(ctx) {
1632
1632
  ...(process.env.MEMORY_DELEGATION_EVIDENCE !== undefined
1633
1633
  ? { memoryDelegationEvidence: enumEnv("MEMORY_DELEGATION_EVIDENCE", "static-face", ["static-face", "attested-only"]) }
1634
1634
  : {}),
1635
+ // #307 件4(core 5.46.0 design/336 §13-3):provenance 总开关。与上一条**逐字同姿态** ——
1636
+ // 缺席=整键缺席(不复制上游缺省),设了就必须是闭集两词之一。坏值在 `enumEnv` 里**启动期**响亮拒:
1637
+ // `false`/`none`/`disabled` 这类"看起来像关掉"的写法若被静默折成缺省,一台自认为「暴露会话零收录」
1638
+ // 的部署其实在照常收录 —— 部署级旋钮坏值禁静默回默认(#210 + operator-knob-must-be-unconditional)。
1639
+ ...(process.env.MEMORY_PROVENANCE !== undefined
1640
+ ? { memoryProvenance: enumEnv("MEMORY_PROVENANCE", "carry", ["off", "carry"]) }
1641
+ : {}),
1635
1642
  ...(memoryOrgDirectoryJson !== undefined ? { memoryOrgDirectoryJson } : {}),
1636
1643
  memoryOrgGrantTtlMs,
1637
1644
  memoryOrgUnavailableBackoffMs,
@@ -2353,7 +2360,7 @@ const APPROVAL_GROUP_KEYS = [
2353
2360
  ];
2354
2361
  const MEMORY_GROUP_KEYS = [
2355
2362
  "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
2356
- "memoryPersistenceCapable", "memorySync", "memoryEmbedder", "memoryOrgAdmissionMode", "memoryDelegationEvidence", "memoryOrgDirectoryJson", "memoryOrgGrantTtlMs", "memoryOrgUnavailableBackoffMs",
2363
+ "memoryPersistenceCapable", "memorySync", "memoryEmbedder", "memoryOrgAdmissionMode", "memoryDelegationEvidence", "memoryProvenance", "memoryOrgDirectoryJson", "memoryOrgGrantTtlMs", "memoryOrgUnavailableBackoffMs",
2357
2364
  "projectMemoryEnabled", "syncImportLeaseStaleSec",
2358
2365
  ];
2359
2366
  const AUTH_GROUP_KEYS = [