@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.
- package/USAGE.md +9 -0
- package/dist/boot/resolve-spec.js +6 -1
- package/dist/boot/runner-deps.d.ts +21 -3
- package/dist/boot/runner-deps.js +31 -4
- package/dist/boot/session-faces.d.ts +3 -0
- package/dist/boot/session-faces.js +11 -2
- package/dist/boot/side-query-lane.d.ts +77 -54
- package/dist/boot/side-query-lane.js +102 -66
- package/dist/boot/stores.d.ts +1 -0
- package/dist/boot/stores.js +19 -1
- package/dist/boot/task-list-lane.d.ts +92 -0
- package/dist/boot/task-list-lane.js +63 -0
- package/dist/bounded-session-map.d.ts +3 -0
- package/dist/bounded-session-map.js +5 -0
- package/dist/capabilities/scenarios.d.ts +30 -2
- package/dist/capabilities/scenarios.js +16 -3
- package/dist/config-types.d.ts +21 -1
- package/dist/config.js +8 -1
- package/dist/hooks/branch-transcript.d.ts +3 -2
- package/dist/hooks/branch-transcript.js +7 -1
- package/dist/http/active-run-conflict.d.ts +43 -0
- package/dist/http/active-run-conflict.js +8 -0
- package/dist/http/route-ctx.d.ts +22 -1
- package/dist/http/routes/approvals-assistant.js +13 -1
- package/dist/http/routes/notify-wake.js +6 -0
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/tasks.js +30 -2
- package/dist/http/server.js +103 -7
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -0
- package/dist/main.js +11 -3
- package/dist/memory-posture.d.ts +12 -1
- package/dist/memory-posture.js +2 -0
- package/dist/observability/fail-open.d.ts +9 -1
- package/dist/observability/fail-open.js +9 -1
- package/dist/plugins/memory-engine-pg.js +42 -4
- package/dist/plugins/memory-engine-tidb.js +38 -4
- package/dist/plugins/memory-origin-law.d.ts +69 -0
- package/dist/plugins/memory-origin-law.js +98 -0
- package/dist/plugins/retention-store-sql.d.ts +7 -0
- package/dist/plugins/retention-store-sql.js +24 -0
- package/dist/plugins/task-list-store-sql.d.ts +36 -25
- package/dist/plugins/task-list-store-sql.js +102 -0
- package/dist/run-local.js +14 -5
- package/dist/runs.js +17 -0
- package/dist/trace/engine-notice-wire.d.ts +128 -0
- package/dist/trace/engine-notice-wire.js +256 -0
- package/dist/trace/ledger-events.d.ts +11 -1
- package/dist/trace/ledger-sink.d.ts +17 -0
- package/dist/trace/ledger-sink.js +25 -0
- package/package.json +2 -2
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `engine_notice` wire 腿(#310,设计稿 = 黑板 [4630],core 表态 [4631] / cli 表态 [4634])——
|
|
3
|
+
* core `EngineNotice` 的**第二终点**:按 sessionId 路由到那条会话的事件投影(SSE + durable 账本)。
|
|
4
|
+
*
|
|
5
|
+
* ## 为什么是白名单而不是全族上 wire
|
|
6
|
+
* `RunnerDeps.onNotice` 是 **deps 级单 sink**(一部署一只),`EngineNotice` 上**没有 runId**——
|
|
7
|
+
* 归属只能靠 `detail.sessionId`。于是:
|
|
8
|
+
* · `config.*` 族一个都不带 sessionId ⇒ 结构性无处投(投给谁都是猜);
|
|
9
|
+
* · core 侧**按 sink 去重**的码(`config.read_face_deployment_clamped` / `config.tool_model_gate_*`)
|
|
10
|
+
* 一旦上 per-session 流,语义当场翻面:共享 sink 意味着整进程只发一次,第二条会话的用户看到的是
|
|
11
|
+
* 「随机缺席」而不是「没发生」;
|
|
12
|
+
* · 运维码(`config.env_timeout_discarded` 一族)推给终端用户 = 跨租户串台面。
|
|
13
|
+
* 白名单里的三码,core 侧的去重单位分别是 **session / harvest / prepared-leg**(不是 sink),所以它们
|
|
14
|
+
* 上 per-session 流不产生上面第二条的翻面——这正是白名单避开去重陷阱的判据,不是随手挑的三个码。
|
|
15
|
+
*
|
|
16
|
+
* ## 缺 sessionId 的白名单码:如实不投
|
|
17
|
+
* 三码的 `detail.sessionId` 在 core 类型上是**可选**的(部分铸点确无 session 语境)。缺席时本腿
|
|
18
|
+
* **不投**(fail-closed 方向 = 宁缺席不串台;猜一个"当前活跃会话"就是串台的定义)。日志终点仍逐字
|
|
19
|
+
* 保留 ⇒ 运维观察面零回退,事实一条不丢。
|
|
20
|
+
*
|
|
21
|
+
* ## 白名单的演化机制(设计稿 ④)
|
|
22
|
+
* 本表是**过渡形**。core [4631] 已认领 `NOTICE_AUDIENCE` **码级注册表**(code → "user"|"operator",
|
|
23
|
+
* 闭集映射,随 5.46 / 最迟 5.47)+ `EngineNotice.sessionId` 顶层可选。到货后本表**退役为消费该表**,
|
|
24
|
+
* 不在本仓自造第二份「哪类码该上 wire」的判定语义。
|
|
25
|
+
* 🔴 在此之前的固定动作:**每一次 core 提货批**,LEDGER 对表 core `EngineNotice` 码册的新增码,
|
|
26
|
+
* 三选一登记(入册 / 明拒 + 理由 / 申裁),登记在 `docs/CORE-CONSUMPTION-LEDGER.md` 的该批行里。
|
|
27
|
+
* 漏对表的后果不是报错而是**静默缺席**:新码永远不上 wire,消费端(cli)无从知道该码存在。
|
|
28
|
+
*/
|
|
29
|
+
import { redactSecrets } from "./redact.js";
|
|
30
|
+
import { recordFailOpen } from "../observability/fail-open.js";
|
|
31
|
+
/**
|
|
32
|
+
* 上 wire 的通告码 —— **显式闭集常量表**(设计稿 ①,起步三码)。
|
|
33
|
+
*
|
|
34
|
+
* 三码同族:都是「这条 session 的记忆姿态」的披露,resume 之后仍然相关 ⇒ 也进 durable 账本
|
|
35
|
+
* (cli 断连补看走既有 events 重放腿,[4634](ii))。
|
|
36
|
+
*/
|
|
37
|
+
export const ENGINE_NOTICE_WIRE_CODES = [
|
|
38
|
+
/** design/178 §3:本会话记忆越过一次性 POLLUTED 态。core 去重单位 = **session**。`detail: { reason, sessionId? }` */
|
|
39
|
+
"memory.session_polluted",
|
|
40
|
+
/** design/178 §3:被污染会话的 harvest 执行了容纳。core 去重单位 = **harvest**(检查点收与终局收是两件事)。
|
|
41
|
+
* `detail: { count, moved, escalated, reason?, sessionId? }` —— 🔴 `moved` 与 `escalated` **不可相减**
|
|
42
|
+
* (就地墓碑同时计入两者,core 顶注);消费端并列呈现,不做算术。 */
|
|
43
|
+
"memory.harvest_quarantined",
|
|
44
|
+
/** design/324 #324①:`memoryDelegationEvidence:"attested-only"` 下,一次静态面本会打的污染标记被豁免。
|
|
45
|
+
* core 去重单位 = **prepared leg**。`detail: { reason, subagentType?, sessionId? }` */
|
|
46
|
+
"memory.delegation_static_mark_waived",
|
|
47
|
+
];
|
|
48
|
+
const WIRE_CODES = new Set(ENGINE_NOTICE_WIRE_CODES);
|
|
49
|
+
/** 白名单谓词(单点):路由与门都读这一个,不许第二处手抄码串。 */
|
|
50
|
+
export function isEngineNoticeWireCode(code) {
|
|
51
|
+
return WIRE_CODES.has(code);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 归属键抽取 —— `detail.sessionId` 提升为顶层(设计稿 ②:归属键一等化)。
|
|
55
|
+
*
|
|
56
|
+
* 非串 / 空串一律按**缺席**处理(不铸 `"undefined"` 这种看起来合法的假值——`compactionOutcomeEventData`
|
|
57
|
+
* 的同款缺键守卫纪律)。core [4631] 的顶层 `EngineNotice.sessionId` 到货后,这里先读顶层再回落 detail
|
|
58
|
+
* (两处同值,additive 零 BREAKING),届时本函数是唯一改动点。
|
|
59
|
+
*/
|
|
60
|
+
export function engineNoticeSessionId(notice) {
|
|
61
|
+
const top = notice.sessionId; // core 5.46+ 顶层键(到货前恒 undefined,读它零代价)
|
|
62
|
+
if (typeof top === "string" && top.length > 0)
|
|
63
|
+
return top;
|
|
64
|
+
const fromDetail = notice.detail?.sessionId;
|
|
65
|
+
return typeof fromDetail === "string" && fromDetail.length > 0 ? fromDetail : undefined;
|
|
66
|
+
}
|
|
67
|
+
/** 自由文本(message / detail 里的串)的长度上限。`workspaceChangedEventData` 的 300 是**路径**级 bound;
|
|
68
|
+
* 通告的 `reason` 是一整句话(core 侧已 neutralize + bound),这里给它一个不腰斩正常句子的上限。 */
|
|
69
|
+
const NOTICE_TEXT_MAX = 1000;
|
|
70
|
+
/** detail 递归消毒的结构上限。三码的真 detail 都是 ≤5 键的扁平对象 ⇒ 生产上永不触顶;
|
|
71
|
+
* 上限存在的理由是**未来码**(以及被污染输入)不得把任意大小的对象推上 wire。 */
|
|
72
|
+
const DETAIL_MAX_DEPTH = 4;
|
|
73
|
+
const DETAIL_MAX_KEYS = 32;
|
|
74
|
+
const DETAIL_MAX_ITEMS = 32;
|
|
75
|
+
/**
|
|
76
|
+
* `detail` 消毒(设计稿 ②:`redactSecrets` + 尺寸 bound;`workspaceChangedEventData` 先例)。
|
|
77
|
+
*
|
|
78
|
+
* core 已 neutralize 三码的 `reason`(工具名/子代类型是 host/模型可控输入),**server 按纪律再过一遍**
|
|
79
|
+
* ——§E1 的既有姿态:上 wire 的自由文本一律经本仓的脱敏器,不因为上游说过一次就免检。
|
|
80
|
+
*
|
|
81
|
+
* 契约(逐条,门在 `test/engine-notice-wire.test.ts`):
|
|
82
|
+
* · 串 ⇒ `redactSecrets` + 截到 {@link NOTICE_TEXT_MAX};
|
|
83
|
+
* · 有限 number / boolean / null ⇒ 逐字(harvest 的 count/moved/escalated 是判据数字,不许变形);
|
|
84
|
+
* · 非有限 number(NaN/Infinity)⇒ **丢键**(JSON 里它们会变成 `null`,一个看起来合法的假读数);
|
|
85
|
+
* · 数组 ⇒ 逐项消毒,超 {@link DETAIL_MAX_ITEMS} 截断;对象 ⇒ 逐键消毒,超 {@link DETAIL_MAX_KEYS} 丢尾键;
|
|
86
|
+
* · 深度超 {@link DETAIL_MAX_DEPTH} ⇒ 该子树丢弃;
|
|
87
|
+
* · 其余(function / symbol / bigint / undefined)⇒ 丢键(JSON 不可承载或语义不明)。
|
|
88
|
+
*/
|
|
89
|
+
export function sanitizeNoticeDetail(detail) {
|
|
90
|
+
const out = sanitizeValue(detail ?? {}, 0);
|
|
91
|
+
return (out ?? {});
|
|
92
|
+
}
|
|
93
|
+
function sanitizeValue(v, depth) {
|
|
94
|
+
if (v === null)
|
|
95
|
+
return null;
|
|
96
|
+
if (typeof v === "string")
|
|
97
|
+
return redactSecrets(v).slice(0, NOTICE_TEXT_MAX);
|
|
98
|
+
if (typeof v === "number")
|
|
99
|
+
return Number.isFinite(v) ? v : undefined;
|
|
100
|
+
if (typeof v === "boolean")
|
|
101
|
+
return v;
|
|
102
|
+
if (depth >= DETAIL_MAX_DEPTH)
|
|
103
|
+
return undefined;
|
|
104
|
+
if (Array.isArray(v)) {
|
|
105
|
+
const items = [];
|
|
106
|
+
for (const item of v.slice(0, DETAIL_MAX_ITEMS)) {
|
|
107
|
+
const s = sanitizeValue(item, depth + 1);
|
|
108
|
+
if (s !== undefined)
|
|
109
|
+
items.push(s);
|
|
110
|
+
}
|
|
111
|
+
return items;
|
|
112
|
+
}
|
|
113
|
+
if (typeof v === "object") {
|
|
114
|
+
const rec = {};
|
|
115
|
+
let n = 0;
|
|
116
|
+
for (const [k, val] of Object.entries(v)) {
|
|
117
|
+
if (n >= DETAIL_MAX_KEYS)
|
|
118
|
+
break;
|
|
119
|
+
const s = sanitizeValue(val, depth + 1);
|
|
120
|
+
if (s === undefined)
|
|
121
|
+
continue;
|
|
122
|
+
rec[k] = s;
|
|
123
|
+
n++;
|
|
124
|
+
}
|
|
125
|
+
return rec;
|
|
126
|
+
}
|
|
127
|
+
return undefined; // function / symbol / bigint / undefined
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `engine_notice` 载荷构造器(三条终点腿同源:live SSE / durable 账本 / 未来的任何第四条腿)。
|
|
131
|
+
*
|
|
132
|
+
* 与 `trace/project.ts` 的 `*EventData` 族同纪律(白名单挑键、禁 `{...notice}`),但**不住在那个文件里**:
|
|
133
|
+
* 那一族的入参全是 core `TaskEvent` 的臂(有 `wire-whitelist-exhaustiveness` 的臂集编译门看着),
|
|
134
|
+
* 而 `EngineNotice` 不是 `TaskEvent` —— 混进去会让那道门的语义变成"两个不同来源的联合",判别力反而降。
|
|
135
|
+
*/
|
|
136
|
+
export function engineNoticeEventData(notice, sessionId, nowMs) {
|
|
137
|
+
return {
|
|
138
|
+
code: notice.code,
|
|
139
|
+
message: redactSecrets(String(notice.message)).slice(0, NOTICE_TEXT_MAX),
|
|
140
|
+
detail: sanitizeNoticeDetail(notice.detail),
|
|
141
|
+
sessionId,
|
|
142
|
+
ts: nowMs,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
let nextLegId = 1;
|
|
146
|
+
/**
|
|
147
|
+
* sessionId → 该会话活跃 run 腿的投递口。**进程内**(`defaultSubagentTailBus` 同款姿势):
|
|
148
|
+
* `onNotice` 是同进程的同步回调,通告与它要投的那条腿必然同副本,跨副本路由这件事结构上不存在。
|
|
149
|
+
*
|
|
150
|
+
* 🔴 跨 session 隔离靠的是 **Map 键的精确串相等**,不是任何「最近活跃」回落 —— 缺席就是缺席(G5)。
|
|
151
|
+
*
|
|
152
|
+
* 🔴 **同 session 的跨代隔离靠 legId「最新代独占」**(#310 codex 对抗复审 R1-[high],验真后修)。
|
|
153
|
+
* 病(真窗,本仓每一处 in-flight 登记都为它写过 identity-guard):run 行在**流体内**就已 setTerminal /
|
|
154
|
+
* setSuspended,而本腿的注销在 `finally` —— 中间那一窗里,同一条 session 上的**下一条腿**(快速续跑 /
|
|
155
|
+
* 紧接着的新提交)已经注册好了自己的口。若按「会话内全量扇出」投,新腿的一条通告会**同时**落进:
|
|
156
|
+
* · 旧腿那条已 end 的 SSE(顶多记一次 fail-open,无害),以及
|
|
157
|
+
* · **旧 taskId 的账本** —— 一条不属于那条 run 的、终态之后的行;resume 复用同 taskId 时还会与新腿
|
|
158
|
+
* 抢同一个 `(task_id, seq)` 域。这不是跨租户泄露,是**同租户跨 run 的账本污染**,同样必须关死。
|
|
159
|
+
* 药:每次 `registerLeg` 铸一个单调 legId,`route` **只投最新代**;旧代的口留在表里(它自己的注销仍
|
|
160
|
+
* 按身份精确删,不会误删新代),但一帧都收不到。方向 = fail-closed(宁可旧腿少一帧,不可新腿的事实
|
|
161
|
+
* 写进旧腿的账)。
|
|
162
|
+
*/
|
|
163
|
+
export class EngineNoticeRouter {
|
|
164
|
+
legs = new Map();
|
|
165
|
+
/**
|
|
166
|
+
* 登记**一条腿**的全部口(live / durable 作为**一代**同进同出)。返回注销函数(幂等,身份精确删)。
|
|
167
|
+
*
|
|
168
|
+
* 每只口各自 try 隔离在 {@link route} 里,单口抛不牵连同腿另一口。
|
|
169
|
+
*/
|
|
170
|
+
registerLeg(sessionId, sinks) {
|
|
171
|
+
const leg = { legId: nextLegId++, sinks: [...sinks] };
|
|
172
|
+
const list = this.legs.get(sessionId) ?? [];
|
|
173
|
+
list.push(leg); // 追加 = 本代成为最新代
|
|
174
|
+
this.legs.set(sessionId, list);
|
|
175
|
+
let done = false;
|
|
176
|
+
return () => {
|
|
177
|
+
if (done)
|
|
178
|
+
return;
|
|
179
|
+
done = true;
|
|
180
|
+
const cur = this.legs.get(sessionId);
|
|
181
|
+
if (!cur)
|
|
182
|
+
return;
|
|
183
|
+
const at = cur.indexOf(leg); // 身份精确删:绝不按位置/长度删(会误删新代)
|
|
184
|
+
if (at >= 0)
|
|
185
|
+
cur.splice(at, 1);
|
|
186
|
+
if (cur.length === 0)
|
|
187
|
+
this.legs.delete(sessionId);
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** 单口腿的便捷形(测试与单终点腿用)。 */
|
|
191
|
+
register(sessionId, sink) {
|
|
192
|
+
return this.registerLeg(sessionId, [sink]);
|
|
193
|
+
}
|
|
194
|
+
/** 诊断/门用:某会话此刻**最新代**有几只口在听(旧代不计——它们收不到帧)。 */
|
|
195
|
+
sinkCount(sessionId) {
|
|
196
|
+
const list = this.legs.get(sessionId);
|
|
197
|
+
return list && list.length > 0 ? list[list.length - 1].sinks.length : 0;
|
|
198
|
+
}
|
|
199
|
+
/** 诊断/门用:某会话此刻有几代腿在表里(>1 = 正处在换代窗内)。 */
|
|
200
|
+
legCount(sessionId) {
|
|
201
|
+
return this.legs.get(sessionId)?.length ?? 0;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 分流器的 wire 半场。返回**真投出去的口数**(0 = 没投:非白名单码 / 缺 sessionId / 该会话无活跃腿)。
|
|
205
|
+
*
|
|
206
|
+
* 单只口抛错**不吃掉同腿的其它口**(fleet bus 订阅者隔离同理由),并按 F 类登记留痕。
|
|
207
|
+
*/
|
|
208
|
+
route(notice, nowMs = Date.now()) {
|
|
209
|
+
if (!isEngineNoticeWireCode(notice.code))
|
|
210
|
+
return 0;
|
|
211
|
+
const sessionId = engineNoticeSessionId(notice);
|
|
212
|
+
if (sessionId === undefined)
|
|
213
|
+
return 0; // 如实不投(顶注:宁缺席不串台)
|
|
214
|
+
const list = this.legs.get(sessionId);
|
|
215
|
+
const leg = list && list.length > 0 ? list[list.length - 1] : undefined; // 只投最新代
|
|
216
|
+
if (!leg || leg.sinks.length === 0)
|
|
217
|
+
return 0;
|
|
218
|
+
const row = engineNoticeEventData(notice, sessionId, nowMs);
|
|
219
|
+
let delivered = 0;
|
|
220
|
+
for (const sink of leg.sinks) {
|
|
221
|
+
try {
|
|
222
|
+
sink(row);
|
|
223
|
+
delivered++;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
recordFailOpen("server.engine-notice.wire-sink-threw", `code=${notice.code}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return delivered;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** 进程单例(`defaultSubagentTailBus` 同款):分流器与 run 腿直接 import,不穿长参数列表;测试自 new 独立实例。 */
|
|
233
|
+
export const defaultEngineNoticeRouter = new EngineNoticeRouter();
|
|
234
|
+
/**
|
|
235
|
+
* run 腿的注册助手 —— 三条腿(sync stream / bg / resume-verify)用**同一个**形,免得各写一份漂。
|
|
236
|
+
*
|
|
237
|
+
* `sessionId` 缺席(adhoc 无会话腿)⇒ 返回 no-op 注销函数,一个字节不注册(顶注的 fail-closed 方向)。
|
|
238
|
+
* `live` / `durable` 各自可缺席:bg 腿没有 live SSE(消费方走 events tail),ephemeral sync 腿没有账本。
|
|
239
|
+
*
|
|
240
|
+
* 🔴 两口注册成**两只独立 sink**(不是一只闭包里顺序调两次):route 的隔离粒度是 sink,合成一只会让
|
|
241
|
+
* live 写(断连的 socket、JSON.stringify 抛)吃掉同一拍的 durable 落账 —— 而 durable 那半正是断连之后
|
|
242
|
+
* 唯一还看得见这条通告的地方(G3),两者的失败必须互不牵连。
|
|
243
|
+
*/
|
|
244
|
+
export function registerEngineNoticeLeg(opts) {
|
|
245
|
+
const { sessionId, live, durable } = opts;
|
|
246
|
+
if (sessionId === undefined || sessionId.length === 0)
|
|
247
|
+
return () => undefined;
|
|
248
|
+
const sinks = [...(live ? [live] : []), ...(durable ? [durable] : [])];
|
|
249
|
+
if (sinks.length === 0)
|
|
250
|
+
return () => undefined;
|
|
251
|
+
// 🔴 两口登记成**同一代**(`registerLeg` 一次调用)而不是两次 `register`:代是"腿"的属性,拆成两代
|
|
252
|
+
// 会让换代时旧腿的一只口先被超代、另一只还没 —— 半代状态没有任何正确语义。口的失败隔离是 `route`
|
|
253
|
+
// 里的 per-sink try(与代无关),两者是正交的两件事。
|
|
254
|
+
return (opts.router ?? defaultEngineNoticeRouter).registerLeg(sessionId, sinks);
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=engine-notice-wire.js.map
|
|
@@ -39,5 +39,15 @@ export type LedgerEventType = "reasoning" | "text" | "tool_start" | "tool_end" |
|
|
|
39
39
|
/** design/172 §4.3 新协议的呈卡帧(与上面两只 tool_approval 帧同一条投递面)。 */
|
|
40
40
|
| "approval_request"
|
|
41
41
|
/** 异步 workflow 完成的入箱 drain(`emitPendingWorkflowCompletions` 两个字面型之一)。 */
|
|
42
|
-
| "workflow_complete"
|
|
42
|
+
| "workflow_complete"
|
|
43
|
+
/**
|
|
44
|
+
* #310 `engine_notice` —— core `EngineNotice` 白名单三码按 `detail.sessionId` 路由到该会话的事件投影。
|
|
45
|
+
*
|
|
46
|
+
* 🔴 **是"腿自己的带外行",不是流事件投影**:它不经 `TaskStream`(`RunnerDeps.onNotice` 是 deps 级
|
|
47
|
+
* 同步 sink),所以两条 durable 腿的 `switch (ev.type)` 里**没有也不该有** `case "engine_notice"` ——
|
|
48
|
+
* `test/durable-append-arm-parity.test.ts` 的差集是按那个 switch 抽的,本型结构上在它射程之外
|
|
49
|
+
* (与 `elicitation` / `question` / `approval_request` / `model_usage` 同类)。本型的"三腿是否都挂了口"
|
|
50
|
+
* 由 `test/engine-notice-wire.test.ts` 的腿覆盖门看守,那才是它的对位门。
|
|
51
|
+
*/
|
|
52
|
+
| "engine_notice";
|
|
43
53
|
//# sourceMappingURL=ledger-events.d.ts.map
|
|
@@ -70,6 +70,23 @@ export interface LedgerSink {
|
|
|
70
70
|
status?: string;
|
|
71
71
|
}): Promise<void>;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* **单写串行链**的可复用形(#310 codex 对抗复审 R1-[high],验真后修)—— `seq` 的**分配发生在链步内**,
|
|
75
|
+
* 于是「分配序 == 发起序 == 提交序」。
|
|
76
|
+
*
|
|
77
|
+
* 病(实测形,`LedgerSink` 内部早有这条纪律的内联版、其顶注 [1.211 codex H2] 逐字记着它):裸写口
|
|
78
|
+
* (`(type, data) => rs.appendEvent(taskId, ++seq, type, data)`)在**同步分配 seq、立刻发起 insert**。
|
|
79
|
+
* 主循环 await 自己的写,所以主循环内部有序;但只要有**一个 fire-and-forget 写者**(子代 forward 帧、
|
|
80
|
+
* #310 的通告口),它的 N 号 insert 可能在池化 SQL 后端上晚于后发的 N+1 号提交。events tail 的读法是
|
|
81
|
+
* `getEvents(id, afterSeq)` —— 读到 N+1 就把游标推过 N,**那一行此后永远不会被投递**(重连也不会,
|
|
82
|
+
* 游标只前进)。行在库里,消费端结构性看不见。
|
|
83
|
+
*
|
|
84
|
+
* 药:所有写口共用一条 promise 链,`++seq` 在链步内取。链上一步失败**不断链**(catch → undefined),
|
|
85
|
+
* 调用方仍拿到自己那一步的真实结果(await 者照常收到 reject)——与 `LedgerSink` 内联版逐字同语义。
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ 用它的前提是**这条腿的全部写口都走它**(混用一个绕开链的写口 = 病灶原样保留)。
|
|
88
|
+
*/
|
|
89
|
+
export declare function createSerialLedgerAppend(appendEvent: (seq: number, type: LedgerEventType, data: unknown) => Promise<void>, startSeq: number): (type: LedgerEventType, data: unknown) => Promise<void>;
|
|
73
90
|
export declare function createLedgerSink(opts: {
|
|
74
91
|
/** The seq-stamped durable append — typically `(seq, type, data) => runStore.appendEvent(taskId, seq, type, data)`. */
|
|
75
92
|
appendEvent: (seq: number, type: LedgerEventType, data: unknown) => Promise<void>;
|