@sema-agent/server 7.36.0-rc.1 → 7.37.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.
Files changed (55) hide show
  1. package/USAGE.md +14 -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/admin-drain.d.ts +4 -2
  25. package/dist/http/routes/approvals-assistant.js +13 -1
  26. package/dist/http/routes/notify-wake.js +6 -0
  27. package/dist/http/routes/runs.js +1 -1
  28. package/dist/http/routes/tasks.js +30 -2
  29. package/dist/http/server.d.ts +3 -1
  30. package/dist/http/server.js +108 -8
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.js +5 -0
  33. package/dist/main.js +11 -3
  34. package/dist/memory-posture.d.ts +12 -1
  35. package/dist/memory-posture.js +2 -0
  36. package/dist/observability/fail-open.d.ts +9 -1
  37. package/dist/observability/fail-open.js +9 -1
  38. package/dist/plugins/memory-engine-pg.js +42 -4
  39. package/dist/plugins/memory-engine-tidb.js +38 -4
  40. package/dist/plugins/memory-origin-law.d.ts +69 -0
  41. package/dist/plugins/memory-origin-law.js +98 -0
  42. package/dist/plugins/retention-store-sql.d.ts +7 -0
  43. package/dist/plugins/retention-store-sql.js +24 -0
  44. package/dist/plugins/task-list-store-sql.d.ts +36 -25
  45. package/dist/plugins/task-list-store-sql.js +102 -0
  46. package/dist/run-local.js +14 -5
  47. package/dist/runs.js +17 -0
  48. package/dist/store-live-probe.d.ts +26 -0
  49. package/dist/store-live-probe.js +60 -0
  50. package/dist/trace/engine-notice-wire.d.ts +128 -0
  51. package/dist/trace/engine-notice-wire.js +256 -0
  52. package/dist/trace/ledger-events.d.ts +11 -1
  53. package/dist/trace/ledger-sink.d.ts +17 -0
  54. package/dist/trace/ledger-sink.js +25 -0
  55. package/package.json +2 -2
@@ -1,35 +1,46 @@
1
- /**
2
- * TaskListStore SQL twins(design/151 S3c server pg 半场,[1530] 提货单②)——core 团队共享任务
3
- * 清单(TaskCreate/TaskUpdate/TaskList 工具族)的 TiDB/PG 双方言实现。挂载缝 = S3c 行为车定
4
- * (createTaskListTools(store) 已收 store 参数);本文件先就绪 store 半场。
5
- *
6
- * 语义真源 = core file 实现(dist/stores/file/task-list-store.js,durable 对 durable)+ [1530] 定谳:
7
- * 1. **`mutate` 必须后端事务原子**(接口 JSDoc codex S4 F2 句):read-modify-write 全序列进一个
8
- * 事务;分区锁 = meta 行 `SELECT … FOR UPDATE`(天然 advisory lock,同清单 mutate 串行)。
9
- * 2. **nextId 只增不回退、与分配同事务**(删最高 id 不复用):`task_list_meta` 单行携 `next_id`,
10
- * **永不从存量 MAX 派生**。
11
- * 3. metadata = JSON 列(`assertJsonMetadata` 在 set 面拦,file/memory 同形);id 任意 string
12
- * (工具层走 allocateId 产数字串,接口不排除外部 id)。
13
- *
14
- * `list` 序:两个 core 参照本就不同(memory=Map 纯插入序;file=JS 对象序 = integer-like 键数字
15
- * 升序、其余按插入序殿后)——SQL twin 对齐 **file 形**(durable 参照):`id_num` 物化列(canonical
16
- * array-index 形才非 NULL)数字升序,非数字 id 按 `sort_seq`(插入序)殿后。set 覆盖已有 id 保位
17
- * (sort_seq 不变)= 两参照一致的保位语义。`sort_seq` 从 **meta 行计数器同事务分配**(复审 F6:
18
- * TiDB AUTO_INCREMENT 按 tidb-server 分段缓存,跨节点不单调——「插入序」在多网关部署下会倒序;
19
- * meta 计数器 = allocateId 同姿势,真单调;覆盖写浪费一个号,单调性无损)。
20
- *
21
- * 分区键 `listKey` = 构造参数(file 形的 root 目录对位):谁的清单由挂载点决定(per-team 组装,
22
- * S3c 行为车),store 层只管字节隔离(VARBINARY / COLLATE "C",roster F2 案)。PG unstorable
23
- * bytes([1439]):item 正文(subject/description/metadata)= 内容面 lossy(pgSafeJsonStringify,
24
- * U+FFFD);**身份键 listKey/id 拒绝式**(清洗形变 = 行与查询键错位)。TiDB verbatim。
25
- */
26
1
  import type { Pool as MySqlPool } from "mysql2/promise";
27
2
  import type { Pool as PgPool } from "pg";
28
3
  import { type TaskListStore } from "@sema-agent/core";
29
4
  export declare const TASK_LIST_META_TABLE = "task_list_meta";
30
5
  export declare const TASK_LIST_ITEM_TABLE = "task_list_item";
6
+ /**
7
+ * #318 `sessionId ↦ 本表的身份键`:`session:<可读前缀>-<sha256(sessionId) 前 16 位>`。
8
+ *
9
+ * 🔴 为什么不直接把 sessionId 当 listKey:sessionId 是**调用方可自报**的不透明串(提交门
10
+ * `http/server.ts` 只卡 ≤64 **字符** —— [#15] 刻意的宽松提交契约,不卡字节数也不卡字符集)。
11
+ * 而本文件的身份键是字节面严格的:{@link assertListKeyBytes} 对 >190 字节当场拒(TiDB
12
+ * `INSERT IGNORE` 静默截断案),PG twin 对孤代理/NUL 走 {@link PgUnstorableError} 拒。直接透传
13
+ * 的话,一个 64 字符全四字节 emoji 的会话(256 字节)会让**整条请求**在场景装配处 500 —— 而那条腿
14
+ * 只是待办清单。派生一个恒可存的键把这一整类失败模式消掉(既不是 fail-open 也不是 fail-loud,是让
15
+ * 那条臂不存在)。
16
+ *
17
+ * 判据:确定性(同 sessionId 跨副本/跨重启恒同键)、字节安全(纯 ASCII,≤ 8+24+1+16 = 49 字节)、
18
+ * 抗碰撞(摘要取**全串**,可读前缀只为运维肉眼对账)。
19
+ *
20
+ * `session:` 是**命名空间**位:design/147 §6 D2 的团队共享清单将来若按 team 分区,两族键同表不撞。
21
+ * 同族先例 = `env-facts.ts` 的 `scratchpadSessionSegment`(文件名面)——刻意**不**复用彼此的实现:
22
+ * 一个键空间改了算法不该把另一个键空间的存量行全孤儿化。
23
+ *
24
+ * 🔴 属主在**本文件**(而不是消费它的 boot 车道):留存腿(`retention-store-sql.ts`)按同一派生式
25
+ * 批量删本表的行,两个消费点必须读同一个函数 —— 键派生有两份就是「在线删除删得掉、留存腿删不掉」。
26
+ */
27
+ export declare function taskListKeyFor(sessionId: string): string;
31
28
  export declare function ensureTiDBTaskListSchema(pool: MySqlPool): Promise<void>;
32
29
  export declare function ensurePgTaskListSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
30
+ /**
31
+ * 整份清单删除(#318 / E21 会话删除级联;codex R1-[high] 验真后加)。
32
+ *
33
+ * 语义 = 「这个 listKey 的清单**不存在过**」:item 行 + meta 行(高水位 `next_id` / 插入序
34
+ * `next_sort`)一并抹掉。⚠️ **meta 行必须一起删**:留着它,同一个 listKey 被重新登记后
35
+ * (会话 id 由调用方自选、删除后可被别人 `register`)新主的第一条任务会从旧主的高水位续号 ——
36
+ * 那是一条跨化身的信息泄漏(「上一位在这里建过 47 条」)。
37
+ *
38
+ * 事务内先对 meta 行 `FOR UPDATE`(与 `mutate` 同一把分区锁)⇒ 与并发的清单写串行,不会删到
39
+ * 一半被插回新行。meta 行不在(从未写过任何任务)= 无锁可拿,DELETE 命中 0 行,幂等。
40
+ */
41
+ export declare function deleteTiDBTaskList(pool: MySqlPool, listKey: string): Promise<void>;
42
+ /** {@link deleteTiDBTaskList} 的 PG 孪生(同语义、同事务姿势)。 */
43
+ export declare function deletePgTaskList(pool: PgPool, listKey: string): Promise<void>;
33
44
  export declare function createTiDBTaskListStore(pool: MySqlPool, listKey: string): TaskListStore;
34
45
  export declare function createPgTaskListStore(pool: PgPool, listKey: string): TaskListStore;
35
46
  //# sourceMappingURL=task-list-store-sql.d.ts.map
@@ -1,8 +1,60 @@
1
+ /**
2
+ * TaskListStore SQL twins(design/151 S3c server pg 半场,[1530] 提货单②)——core 团队共享任务
3
+ * 清单(TaskCreate/TaskUpdate/TaskList 工具族)的 TiDB/PG 双方言实现。挂载缝 = S3c 行为车定
4
+ * (createTaskListTools(store) 已收 store 参数);本文件先就绪 store 半场。
5
+ *
6
+ * 语义真源 = core file 实现(dist/stores/file/task-list-store.js,durable 对 durable)+ [1530] 定谳:
7
+ * 1. **`mutate` 必须后端事务原子**(接口 JSDoc codex S4 F2 句):read-modify-write 全序列进一个
8
+ * 事务;分区锁 = meta 行 `SELECT … FOR UPDATE`(天然 advisory lock,同清单 mutate 串行)。
9
+ * 2. **nextId 只增不回退、与分配同事务**(删最高 id 不复用):`task_list_meta` 单行携 `next_id`,
10
+ * **永不从存量 MAX 派生**。
11
+ * 3. metadata = JSON 列(`assertJsonMetadata` 在 set 面拦,file/memory 同形);id 任意 string
12
+ * (工具层走 allocateId 产数字串,接口不排除外部 id)。
13
+ *
14
+ * `list` 序:两个 core 参照本就不同(memory=Map 纯插入序;file=JS 对象序 = integer-like 键数字
15
+ * 升序、其余按插入序殿后)——SQL twin 对齐 **file 形**(durable 参照):`id_num` 物化列(canonical
16
+ * array-index 形才非 NULL)数字升序,非数字 id 按 `sort_seq`(插入序)殿后。set 覆盖已有 id 保位
17
+ * (sort_seq 不变)= 两参照一致的保位语义。`sort_seq` 从 **meta 行计数器同事务分配**(复审 F6:
18
+ * TiDB AUTO_INCREMENT 按 tidb-server 分段缓存,跨节点不单调——「插入序」在多网关部署下会倒序;
19
+ * meta 计数器 = allocateId 同姿势,真单调;覆盖写浪费一个号,单调性无损)。
20
+ *
21
+ * 分区键 `listKey` = 构造参数(file 形的 root 目录对位):谁的清单由挂载点决定(per-team 组装,
22
+ * S3c 行为车),store 层只管字节隔离(VARBINARY / COLLATE "C",roster F2 案)。PG unstorable
23
+ * bytes([1439]):item 正文(subject/description/metadata)= 内容面 lossy(pgSafeJsonStringify,
24
+ * U+FFFD);**身份键 listKey/id 拒绝式**(清洗形变 = 行与查询键错位)。TiDB verbatim。
25
+ */
26
+ import { createHash } from "node:crypto";
1
27
  // assertJsonMetadata:core 1.374 起包根导出([1531]§一3——私拷镜像即换,DURABLE_AGENT_HANDLE_RE 同姿势)。
2
28
  import { assertJsonMetadata } from "@sema-agent/core";
3
29
  import { pgHasUnstorable, pgSafeJsonStringify, PgUnstorableError } from "./pg-safe-json.js";
4
30
  export const TASK_LIST_META_TABLE = "task_list_meta";
5
31
  export const TASK_LIST_ITEM_TABLE = "task_list_item";
32
+ /**
33
+ * #318 `sessionId ↦ 本表的身份键`:`session:<可读前缀>-<sha256(sessionId) 前 16 位>`。
34
+ *
35
+ * 🔴 为什么不直接把 sessionId 当 listKey:sessionId 是**调用方可自报**的不透明串(提交门
36
+ * `http/server.ts` 只卡 ≤64 **字符** —— [#15] 刻意的宽松提交契约,不卡字节数也不卡字符集)。
37
+ * 而本文件的身份键是字节面严格的:{@link assertListKeyBytes} 对 >190 字节当场拒(TiDB
38
+ * `INSERT IGNORE` 静默截断案),PG twin 对孤代理/NUL 走 {@link PgUnstorableError} 拒。直接透传
39
+ * 的话,一个 64 字符全四字节 emoji 的会话(256 字节)会让**整条请求**在场景装配处 500 —— 而那条腿
40
+ * 只是待办清单。派生一个恒可存的键把这一整类失败模式消掉(既不是 fail-open 也不是 fail-loud,是让
41
+ * 那条臂不存在)。
42
+ *
43
+ * 判据:确定性(同 sessionId 跨副本/跨重启恒同键)、字节安全(纯 ASCII,≤ 8+24+1+16 = 49 字节)、
44
+ * 抗碰撞(摘要取**全串**,可读前缀只为运维肉眼对账)。
45
+ *
46
+ * `session:` 是**命名空间**位:design/147 §6 D2 的团队共享清单将来若按 team 分区,两族键同表不撞。
47
+ * 同族先例 = `env-facts.ts` 的 `scratchpadSessionSegment`(文件名面)——刻意**不**复用彼此的实现:
48
+ * 一个键空间改了算法不该把另一个键空间的存量行全孤儿化。
49
+ *
50
+ * 🔴 属主在**本文件**(而不是消费它的 boot 车道):留存腿(`retention-store-sql.ts`)按同一派生式
51
+ * 批量删本表的行,两个消费点必须读同一个函数 —— 键派生有两份就是「在线删除删得掉、留存腿删不掉」。
52
+ */
53
+ export function taskListKeyFor(sessionId) {
54
+ const readable = sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 24);
55
+ const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
56
+ return `session:${readable.length > 0 ? readable : "_"}-${digest}`;
57
+ }
6
58
  /** JS 对象 integer-like(array-index)键判別——file 参照的遍历序里数字升序的那一族:canonical
7
59
  * 十进制、0 ≤ n < 2^32-1。"01"/"1e3"/负数/超界 = 普通串键(插入序殿后)。 */
8
60
  function arrayIndexOf(id) {
@@ -91,6 +143,56 @@ async function setOn(exec, dialect, listKey, id, itemJson) {
91
143
  ? `UPDATE ${TASK_LIST_META_TABLE} SET next_sort = ? WHERE list_key = ?`
92
144
  : `UPDATE ${TASK_LIST_META_TABLE} SET next_sort = $1 WHERE list_key = $2`, [sort + 1, listKey]);
93
145
  }
146
+ /**
147
+ * 整份清单删除(#318 / E21 会话删除级联;codex R1-[high] 验真后加)。
148
+ *
149
+ * 语义 = 「这个 listKey 的清单**不存在过**」:item 行 + meta 行(高水位 `next_id` / 插入序
150
+ * `next_sort`)一并抹掉。⚠️ **meta 行必须一起删**:留着它,同一个 listKey 被重新登记后
151
+ * (会话 id 由调用方自选、删除后可被别人 `register`)新主的第一条任务会从旧主的高水位续号 ——
152
+ * 那是一条跨化身的信息泄漏(「上一位在这里建过 47 条」)。
153
+ *
154
+ * 事务内先对 meta 行 `FOR UPDATE`(与 `mutate` 同一把分区锁)⇒ 与并发的清单写串行,不会删到
155
+ * 一半被插回新行。meta 行不在(从未写过任何任务)= 无锁可拿,DELETE 命中 0 行,幂等。
156
+ */
157
+ export async function deleteTiDBTaskList(pool, listKey) {
158
+ assertListKeyBytes(listKey);
159
+ const c = await pool.getConnection();
160
+ try {
161
+ await c.beginTransaction();
162
+ await c.query(`SELECT next_id FROM ${TASK_LIST_META_TABLE} WHERE list_key = ? FOR UPDATE`, [listKey]);
163
+ await c.query(`DELETE FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = ?`, [listKey]);
164
+ await c.query(`DELETE FROM ${TASK_LIST_META_TABLE} WHERE list_key = ?`, [listKey]);
165
+ await c.commit();
166
+ }
167
+ catch (e) {
168
+ await c.rollback().catch(() => { });
169
+ throw e;
170
+ }
171
+ finally {
172
+ c.release();
173
+ }
174
+ }
175
+ /** {@link deleteTiDBTaskList} 的 PG 孪生(同语义、同事务姿势)。 */
176
+ export async function deletePgTaskList(pool, listKey) {
177
+ if (pgHasUnstorable(listKey))
178
+ throw new PgUnstorableError("task-list listKey");
179
+ assertListKeyBytes(listKey);
180
+ const c = await pool.connect();
181
+ try {
182
+ await c.query("BEGIN");
183
+ await c.query(`SELECT next_id FROM ${TASK_LIST_META_TABLE} WHERE list_key = $1 FOR UPDATE`, [listKey]);
184
+ await c.query(`DELETE FROM ${TASK_LIST_ITEM_TABLE} WHERE list_key = $1`, [listKey]);
185
+ await c.query(`DELETE FROM ${TASK_LIST_META_TABLE} WHERE list_key = $1`, [listKey]);
186
+ await c.query("COMMIT");
187
+ }
188
+ catch (e) {
189
+ await c.query("ROLLBACK").catch(() => { });
190
+ throw e;
191
+ }
192
+ finally {
193
+ c.release();
194
+ }
195
+ }
94
196
  export function createTiDBTaskListStore(pool, listKey) {
95
197
  assertListKeyBytes(listKey);
96
198
  const tx = async (fn) => {
package/dist/run-local.js CHANGED
@@ -66,6 +66,7 @@ import { loadSkills } from "./capabilities/skills.js";
66
66
  import { createRepoClient } from "./capabilities/repo-tools.js";
67
67
  import { webSearchConfigFromEnv, createWebSearchBackend } from "./plugins/web-search.js";
68
68
  import { buildScenarios, selectScenario, centerScenarios, canonicalScenarioName } from "./capabilities/scenarios.js";
69
+ import { createTaskListLane } from "./boot/task-list-lane.js";
69
70
  import { pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
70
71
  import { HttpError } from "./security.js";
71
72
  import { memoryEngineBackendFor, memorySpecForRequest } from "./memory-scope.js";
@@ -701,9 +702,15 @@ export async function runLocal(argv, deps = {}) {
701
702
  // default scenario's WebSearch tool is assembled on the CLI path too (it was silently never built before).
702
703
  const webSearchCfg = webSearchConfigFromEnv();
703
704
  const webSearch = webSearchCfg ? createWebSearchBackend(webSearchCfg) : undefined;
705
+ // #318:会话级任务清单车道。run-local 无 SQL 后端 ⇒ 内存臂(进程内 per-session)。一次性 CLI 的
706
+ // 单进程内本来就只解析一次场景,接它的意义在于**装配面同源**:server 与 run-local 走同一条
707
+ // `ScenarioDeps.taskListStoreFor` 契约,不让 CLI 腿静默留在「per-call 私店」的修前形上。
708
+ // `--session` 跨调用续聊的待办清单不跨进程存活(local 形的诚实边界,USAGE 已登记)。
709
+ const taskListLane = createTaskListLane({ logger });
704
710
  const scenarioDeps = {
705
711
  runner, subRunner, handslessSubRunner, model: "default", skills, repoClient, metrics, logger,
706
712
  ...(webSearch ? { webSearch } : {}),
713
+ taskListStoreFor: taskListLane.storeFor,
707
714
  brandIdentity: true, // run-local is always a local/TOC deployment → brand the default scenario as Sema.
708
715
  };
709
716
  const scenarios = buildScenarios(scenarioDeps);
@@ -724,9 +731,15 @@ export async function runLocal(argv, deps = {}) {
724
731
  // GIT_API_BASEURL + a repo — which a one-shot CLI may not
725
732
  // supplies). The server catches this in resolveSpec/prepareSpec and returns a clean 4xx; here we mirror that
726
733
  // with the doctor-style UX (message to stderr + exit 2) instead of letting it bubble to the fatal exit-1 path.
734
+ // --session REUSES a session (durable multi-turn across invocations, via the file session store); absent →
735
+ // a fresh uuidv7 (still persisted). --user is the stable memory/identity scope so long-term memory
736
+ // accumulates per user (default "local" = the single TOC user). The file stores acquire/append lazily.
737
+ // #318:解析点上提到场景装配**之前** —— 场景工厂现在要 `ScenarioContext.sessionId`(会话级任务
738
+ // 清单的分区键)。纯移动,取值逐字不变(uuidv7() 无前置依赖)。
739
+ const sessionId = args.session ?? uuidv7();
727
740
  let cap;
728
741
  try {
729
- cap = selectScenario(scenarios, scenarioName)({ scenario: scenarioName, objective: args.objective });
742
+ cap = selectScenario(scenarios, scenarioName)({ scenario: scenarioName, objective: args.objective }, undefined, { sessionId });
730
743
  }
731
744
  catch (err) {
732
745
  if (err instanceof HttpError) {
@@ -740,10 +753,6 @@ export async function runLocal(argv, deps = {}) {
740
753
  throw err;
741
754
  }
742
755
  const mention = parseModelMention(args.objective, Object.keys(config.models));
743
- // --session REUSES a session (durable multi-turn across invocations, via the file session store); absent →
744
- // a fresh uuidv7 (still persisted). --user is the stable memory/identity scope so long-term memory
745
- // accumulates per user (default "local" = the single TOC user). The file stores acquire/append lazily.
746
- const sessionId = args.session ?? uuidv7();
747
756
  const scope = args.user ?? "local";
748
757
  // Timeout policy mirrors the server (taskWallClockSec): run-local IS the pure single-user
749
758
  // TOC lane → NO wall clock by default (core's wedge safety-net still applies); explicit TASK_TIMEOUT_SEC
package/dist/runs.js CHANGED
@@ -3,11 +3,13 @@ import { withPrincipal } from "./observability/principal-context.js";
3
3
  import { redactSecrets } from "./trace/redact.js";
4
4
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
5
5
  import { createLedgerSink } from "./trace/ledger-sink.js";
6
+ import { registerEngineNoticeLeg } from "./trace/engine-notice-wire.js"; // #310:通告 wire 腿(bg 半场)
6
7
  import { recordTurnActivity } from "./turn-activity.js";
7
8
  import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
8
9
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
9
10
  import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
10
11
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
12
+ import { recordFailOpen } from "./observability/fail-open.js"; // #310:通告 durable 写失败的留痕口
11
13
  /**
12
14
  * stoppedBy (core 1.252): a service cancel is a USER stop — mark the run's still-running background
13
15
  * children BEFORE core's teardown reaps them (bare abort = attribution falls back to "system"; core's own
@@ -526,6 +528,20 @@ approval) {
526
528
  const { type, ...rest } = frame;
527
529
  return append(type, rest);
528
530
  };
531
+ // #310 `engine_notice` wire 腿(设计稿 = 黑板 [4630],三腿之一:**bg**)。本腿**没有 live SSE**
532
+ // (消费方走 `GET /v1/runs/:id/events` 的 durable tail),所以只挂 durable 口 —— 这与本腿的 HITL 帧
533
+ // (elicit/question/approval 三族)姿态逐字相同:唯一投递面就是账本,tail 负责送到人眼前。
534
+ // fire-and-forget:`onNotice` 是 core 的同步回调,不能拿一次账本写去阻塞引擎;seq 在 `append` 的链步内
535
+ // 同步分配 ⇒ 与主循环的 await 写不会乱序(`onForwardEvent` 同款契约)。
536
+ // 🔴 写失败**留痕不裸吞**(codex 对抗复审 R1-[medium],验真后修):本腿账本是通告的**唯一**用户可见
537
+ // 终点,一次 store 抖动就让它对用户永久消失;`route` 的同步 try 观察不到异步 reject。同文件 resume 腿
538
+ // 的 `warnAppend` 是同一条纪律的先例(C2/C5 批2)。
539
+ const unregisterEngineNotice = registerEngineNoticeLeg({
540
+ sessionId: spec.sessionId,
541
+ // (本函数没有 logger 席 —— 留痕走 `recordFailOpen` 的三件套:逐次计数 + 逐次探针行[带 detail] +
542
+ // 一次性 warn。这正是 #157 为「结构上拿不到 logger 的兜底臂」准备的口,不为它加一个参数。)
543
+ durable: (row) => void append("engine_notice", row).catch(() => recordFailOpen("server.engine-notice.durable-append-failed", `leg=bg code=${row.code} task=${taskId}`)),
544
+ });
529
545
  try {
530
546
  // P1 ①② follow-on (core): drain this session's async-workflow completion inbox at the START of the
531
547
  // background leg (before ANY branch — verify/cascade/plain-stream all get it; parity with the sync leg's
@@ -953,6 +969,7 @@ approval) {
953
969
  }
954
970
  finally {
955
971
  legLive = false; // notifications from here on take the durable-inbox path
972
+ unregisterEngineNotice(); // #310:腿结束即注销 wire 口(下一条同会话腿自带自己的口)
956
973
  // MF-Fleet: flip/remove the fleet row at the SINGLE finalization point — runs once for EVERY exit path
957
974
  // (success / cancel / error / suspend / needs_review). A truly-terminal status leaves the fleet; a PARKED
958
975
  // status (suspended→waiting / needs_review→awaiting approval) STAYS (the run is still active). A cancelled
@@ -11,6 +11,8 @@
11
11
  * - 翻转披露(§M):live→dead warn `store_probe_dead`、dead→live info `store_probe_recovered`,
12
12
  * 只在翻转拍记(连续死不逐拍刷日志——down 的 DB 不该制造日志风暴);
13
13
  * - 诚实缺席:首拍落地前 state() = undefined(/health 键缺席=「还没探过」,不冒充「活」)。
14
+ * - 面分级(#305):`state().error` 是**原始** message —— 它只喂给凭证后/本机的消费面;免鉴权的
15
+ * `/health` 一格由 wire 装配点过 {@link publicStoreProbeError} 换成闭集词(理由见该常量头注)。
14
16
  *
15
17
  * 探针原语:调用方给 `probe`(main.ts 传 `() => backend.dbNowMs!()`——既有 S10 时钟探针的真 DB
16
18
  * 往返,零新 SQL 面)。timer 全部 unref(环不得阻止进程退出)。
@@ -28,6 +30,30 @@ export interface StoreLiveProbe {
28
30
  state: () => StoreLiveState | undefined;
29
31
  stop: () => void;
30
32
  }
33
+ /**
34
+ * #305(安全轴):`/health` 免鉴权面上 `storeProbe.error` 可披露的**闭集词表**。
35
+ *
36
+ * 为什么必须是闭集而不是「脱敏后的原文」:`/health` 排在 `http/server.ts` 的鉴权门**之前**、缺省全网卡
37
+ * 监听 ⇒ 任何能连到这个端口的人都读得到这一格;而它此前装的是**驱动原始异常 message 逐字**,DB 驱动
38
+ * 的连接类异常惯于把整条 DSN 回声进去(`mysql://user:pass@host:3306/db`)或带上内网主机名/账号名。
39
+ * `trace/redact.ts` 的 `redactSecrets` **挡不住这条**:它的 URL-userinfo 规则只把口令换成 `«redacted»`,
40
+ * **用户名与 `@` 之后的主机原样留下** —— 而本条要防的正是那两个。闭集词是严格更强的形(值恒为下列
41
+ * 字面量之一,构造上不可能携带调用方文本),因此不再叠加脱敏/长度帽:对一个恒为字面量的返回值,那两道
42
+ * 都只是装饰。
43
+ *
44
+ * 信息不丢:**完整原文走日志轴** —— 环在 live→dead 翻转拍打的 `store_probe_dead` warn 带 `{ error }`
45
+ * 原文,运维在自己的日志/采集里读得到全文,那一面本来就是凭证后的。
46
+ */
47
+ export declare const STORE_PROBE_PUBLIC_PHRASES: readonly ["probe_timeout", "connection_refused", "connection_reset", "host_unreachable", "dns_failure", "network_timeout", "auth_failed", "connection_limit", "probe_failed"];
48
+ export type StoreProbePublicPhrase = (typeof STORE_PROBE_PUBLIC_PHRASES)[number];
49
+ /**
50
+ * 原始探针异常 message → 免鉴权面可披露的闭集词。**返回值恒是 {@link STORE_PROBE_PUBLIC_PHRASES} 的
51
+ * 一个字面量**(返回类型即闭集,写错一个词是编译错误),永不回声入参。
52
+ *
53
+ * 误分类的代价是「运维读到的分类不精确」,**不是泄露** —— 所以这里的判别用宽松的子串包含即可,不必为
54
+ * 精确性把原文碎片带出来。臂序有意义:自家的 race 超时先于泛化的 timeout 臂。
55
+ */
56
+ export declare function publicStoreProbeError(raw: string): StoreProbePublicPhrase;
31
57
  export declare function createStoreLiveProbe(opts: {
32
58
  probe: () => Promise<unknown>;
33
59
  intervalMs: number;
@@ -11,12 +11,72 @@
11
11
  * - 翻转披露(§M):live→dead warn `store_probe_dead`、dead→live info `store_probe_recovered`,
12
12
  * 只在翻转拍记(连续死不逐拍刷日志——down 的 DB 不该制造日志风暴);
13
13
  * - 诚实缺席:首拍落地前 state() = undefined(/health 键缺席=「还没探过」,不冒充「活」)。
14
+ * - 面分级(#305):`state().error` 是**原始** message —— 它只喂给凭证后/本机的消费面;免鉴权的
15
+ * `/health` 一格由 wire 装配点过 {@link publicStoreProbeError} 换成闭集词(理由见该常量头注)。
14
16
  *
15
17
  * 探针原语:调用方给 `probe`(main.ts 传 `() => backend.dbNowMs!()`——既有 S10 时钟探针的真 DB
16
18
  * 往返,零新 SQL 面)。timer 全部 unref(环不得阻止进程退出)。
17
19
  */
18
20
  /** 单拍探针上界——挂死连接的判死时间;3s 对 15s 默认间隔留足余量(环内串行,无叠拍)。 */
19
21
  export const STORE_PROBE_TIMEOUT_MS = 3000;
22
+ /**
23
+ * #305(安全轴):`/health` 免鉴权面上 `storeProbe.error` 可披露的**闭集词表**。
24
+ *
25
+ * 为什么必须是闭集而不是「脱敏后的原文」:`/health` 排在 `http/server.ts` 的鉴权门**之前**、缺省全网卡
26
+ * 监听 ⇒ 任何能连到这个端口的人都读得到这一格;而它此前装的是**驱动原始异常 message 逐字**,DB 驱动
27
+ * 的连接类异常惯于把整条 DSN 回声进去(`mysql://user:pass@host:3306/db`)或带上内网主机名/账号名。
28
+ * `trace/redact.ts` 的 `redactSecrets` **挡不住这条**:它的 URL-userinfo 规则只把口令换成 `«redacted»`,
29
+ * **用户名与 `@` 之后的主机原样留下** —— 而本条要防的正是那两个。闭集词是严格更强的形(值恒为下列
30
+ * 字面量之一,构造上不可能携带调用方文本),因此不再叠加脱敏/长度帽:对一个恒为字面量的返回值,那两道
31
+ * 都只是装饰。
32
+ *
33
+ * 信息不丢:**完整原文走日志轴** —— 环在 live→dead 翻转拍打的 `store_probe_dead` warn 带 `{ error }`
34
+ * 原文,运维在自己的日志/采集里读得到全文,那一面本来就是凭证后的。
35
+ */
36
+ export const STORE_PROBE_PUBLIC_PHRASES = [
37
+ "probe_timeout",
38
+ "connection_refused",
39
+ "connection_reset",
40
+ "host_unreachable",
41
+ "dns_failure",
42
+ "network_timeout",
43
+ "auth_failed",
44
+ "connection_limit",
45
+ /** 未识别臂 —— **刻意的**:分类不认得的异常一律丢原文取本词(fail-closed 的默认方向是「少说」,
46
+ * 不是「截断了再说」;截断的前 160 字恰恰是驱动最爱放 DSN 的位置)。 */
47
+ "probe_failed",
48
+ ];
49
+ /**
50
+ * 原始探针异常 message → 免鉴权面可披露的闭集词。**返回值恒是 {@link STORE_PROBE_PUBLIC_PHRASES} 的
51
+ * 一个字面量**(返回类型即闭集,写错一个词是编译错误),永不回声入参。
52
+ *
53
+ * 误分类的代价是「运维读到的分类不精确」,**不是泄露** —— 所以这里的判别用宽松的子串包含即可,不必为
54
+ * 精确性把原文碎片带出来。臂序有意义:自家的 race 超时先于泛化的 timeout 臂。
55
+ */
56
+ export function publicStoreProbeError(raw) {
57
+ const t = raw.toLowerCase();
58
+ if (t.includes("probe timed out"))
59
+ return "probe_timeout"; // 本环自铸的 race 超时(见 probeOnce)
60
+ if (t.includes("econnrefused") || t.includes("connection refused"))
61
+ return "connection_refused";
62
+ if (t.includes("econnreset") || t.includes("connection reset"))
63
+ return "connection_reset";
64
+ if (t.includes("ehostunreach") || t.includes("enetunreach") || t.includes("enetdown"))
65
+ return "host_unreachable";
66
+ if (t.includes("enotfound") || t.includes("eai_again") || t.includes("getaddrinfo"))
67
+ return "dns_failure";
68
+ if (t.includes("etimedout") || t.includes("timed out") || t.includes("timeout"))
69
+ return "network_timeout";
70
+ // mysql `ER_ACCESS_DENIED_ERROR` / pg `28P01 password authentication failed` / 通用 auth 措辞。
71
+ if (t.includes("access denied") || t.includes("authentication failed") || t.includes("28p01") || t.includes("er_access_denied") || t.includes("er_dbaccess_denied")) {
72
+ return "auth_failed";
73
+ }
74
+ // mysql `ER_CON_COUNT_ERROR` / pg `53300 sorry, too many clients already` / 池耗尽措辞。
75
+ if (t.includes("too many connections") || t.includes("too many clients") || t.includes("53300") || t.includes("er_con_count_error") || t.includes("connection slots")) {
76
+ return "connection_limit";
77
+ }
78
+ return "probe_failed";
79
+ }
20
80
  export function createStoreLiveProbe(opts) {
21
81
  const timeoutMs = opts.timeoutMs ?? STORE_PROBE_TIMEOUT_MS;
22
82
  let last;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 上 wire 的通告码 —— **显式闭集常量表**(设计稿 ①,起步三码)。
3
+ *
4
+ * 三码同族:都是「这条 session 的记忆姿态」的披露,resume 之后仍然相关 ⇒ 也进 durable 账本
5
+ * (cli 断连补看走既有 events 重放腿,[4634](ii))。
6
+ */
7
+ export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived"];
8
+ export type EngineNoticeWireCode = (typeof ENGINE_NOTICE_WIRE_CODES)[number];
9
+ /** 白名单谓词(单点):路由与门都读这一个,不许第二处手抄码串。 */
10
+ export declare function isEngineNoticeWireCode(code: string): code is EngineNoticeWireCode;
11
+ /**
12
+ * 归属键抽取 —— `detail.sessionId` 提升为顶层(设计稿 ②:归属键一等化)。
13
+ *
14
+ * 非串 / 空串一律按**缺席**处理(不铸 `"undefined"` 这种看起来合法的假值——`compactionOutcomeEventData`
15
+ * 的同款缺键守卫纪律)。core [4631] 的顶层 `EngineNotice.sessionId` 到货后,这里先读顶层再回落 detail
16
+ * (两处同值,additive 零 BREAKING),届时本函数是唯一改动点。
17
+ */
18
+ export declare function engineNoticeSessionId(notice: {
19
+ sessionId?: unknown;
20
+ detail?: Record<string, unknown>;
21
+ }): string | undefined;
22
+ /** wire 上的 `engine_notice` 载荷(**五键固定**,设计稿 ②)。`type` 由发帧处补(SSE)/ 由账本行的 type 列承载。 */
23
+ export interface EngineNoticeWireRow {
24
+ /** 稳定机器码 —— 消费契约:cli 按 `code`+`detail` 渲染(见 {@link ENGINE_NOTICE_WIRE_CODES} 各码的 detail 形)。 */
25
+ code: string;
26
+ /**
27
+ * core 铸的人话行。🔴 **仅 fallback 展示,不是匹配键**:core 明写 `memory.session_polluted` 的 message
28
+ * 随 `memoryProvenance` 模式变文(design/336 mode-aware),按 message 匹配必碎(5.41 合流码形退役同教训)。
29
+ */
30
+ message: string;
31
+ /** 机器可读事实,逐键消毒后直透(见 {@link sanitizeNoticeDetail} 的消毒/尺寸契约)。 */
32
+ detail: Record<string, unknown>;
33
+ /** 归属键(从 detail 提升)。本行只会出现在**这一条** session 的流上。 */
34
+ sessionId: string;
35
+ /** **服务端观察时刻**(ms epoch)——`EngineNotice` 自身不带时间戳,这是本仓收到它的那一拍,不是 core 铸它的那一拍。 */
36
+ ts: number;
37
+ }
38
+ /**
39
+ * `detail` 消毒(设计稿 ②:`redactSecrets` + 尺寸 bound;`workspaceChangedEventData` 先例)。
40
+ *
41
+ * core 已 neutralize 三码的 `reason`(工具名/子代类型是 host/模型可控输入),**server 按纪律再过一遍**
42
+ * ——§E1 的既有姿态:上 wire 的自由文本一律经本仓的脱敏器,不因为上游说过一次就免检。
43
+ *
44
+ * 契约(逐条,门在 `test/engine-notice-wire.test.ts`):
45
+ * · 串 ⇒ `redactSecrets` + 截到 {@link NOTICE_TEXT_MAX};
46
+ * · 有限 number / boolean / null ⇒ 逐字(harvest 的 count/moved/escalated 是判据数字,不许变形);
47
+ * · 非有限 number(NaN/Infinity)⇒ **丢键**(JSON 里它们会变成 `null`,一个看起来合法的假读数);
48
+ * · 数组 ⇒ 逐项消毒,超 {@link DETAIL_MAX_ITEMS} 截断;对象 ⇒ 逐键消毒,超 {@link DETAIL_MAX_KEYS} 丢尾键;
49
+ * · 深度超 {@link DETAIL_MAX_DEPTH} ⇒ 该子树丢弃;
50
+ * · 其余(function / symbol / bigint / undefined)⇒ 丢键(JSON 不可承载或语义不明)。
51
+ */
52
+ export declare function sanitizeNoticeDetail(detail: Record<string, unknown> | undefined): Record<string, unknown>;
53
+ /**
54
+ * `engine_notice` 载荷构造器(三条终点腿同源:live SSE / durable 账本 / 未来的任何第四条腿)。
55
+ *
56
+ * 与 `trace/project.ts` 的 `*EventData` 族同纪律(白名单挑键、禁 `{...notice}`),但**不住在那个文件里**:
57
+ * 那一族的入参全是 core `TaskEvent` 的臂(有 `wire-whitelist-exhaustiveness` 的臂集编译门看着),
58
+ * 而 `EngineNotice` 不是 `TaskEvent` —— 混进去会让那道门的语义变成"两个不同来源的联合",判别力反而降。
59
+ */
60
+ export declare function engineNoticeEventData(notice: {
61
+ code: string;
62
+ message: string;
63
+ detail?: Record<string, unknown>;
64
+ }, sessionId: string, nowMs: number): EngineNoticeWireRow;
65
+ /** 一条 run 腿注册进来的投递口。抛错由 {@link EngineNoticeRouter.route} 隔离(见那里的 fail-open 登记)。 */
66
+ export type EngineNoticeWireSink = (row: EngineNoticeWireRow) => void;
67
+ /**
68
+ * sessionId → 该会话活跃 run 腿的投递口。**进程内**(`defaultSubagentTailBus` 同款姿势):
69
+ * `onNotice` 是同进程的同步回调,通告与它要投的那条腿必然同副本,跨副本路由这件事结构上不存在。
70
+ *
71
+ * 🔴 跨 session 隔离靠的是 **Map 键的精确串相等**,不是任何「最近活跃」回落 —— 缺席就是缺席(G5)。
72
+ *
73
+ * 🔴 **同 session 的跨代隔离靠 legId「最新代独占」**(#310 codex 对抗复审 R1-[high],验真后修)。
74
+ * 病(真窗,本仓每一处 in-flight 登记都为它写过 identity-guard):run 行在**流体内**就已 setTerminal /
75
+ * setSuspended,而本腿的注销在 `finally` —— 中间那一窗里,同一条 session 上的**下一条腿**(快速续跑 /
76
+ * 紧接着的新提交)已经注册好了自己的口。若按「会话内全量扇出」投,新腿的一条通告会**同时**落进:
77
+ * · 旧腿那条已 end 的 SSE(顶多记一次 fail-open,无害),以及
78
+ * · **旧 taskId 的账本** —— 一条不属于那条 run 的、终态之后的行;resume 复用同 taskId 时还会与新腿
79
+ * 抢同一个 `(task_id, seq)` 域。这不是跨租户泄露,是**同租户跨 run 的账本污染**,同样必须关死。
80
+ * 药:每次 `registerLeg` 铸一个单调 legId,`route` **只投最新代**;旧代的口留在表里(它自己的注销仍
81
+ * 按身份精确删,不会误删新代),但一帧都收不到。方向 = fail-closed(宁可旧腿少一帧,不可新腿的事实
82
+ * 写进旧腿的账)。
83
+ */
84
+ export declare class EngineNoticeRouter {
85
+ private readonly legs;
86
+ /**
87
+ * 登记**一条腿**的全部口(live / durable 作为**一代**同进同出)。返回注销函数(幂等,身份精确删)。
88
+ *
89
+ * 每只口各自 try 隔离在 {@link route} 里,单口抛不牵连同腿另一口。
90
+ */
91
+ registerLeg(sessionId: string, sinks: readonly EngineNoticeWireSink[]): () => void;
92
+ /** 单口腿的便捷形(测试与单终点腿用)。 */
93
+ register(sessionId: string, sink: EngineNoticeWireSink): () => void;
94
+ /** 诊断/门用:某会话此刻**最新代**有几只口在听(旧代不计——它们收不到帧)。 */
95
+ sinkCount(sessionId: string): number;
96
+ /** 诊断/门用:某会话此刻有几代腿在表里(>1 = 正处在换代窗内)。 */
97
+ legCount(sessionId: string): number;
98
+ /**
99
+ * 分流器的 wire 半场。返回**真投出去的口数**(0 = 没投:非白名单码 / 缺 sessionId / 该会话无活跃腿)。
100
+ *
101
+ * 单只口抛错**不吃掉同腿的其它口**(fleet bus 订阅者隔离同理由),并按 F 类登记留痕。
102
+ */
103
+ route(notice: {
104
+ code: string;
105
+ message: string;
106
+ detail?: Record<string, unknown>;
107
+ sessionId?: unknown;
108
+ }, nowMs?: number): number;
109
+ }
110
+ /** 进程单例(`defaultSubagentTailBus` 同款):分流器与 run 腿直接 import,不穿长参数列表;测试自 new 独立实例。 */
111
+ export declare const defaultEngineNoticeRouter: EngineNoticeRouter;
112
+ /**
113
+ * run 腿的注册助手 —— 三条腿(sync stream / bg / resume-verify)用**同一个**形,免得各写一份漂。
114
+ *
115
+ * `sessionId` 缺席(adhoc 无会话腿)⇒ 返回 no-op 注销函数,一个字节不注册(顶注的 fail-closed 方向)。
116
+ * `live` / `durable` 各自可缺席:bg 腿没有 live SSE(消费方走 events tail),ephemeral sync 腿没有账本。
117
+ *
118
+ * 🔴 两口注册成**两只独立 sink**(不是一只闭包里顺序调两次):route 的隔离粒度是 sink,合成一只会让
119
+ * live 写(断连的 socket、JSON.stringify 抛)吃掉同一拍的 durable 落账 —— 而 durable 那半正是断连之后
120
+ * 唯一还看得见这条通告的地方(G3),两者的失败必须互不牵连。
121
+ */
122
+ export declare function registerEngineNoticeLeg(opts: {
123
+ sessionId: string | undefined;
124
+ live?: EngineNoticeWireSink;
125
+ durable?: EngineNoticeWireSink;
126
+ router?: EngineNoticeRouter;
127
+ }): () => void;
128
+ //# sourceMappingURL=engine-notice-wire.d.ts.map