dsh-kingdom 3.2.0 → 3.2.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.
@@ -1,103 +1,108 @@
1
- # dsh-Kingdom 3.2.0 本地候选说明
2
-
3
- 3.2.0 只有两类产品变更:**主管与领地的完全绑定**(任命时必须同时指定领地、一个主管只主理一个
4
- 领地,席位与主理同一事务原子写入;退任时同时解除主理)与**交付区版面修复**(控制区不再抢宽导致
5
- 正文退化成竖排)。没有新增表、没有新增迁移,沿用 schema v4 与既有事件类型。
6
-
7
- ## 验证状态(本轮实际执行过的)
8
-
9
- | 项 | 结果 |
10
- |---|---|
11
- | `npm run typecheck`(`tsc -p tsconfig.json --noEmit`) | 通过,0 错误 |
12
- | 全量 `node --test tests/*.test.ts` | 594 项:**592 通过 / 0 失败 / 2 跳过**(2 项在 Windows 上跳过:1 项是 `process.platform === 'win32'` 的平台门,1 项是宿主无法创建符号链接时的运行时跳过) |
13
- | 新增定向用例 `tests/v320-supervisor-territory-binding.test.ts` | 6/6 通过(AC1–AC5 + 事务嵌套) |
14
- | `scripts/p2-smoke.mjs` | 36/36 通过 |
15
- | `scripts/p3-smoke.mjs` | 19/19 通过 |
16
- | `scripts/hotplug-audit.mjs` | 26/26 通过 |
17
-
18
- **未执行(NOT_RUN)**:发布门 `scripts/release.ps1 -DryRun`(须在干净工作区、冻结提交后运行);
19
- 真实浏览器人工交互验收(含新版表单在四种主题与窄屏下的实际观感);把本版 tgz 装进本机
20
- profile 后的 DSH `0.1.7-rc.2` 实机加载回归;Linux 平台;收益与成本测量。未运行过的项不得当作
21
- 已通过。本版发布前的独立审查结论与 Owner 验收记录见发布回执(不在包内)。
22
-
23
- ## 1. 主管与领地完全绑定
24
-
25
- **问题**:此前"任命主管"与"指定它的领地"是两条独立命令。中途放弃第二步,就会留下一个没有
26
- 领地的在任主管:席位显示已任命,GUI 里那块领地却仍显示"没有主管",而治理操作按
27
- `TERRITORY_SUPERVISOR_MISSING` fail-closed。
28
-
29
- **现在的规则**(Owner 口径 2026-09-28 + 1:1 裁定 2026-09-29):
30
-
31
- - **任命主管必须同时给出领地**:`role.bind` 的 `role_type=SUPERVISOR` 需要 `territory_id`;
32
- 缺省即拒绝,不写入任何事实。非主管角色携带 `territory_id` 同样拒绝(不静默忽略)。
33
- - **同一事务原子写入**:席位与「该领地的主理」一起提交或一起回滚。领地侧被拒时,已经插入的
34
- 席位随事务回滚,**不会**留下"有主管、没领地"的中间态。
35
- - **不静默替换在任主理**:目标领地已有**在任 ACTIVE** 主理时,改派被拒绝并提示先解除;
36
- 解除(`supervisor_binding_id: null`)后再指派。指向**已退任席位**的历史悬挂引用属于修复,
37
- 可以直接改派(direct Slash 与 Owner GUI 窗口是同一条判据)。
38
- - **一个主管席位只主理一个领地(1:1)**:席位已隶属其它未删除领地时,指派被拒绝并提示先解除
39
- 原领地的主理。要在领地之间移动一个主管,必须"先解除原领地,再指派到新领地"。
40
- 已被删除(tombstone)的领地不参与治理,其历史指针不锁住席位。
41
- - **退任联动**:`role.unbind` 退任一个主管时,**同一事务**里解除它在领地上的主理关系并写出一条
42
- 明确的解除事实(`TERRITORY_SUPERVISOR_UPDATED`,`unassigned: true`),不留下指向 RETIRED
43
- 席位的悬挂引用。
44
- - **存量未隶属席位**:3.2.0 之前建立的、没有领地的在任主管**不会被自动归属**。席位投影会给出
45
- 它实际主理的领地列表,为空时由界面标注「未隶属领地(待处理)」,由 Owner 决定指派或解除。
46
- 这类遗留席位同样受 1:1 约束(已经主理某领地的席位不会被自动改派)。
47
-
48
- 命令与界面:
49
-
50
- ```
51
- /kingdom role.bind {"role_type":"SUPERVISOR","role_name":"主理","territory_id":"<领地 id>","session_id":"<真实 DSH session>"}
52
- /kingdom role.bind {"role_type":"WORKER","role_name":"执行者"} # 非主管不带 territory_id
53
- /kingdom territory.supervisor {"territory_id":"...","supervisor_binding_id":null} # 解除该领地主理
54
- /kingdom role.unbind {"binding_id":"<主管 binding id>","reason":"换届"} # 同时解除其领地主理
55
- ```
56
-
57
- Owner GUI 管理窗口的 `role.bind` 表单在角色为主管时出现**必选**领地下拉(内容来自本次授权范围
58
- 目录),其它角色隐藏该字段;`kingdom_draft_owner_binding_intent` 的自然语言草案对主管改为
59
- **单步**可执行命令(`role.bind` 自带 `territory_id`),不再是旧的两步。
60
-
61
- **领地范围判据**:主管任命的目标领地必须**显式**出现在本次管理窗口的授权范围内
62
- (`catalog.territories` 与提交判定完全一致,不用 `kingdomWide` 放宽)。若要在窗口里给新建的
63
- 领地任命主管,请重新激活一个包含该领地的窗口。
64
-
65
- **管理窗口现在也能解除主理**:`territory.supervisor` 的 `supervisor_binding_id` 接受**显式 `null`**
66
- (表单上是"解除现任主理(不指派新主管)"勾选框;省略该字段仍是输入错误,不会被当成解除)。
67
- 因此 1:1 之下"把主理从领地 A 换到领地 B"可以完全在窗口里完成:先在 A 上解除、再到 B 上指派。
68
- 解除同样受未结算执行守卫约束(有在途工作时不允许解除责任),预览会写清"解除后该领地无主理 →
69
- fail-closed 直到指派新的"。
70
-
71
- **存量数据的说明**:3.2.0 之前可能留下两类与上面规则不符的存量——① 没有隶属领地的在任主管席位;
72
- ② 一个席位同时挂在多个领地(旧能力允许)。本版**不会自动收敛**它们:① 只作可见标注,② 只约束
73
- **新的**指派。两者在退任时都会被一并解除(退任联动遍历该席位名下的所有未删除领地)。
74
-
75
- **事务嵌套**:直接 Slash 的 `ownerWrite` 与 Owner GUI 窗口的 `apply` 各自已经开了一层事务,
76
- 而这条原子通道还要求自己的边界。`KingdomStore.withImmediateTransaction` 因此改为真正的嵌套:
77
- 外层 `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK`,内层 `SAVEPOINT`/`RELEASE`/`ROLLBACK TO`。
78
- 只做"加入外层"是不够的——内层把拒绝变成返回文案时,它已写下的行会留在外层事务里被提交。
79
- `schema`、表结构与事件类型均未改动。
80
-
81
- **边界**:`bindRole()` 仍是低层原语,本身不承载"主管必须有领地"这条策略;策略在全部 Owner 入口
82
- (direct Slash、Owner GUI 窗口、GUI 表单)强制执行,并统一由 `bindSupervisorForTerritory` 完成
83
- 原子写入。这样既保证产品层面不存在"有主管、没领地"的创建路径,又不改动只用于构造测试夹具的
84
- 低层调用。Agent Tool(`kingdom_bind_role` 等)依旧是 zero-write。
85
-
86
- **未结算执行的拦截范围(3.2.0 审计 r1 更正)**:`UNSETTLED_EXECUTION` / `UNSETTLED_LEASE` /
87
- `UNSETTLED_DISPATCH` 守卫只覆盖 **Owner 管理窗口**这条路径(`guardUnsettled`)。direct Slash 与
88
- core 的 `role.bind` / `territory.supervisor` **没有**等价守卫——这是 3.1.0 的既有语义,本版没有
89
- 改动它,文档也不得声称"旧主管的未结算执行一定会拦住改派"。需要该守卫时请走 Owner 管理窗口。
90
-
91
- ## 2. 交付区版面修复
92
-
93
- 交付条目的控制区里有一条 `flex: 1 0 100%` 的状态文本,而它在 `grid-template-columns:
94
- minmax(0, 1fr) auto` 的右列(`auto` = max-content)里。右轨被撑到最宽,左轨被压成 0 宽,
95
- 正文因此退化成每行一两个字、竖排显示。现在改成分行 flex:正文占弹性主列(20rem 起),
96
- 控制区按自身内容窄排,放不下时整体换行。这是纯 CSS 改动,不改变任何数据、事件或判断。
97
- 交付条目里显示的 `UNKNOWN` 是**数据**(v1.0.0 旧格式的弱接受证据),产品按设计不推断。
98
-
99
- ## 不适用范围
100
-
101
- - 不改 schema,不新增迁移;不改 Owner Control Plane 的授权模型。
102
- - 不改 `resolveTrustedToolSession`,不改 3.1.0 的 peer 分线声明。
103
- - 不处理 3.1.0 审计遗留的文档残留(另行处理)。
1
+ # dsh-Kingdom 3.2.0 本地候选说明
2
+
3
+ 3.2.0 只有两类产品变更:**主管与领地的完全绑定**(任命时必须同时指定领地、一个主管只主理一个
4
+ 领地,席位与主理同一事务原子写入;退任时同时解除主理)与**交付区版面修复**(控制区不再抢宽导致
5
+ 正文退化成竖排)。没有新增表、没有新增迁移,沿用 schema v4 与既有事件类型。
6
+
7
+ ## 验证状态(本轮实际执行过的)
8
+
9
+ | 项 | 结果 |
10
+ |---|---|
11
+ | `npm run typecheck`(`tsc -p tsconfig.json --noEmit`) | 通过,0 错误 |
12
+ | 全量 `node --test tests/*.test.ts` | 596 项:**594 通过 / 0 失败 / 2 跳过**(2 项在 Windows 上跳过:1 项是 `process.platform === 'win32'` 的平台门,1 项是宿主无法创建符号链接时的运行时跳过) |
13
+ | 新增定向用例 `tests/v320-supervisor-territory-binding.test.ts` | 6/6 通过(AC1–AC5 + 事务嵌套) |
14
+ | `tests/owner-window.test.ts` | 28/28 通过(含"窗口解除主理"与"缺字段/范围外拒绝"两个新用例) |
15
+ | `scripts/p2-smoke.mjs` | 36/36 通过 |
16
+ | `scripts/p3-smoke.mjs` | 19/19 通过 |
17
+ | `scripts/hotplug-audit.mjs` | 26/26 通过 |
18
+ | 发布门 `pwsh -File scripts/release.ps1 -Version 3.2.0 -DryRun` | **P0–P3 全 PASS**(在提交 `075e410` 后的干净工作树运行;P3 产出 `dsh-kingdom-3.2.0.tgz`,722 669 字节,SHA-256 `6d40dc00d3457b223850f66cfa5a3ed0f2c42588d9e2042037607147ec7e3053`) |
19
+ | GitHub Actions(push 到 `main`,ubuntu-latest / Node 24) | 成功(typecheck + 全量测试 + 三个 smoke + `npm pack --dry-run`) |
20
+ | 独立审查 | `AUDIT_PASS`(AC1–AC7 全 PASS;AC8 的发布门在提交后补跑通过)。审查只读,未改动候选 |
21
+ | npm | `dsh-kingdom@3.2.0` 已发布(`latest`),shasum `0bc97ac894519774a4aaf773bdc343a6aa74a946`;从 registry 取回的 tgz 与上面冻结的 tgz **字节一致** |
22
+
23
+ **未执行(NOT_RUN)**:真实浏览器人工交互验收(新表单的"解除现任主理"勾选框与四种主题/窄屏下的
24
+ 实际观感;交付区版面只有"同源 CSS + 同构 DOM"的等效渲染对比);把本版 tgz 装进本机 profile 后的
25
+ DSH `0.1.7-rc.2` 实机加载回归;Linux 之外平台的产品行为;收益与成本测量;Owner 正式验收。
26
+ 未运行过的项不得当作已通过。
27
+
28
+ ## 1. 主管与领地完全绑定
29
+
30
+ **问题**:此前"任命主管"与"指定它的领地"是两条独立命令。中途放弃第二步,就会留下一个没有
31
+ 领地的在任主管:席位显示已任命,GUI 里那块领地却仍显示"没有主管",而治理操作按
32
+ `TERRITORY_SUPERVISOR_MISSING` fail-closed。
33
+
34
+ **现在的规则**(Owner 口径 2026-09-28 + 1:1 裁定 2026-09-29):
35
+
36
+ - **任命主管必须同时给出领地**:`role.bind` 的 `role_type=SUPERVISOR` 需要 `territory_id`;
37
+ 缺省即拒绝,不写入任何事实。非主管角色携带 `territory_id` 同样拒绝(不静默忽略)。
38
+ - **同一事务原子写入**:席位与「该领地的主理」一起提交或一起回滚。领地侧被拒时,已经插入的
39
+ 席位随事务回滚,**不会**留下"有主管、没领地"的中间态。
40
+ - **不静默替换在任主理**:目标领地已有**在任 ACTIVE** 主理时,改派被拒绝并提示先解除;
41
+ 解除(`supervisor_binding_id: null`)后再指派。指向**已退任席位**的历史悬挂引用属于修复,
42
+ 可以直接改派(direct Slash 与 Owner GUI 窗口是同一条判据)。
43
+ - **一个主管席位只主理一个领地(1:1)**:席位已隶属其它未删除领地时,指派被拒绝并提示先解除
44
+ 原领地的主理。要在领地之间移动一个主管,必须"先解除原领地,再指派到新领地"。
45
+ 已被删除(tombstone)的领地不参与治理,其历史指针不锁住席位。
46
+ - **退任联动**:`role.unbind` 退任一个主管时,**同一事务**里解除它在领地上的主理关系并写出一条
47
+ 明确的解除事实(`TERRITORY_SUPERVISOR_UPDATED`,`unassigned: true`),不留下指向 RETIRED
48
+ 席位的悬挂引用。
49
+ - **存量未隶属席位**:3.2.0 之前建立的、没有领地的在任主管**不会被自动归属**。席位投影会给出
50
+ 它实际主理的领地列表,为空时由界面标注「未隶属领地(待处理)」,由 Owner 决定指派或解除。
51
+ 这类遗留席位同样受 1:1 约束(已经主理某领地的席位不会被自动改派)。
52
+
53
+ 命令与界面:
54
+
55
+ ```
56
+ /kingdom role.bind {"role_type":"SUPERVISOR","role_name":"主理","territory_id":"<领地 id>","session_id":"<真实 DSH session>"}
57
+ /kingdom role.bind {"role_type":"WORKER","role_name":"执行者"} # 非主管不带 territory_id
58
+ /kingdom territory.supervisor {"territory_id":"...","supervisor_binding_id":null} # 解除该领地主理
59
+ /kingdom role.unbind {"binding_id":"<主管 binding id>","reason":"换届"} # 同时解除其领地主理
60
+ ```
61
+
62
+ Owner GUI 管理窗口的 `role.bind` 表单在角色为主管时出现**必选**领地下拉(内容来自本次授权范围
63
+ 目录),其它角色隐藏该字段;`kingdom_draft_owner_binding_intent` 的自然语言草案对主管改为
64
+ **单步**可执行命令(`role.bind` 自带 `territory_id`),不再是旧的两步。
65
+
66
+ **领地范围判据**:主管任命的目标领地必须**显式**出现在本次管理窗口的授权范围内
67
+ (`catalog.territories` 与提交判定完全一致,不用 `kingdomWide` 放宽)。若要在窗口里给新建的
68
+ 领地任命主管,请重新激活一个包含该领地的窗口。
69
+
70
+ **管理窗口现在也能解除主理**:`territory.supervisor` 的 `supervisor_binding_id` 接受**显式 `null`**
71
+ (表单上是"解除现任主理(不指派新主管)"勾选框;省略该字段仍是输入错误,不会被当成解除)。
72
+ 因此 1:1 之下"把主理从领地 A 换到领地 B"可以完全在窗口里完成:先在 A 上解除、再到 B 上指派。
73
+ 解除同样受未结算执行守卫约束(有在途工作时不允许解除责任),预览会写清"解除后该领地无主理 →
74
+ fail-closed 直到指派新的"。
75
+
76
+ **存量数据的说明**:3.2.0 之前可能留下两类与上面规则不符的存量——① 没有隶属领地的在任主管席位;
77
+ ② 一个席位同时挂在多个领地(旧能力允许)。本版**不会自动收敛**它们:① 只作可见标注,② 只约束
78
+ **新的**指派。两者在退任时都会被一并解除(退任联动遍历该席位名下的所有未删除领地)。
79
+
80
+ **事务嵌套**:直接 Slash 的 `ownerWrite` 与 Owner GUI 窗口的 `apply` 各自已经开了一层事务,
81
+ 而这条原子通道还要求自己的边界。`KingdomStore.withImmediateTransaction` 因此改为真正的嵌套:
82
+ 外层 `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK`,内层 `SAVEPOINT`/`RELEASE`/`ROLLBACK TO`。
83
+ 只做"加入外层"是不够的——内层把拒绝变成返回文案时,它已写下的行会留在外层事务里被提交。
84
+ `schema`、表结构与事件类型均未改动。
85
+
86
+ **边界**:`bindRole()` 仍是低层原语,本身不承载"主管必须有领地"这条策略;策略在全部 Owner 入口
87
+ (direct Slash、Owner GUI 窗口、GUI 表单)强制执行,并统一由 `bindSupervisorForTerritory` 完成
88
+ 原子写入。这样既保证产品层面不存在"有主管、没领地"的创建路径,又不改动只用于构造测试夹具的
89
+ 低层调用。Agent Tool(`kingdom_bind_role` 等)依旧是 zero-write。
90
+
91
+ **未结算执行的拦截范围(3.2.0 审计 r1 更正)**:`UNSETTLED_EXECUTION` / `UNSETTLED_LEASE` /
92
+ `UNSETTLED_DISPATCH` 守卫只覆盖 **Owner 管理窗口**这条路径(`guardUnsettled`)。direct Slash 与
93
+ core 的 `role.bind` / `territory.supervisor` **没有**等价守卫——这是 3.1.0 的既有语义,本版没有
94
+ 改动它,文档也不得声称"旧主管的未结算执行一定会拦住改派"。需要该守卫时请走 Owner 管理窗口。
95
+
96
+ ## 2. 交付区版面修复
97
+
98
+ 交付条目的控制区里有一条 `flex: 1 0 100%` 的状态文本,而它在 `grid-template-columns:
99
+ minmax(0, 1fr) auto` 的右列(`auto` = max-content)里。右轨被撑到最宽,左轨被压成 0 宽,
100
+ 正文因此退化成每行一两个字、竖排显示。现在改成分行 flex:正文占弹性主列(20rem 起),
101
+ 控制区按自身内容窄排,放不下时整体换行。这是纯 CSS 改动,不改变任何数据、事件或判断。
102
+ 交付条目里显示的 `UNKNOWN` 是**数据**(v1.0.0 旧格式的弱接受证据),产品按设计不推断。
103
+
104
+ ## 不适用范围
105
+
106
+ - 不改 schema,不新增迁移;不改 Owner Control Plane 的授权模型。
107
+ - 不改 `resolveTrustedToolSession`,不改 3.1.0 的 peer 分线声明。
108
+ - 不处理 3.1.0 审计遗留的文档残留(另行处理)。
@@ -0,0 +1,39 @@
1
+ # dsh-Kingdom 3.2.1
2
+
3
+ 3.2.1 修复派发、恢复和授权事务中的正确性缺口。它在已发布的3.2.0上合入有限整改,保留主管与领地完全绑定、交付记录、schema v4和既有DSH兼容范围。没有数据库迁移。
4
+
5
+ ## 本版变化
6
+
7
+ - **派发前保存关联。** 每次派发先生成Kingdom ID,并可由Adapter纯准备一个独立Runtime消息引用。在Runtime入队前,Execution、Lease、Dispatch Intent和计划引用已提交。计划引用不是接受回执。
8
+ - **可靠Receipt经过持久化屏障。** DSH prepared消息和单次token绑定Adapter、Session、文本与两端引用。只有公开` sessions.flush `返回true才返回可靠Receipt。缺失、false、异常或超时不会触发重发;同一token不能再次入队。Receipt必须匹配已提交的Runtime/instance/Session/ref。
9
+ - **丢回执或原上下文后可只读对账。** 当前主管可通过Tool或GUI查询同一计划引用。新Adapter只读live或公开持久化事件,不resume、不补Receipt、不建执行权限、不生成Claim、不清理或释放Lease。读取最多10000事件,每次持久化操作最多5000ms;读取前后重验授权及关键账本关系。
10
+ - **收紧事务授权。** 受理及派发事务内重新验证原调用者;ACCEPT重新确认主管绑定和Task领地,使用条件状态更新,变化时整体拒绝或回滚。3.2的交付改动证据与SAVEPOINT嵌套事务保持有效。
11
+ - **明确资源占用。** 占用提示提供有界、脱敏的执行/租约引用,并要求前序结束或对账后重新请求;没有自动排队。
12
+ - **计划引用不计作已接受执行。** 用量入口和事务内持久化都要求精确引用及Receipt。稀疏或损坏事件数组按不可读处理。
13
+
14
+ ## 验证范围
15
+
16
+ 本版新基线在Windows/Node24下执行:
17
+
18
+ | 验证 | 结果 |
19
+ |---|---|
20
+ | TypeScript typecheck和当前源码构建 | 通过 |
21
+ | 完整TS测试与新增探针契约测试 | 665项:663通过、0失败、2跳过(平台条件) |
22
+ | 隔离governance / GUI / lifecycle smoke | 36 / 19 / 26项全部通过 |
23
+ | 官方0.1.5-rc.2实际组件产品探针 | 两例通过:flush后强杀再读为QUEUED;COMMIT后未发送再读为UNKNOWN |
24
+ | 官方0.1.7-rc.2实际组件产品探针 | 同上两例通过 |
25
+ | 两条线的产品探针数据不变断言 | Core行/事件不变,Receipt缺失,Claim0,模型turn0 |
26
+
27
+ 真实探针经过本版Adapter准备、Core TX-3、DSH入队/flush、owned writer强杀和fresh reader公开只读读取。Authority/Binding/Capability/enforcement前置使用隔离fake fixture,未加载Provider;它证明消息关联与持久化读取,不是完整受治理模型执行。Requested model为null,effective model为UNKNOWN。更多API与证据边界见[探针说明](DISPATCH-IDENTITY-PROBE.md)。
28
+
29
+ 本版还覆盖真实子进程提交边界与两个进程竞争同Session Lease;这些SQLite测试的Runtime为明确fake,不把它们当作DSH执行证明。首次全量检查发现的两处旧fixture缺flush和稀疏数组回归已修复,最终结果如上。
30
+
31
+ ## 升级与保留边界
32
+
33
+ 安装准确的`dsh-kingdom-3.2.1.tgz`或从官方npm获取`dsh-kingdom@3.2.1`。升级前核对未决执行并一致备份SQLite;本版不对正式数据库进行自动迁移或作废旧执行。
34
+
35
+ 找到精确输入是正观察;**找不到输入仍为UNKNOWN,不能证明未派发、已停机或可安全重试**。排队输入可能在未来resume时执行。本版没有安全作废按钮、冷启动Fence/cleanup重建、子目录WRITE并发、PostgreSQL或SSE。
36
+
37
+ 0.1.7的公开snapshot兼容读取已deprecated;本版既有兼容面仍使用该API,持久化冷读使用公开handle。未来Runtime版本需要单独验证,不能外推。完整付费Provider、真人GUI验收、任意旧库迁移和生产性能没有在本次发布中执行。
38
+
39
+ GitHub Release与npm的公开版本、资产及integrity是发布状态的核对入口;打包/CI不等于Owner最终验收或Kingdom Task DONE。
@@ -14,7 +14,7 @@
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { cleanupDshEnforcement, materializeDshEnforcement, readEnforceableSet, } from '../capability/dsh-enforcement.js';
16
16
  import { terminalOutcomeOf } from '../dispatch/evidence.js';
17
- import { locateDshDispatchRef, readDshSessionEvents } from './dsh-session-events.js';
17
+ import { DSH_RECONCILE_EVENT_LIMIT, locateDshDispatchRef, readBoundedDshSessionEvents, readDshSessionEvents, } from './dsh-session-events.js';
18
18
  /** Deliberately not exported: only this backend can issue a fence token. */
19
19
  class DshTrustFenceToken {
20
20
  // Runtime identity is checked through an Adapter-instance-private WeakMap;
@@ -48,6 +48,28 @@ function boundedFenceReason(value) {
48
48
  function fenceResult(ok, status, reservation, generation, reason) {
49
49
  return { ok, status, reservation, generation, reason: boundedFenceReason(reason) };
50
50
  }
51
+ const DEFAULT_DSH_IO_TIMEOUT_MS = 5000;
52
+ /** Timeout never cancels/resends a Runtime operation; its outcome stays uncertain. */
53
+ async function boundedWait(operation, milliseconds, code) {
54
+ let timer;
55
+ try {
56
+ return await Promise.race([
57
+ operation,
58
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(code)), milliseconds); }),
59
+ ]);
60
+ }
61
+ finally {
62
+ if (timer !== undefined)
63
+ clearTimeout(timer);
64
+ }
65
+ }
66
+ function freezeUserMessage(text) {
67
+ const message = buildUserMessage(text);
68
+ Object.freeze(message.content[0]);
69
+ Object.freeze(message.content);
70
+ Object.freeze(message.source);
71
+ return Object.freeze(message);
72
+ }
51
73
  function asAgents(value) {
52
74
  return value;
53
75
  }
@@ -120,6 +142,8 @@ export class DshRuntimeAdapter {
120
142
  runtimeType = 'dsh';
121
143
  /** Fence tokens are accepted only by the exact Adapter instance that issued them. */
122
144
  trustFenceStates = new WeakMap();
145
+ /** The exposed ref cannot replace the original single-use preparation token. */
146
+ preparedDispatches = new WeakMap();
123
147
  deps;
124
148
  constructor(deps) {
125
149
  this.deps = {
@@ -128,6 +152,10 @@ export class DshRuntimeAdapter {
128
152
  model: deps.model ?? null,
129
153
  agents: asAgents(deps.agents),
130
154
  sessionPersistence: asPersistence(deps.sessionPersistence),
155
+ sessions: deps.sessions,
156
+ dispatchFlushTimeoutMs: Number.isFinite(deps.dispatchFlushTimeoutMs) && (deps.dispatchFlushTimeoutMs ?? 0) > 0
157
+ ? Math.min(deps.dispatchFlushTimeoutMs, DEFAULT_DSH_IO_TIMEOUT_MS)
158
+ : DEFAULT_DSH_IO_TIMEOUT_MS,
131
159
  presets: asPresets(deps.presets),
132
160
  policy: {
133
161
  toolDisclosure: deps.toolDisclosure,
@@ -535,12 +563,80 @@ export class DshRuntimeAdapter {
535
563
  return materializeDshEnforcement(this.deps.policy, ctx, request);
536
564
  }
537
565
  // ── Dispatch / Evidence / Reconcile ────────────────────────────────────────
566
+ async prepareDispatch(input) {
567
+ if (!input.kingdomDispatchId.trim() || !input.sessionRef.trim())
568
+ throw new Error('dsh prepare: DISPATCH_IDENTITY_REQUIRED');
569
+ const agent = this.deps.agents.get(input.sessionRef);
570
+ if (!agent || agent.id !== input.sessionRef)
571
+ throw new Error('dsh prepare: EXACT_LIVE_SESSION_REQUIRED');
572
+ const message = freezeUserMessage(input.text);
573
+ const baseline = readDshSessionEvents(agent.session);
574
+ if (baseline === null)
575
+ throw new Error('dsh prepare: BASELINE_UNREADABLE');
576
+ if (locateDshDispatchRef(baseline, message.id) >= 0)
577
+ throw new Error('dsh prepare: RUNTIME_REF_ALREADY_PRESENT');
578
+ const token = Object.freeze({ runtimeDispatchRef: message.id });
579
+ this.preparedDispatches.set(token, {
580
+ kingdomDispatchId: input.kingdomDispatchId,
581
+ sessionRef: input.sessionRef,
582
+ text: input.text,
583
+ message,
584
+ agent,
585
+ session: agent.session,
586
+ used: false,
587
+ });
588
+ return token;
589
+ }
538
590
  async dispatch(input) {
539
591
  const agent = this.deps.agents.get(input.sessionRef);
540
592
  if (!agent)
541
593
  throw new Error(`dsh dispatch: session ${input.sessionRef} 不在 live registry(无法 dispatch)`);
542
- const message = buildUserMessage(input.text);
543
- agent.followup(message); // 持久 splice 插入即「已接受」
594
+ let message;
595
+ if (input.preparedDispatch !== undefined) {
596
+ const prepared = input.preparedDispatch !== null && typeof input.preparedDispatch === 'object'
597
+ ? this.preparedDispatches.get(input.preparedDispatch)
598
+ : undefined;
599
+ if (!prepared)
600
+ throw new Error('dsh dispatch: UNKNOWN_PREPARED_TOKEN');
601
+ if (prepared.used)
602
+ throw new Error('dsh dispatch: PREPARED_TOKEN_ALREADY_USED');
603
+ if (prepared.kingdomDispatchId !== input.kingdomDispatchId || prepared.sessionRef !== input.sessionRef
604
+ || prepared.text !== input.text || prepared.agent !== agent || prepared.session !== agent.session
605
+ || agent.id !== input.sessionRef || prepared.message.id !== input.preparedDispatch.runtimeDispatchRef) {
606
+ throw new Error('dsh dispatch: PREPARED_IDENTITY_MISMATCH');
607
+ }
608
+ if (typeof this.deps.sessions?.flush !== 'function')
609
+ throw new Error('dsh dispatch: DURABILITY_BARRIER_UNAVAILABLE');
610
+ const baseline = readDshSessionEvents(agent.session);
611
+ if (baseline === null)
612
+ throw new Error('dsh dispatch: BASELINE_UNREADABLE');
613
+ if (locateDshDispatchRef(baseline, prepared.message.id) >= 0)
614
+ throw new Error('dsh dispatch: RUNTIME_REF_ALREADY_PRESENT');
615
+ // Consume before any side effect. A thrown followup or uncertain barrier
616
+ // must never make this token usable for an automatic second send.
617
+ prepared.used = true;
618
+ message = prepared.message;
619
+ agent.followup(message);
620
+ let durable;
621
+ try {
622
+ durable = await boundedWait(Promise.resolve(this.deps.sessions.flush(agent.session)), this.deps.dispatchFlushTimeoutMs, 'dsh dispatch: DURABILITY_BARRIER_TIMEOUT');
623
+ }
624
+ catch (error) {
625
+ throw new Error(error instanceof Error && error.message === 'dsh dispatch: DURABILITY_BARRIER_TIMEOUT'
626
+ ? 'dsh dispatch: DURABILITY_BARRIER_TIMEOUT'
627
+ : 'dsh dispatch: DURABILITY_BARRIER_UNCERTAIN');
628
+ }
629
+ if (durable !== true)
630
+ throw new Error('dsh dispatch: DURABILITY_BARRIER_NOT_CONFIRMED');
631
+ }
632
+ else {
633
+ // Explicit compatibility seam for historical direct Adapter consumers;
634
+ // correlated governed dispatch cannot fall back to this non-durable path.
635
+ if (input.kingdomDispatchId !== undefined)
636
+ throw new Error('dsh dispatch: PREPARED_TOKEN_REQUIRED');
637
+ message = buildUserMessage(input.text);
638
+ agent.followup(message);
639
+ }
544
640
  return {
545
641
  refs: {
546
642
  runtimeType: this.runtimeType,
@@ -557,23 +653,105 @@ export class DshRuntimeAdapter {
557
653
  return 'UNKNOWN';
558
654
  return reconstructExecutionObservation(live, refs.runtimeDispatchRef);
559
655
  }
656
+ async persistedEvents(sessionRef) {
657
+ const persistence = this.deps.sessionPersistence;
658
+ if (typeof persistence?.open !== 'function')
659
+ return { events: [], complete: false, reason: 'UNREADABLE' };
660
+ let reader;
661
+ let openTimedOut = false;
662
+ let result = { events: [], complete: false, reason: 'UNREADABLE' };
663
+ try {
664
+ const opening = Promise.resolve(persistence.open(sessionRef, 'read'));
665
+ // If open finishes after our deadline, release only its read handle.
666
+ void opening.then(handle => {
667
+ if (openTimedOut)
668
+ void Promise.resolve().then(() => handle.close()).catch(() => undefined);
669
+ }, () => undefined);
670
+ try {
671
+ reader = await boundedWait(opening, DEFAULT_DSH_IO_TIMEOUT_MS, 'PERSISTENCE_OPEN_TIMEOUT');
672
+ }
673
+ catch {
674
+ openTimedOut = true;
675
+ return result;
676
+ }
677
+ if (reader.id !== sessionRef || reader.access !== 'read' || typeof reader.read !== 'function' || typeof reader.close !== 'function')
678
+ return result;
679
+ const slice = await boundedWait(Promise.resolve(reader.read(0, DSH_RECONCILE_EVENT_LIMIT)), DEFAULT_DSH_IO_TIMEOUT_MS, 'PERSISTENCE_READ_TIMEOUT');
680
+ // At the exact limit we cannot know whether the log has a later tail.
681
+ result = readBoundedDshSessionEvents({ events: slice.events });
682
+ if (result.events.length === DSH_RECONCILE_EVENT_LIMIT)
683
+ result = { ...result, complete: false, reason: 'EVENT_LIMIT' };
684
+ }
685
+ catch {
686
+ result = { events: [], complete: false, reason: 'UNREADABLE' };
687
+ }
688
+ finally {
689
+ if (reader && typeof reader.close === 'function') {
690
+ try {
691
+ await boundedWait(Promise.resolve(reader.close()), DEFAULT_DSH_IO_TIMEOUT_MS, 'PERSISTENCE_CLOSE_TIMEOUT');
692
+ }
693
+ catch {
694
+ result = { events: [], complete: false, reason: 'UNREADABLE' };
695
+ }
696
+ }
697
+ }
698
+ return result;
699
+ }
560
700
  async reconcile(kingdomDispatchId, refs) {
561
- // session 维:live registry 或持久可恢复 → AVAILABLE;否则 UNKNOWN(GONE 需显式删除证据,S5 细化)
562
701
  const live = this.deps.agents.get(refs.sessionRef);
563
702
  let sessionObservation = 'UNKNOWN';
564
- if (live)
565
- sessionObservation = 'AVAILABLE';
566
- else if (this.deps.sessionPersistence?.has?.(refs.sessionRef) === true)
703
+ let source = 'NONE';
704
+ let projection = { events: [], complete: false, reason: 'UNREADABLE' };
705
+ if (live?.id === refs.sessionRef) {
567
706
  sessionObservation = 'AVAILABLE';
568
- // execution 维:live 会话事件链;不可判定 → UNKNOWN(fail-closed,不盲发、不 ABORT)
707
+ source = 'LIVE';
708
+ projection = readBoundedDshSessionEvents(live.session);
709
+ }
710
+ else if (typeof this.deps.sessionPersistence?.open === 'function') {
711
+ source = 'PERSISTED';
712
+ projection = await this.persistedEvents(refs.sessionRef);
713
+ if (projection.complete || projection.reason === 'EVENT_LIMIT')
714
+ sessionObservation = 'AVAILABLE';
715
+ }
716
+ if (sessionObservation === 'UNKNOWN') {
717
+ try {
718
+ // Presence metadata never establishes a dispatch or its terminal.
719
+ const persistence = this.deps.sessionPersistence;
720
+ const present = typeof persistence?.stat === 'function'
721
+ ? await boundedWait(Promise.resolve(persistence.stat(refs.sessionRef)), DEFAULT_DSH_IO_TIMEOUT_MS, 'PERSISTENCE_STAT_TIMEOUT')
722
+ : persistence?.has?.(refs.sessionRef) === true;
723
+ if (present)
724
+ sessionObservation = 'AVAILABLE';
725
+ }
726
+ catch { /* unknown presence stays unknown */ }
727
+ }
728
+ const located = Boolean(refs.runtimeDispatchRef && locateDshDispatchRef(projection.events, refs.runtimeDispatchRef) >= 0);
569
729
  let executionObservation = 'UNKNOWN';
570
- if (live && refs.runtimeDispatchRef) {
571
- executionObservation = reconstructExecutionObservation(live.session, refs.runtimeDispatchRef);
730
+ if (projection.complete && located && refs.runtimeDispatchRef) {
731
+ const start = locateDshDispatchRef(projection.events, refs.runtimeDispatchRef);
732
+ const foreign = projection.events.slice(start).some(event => {
733
+ if (event.type === 'user/message')
734
+ return eventMessageId(event) !== refs.runtimeDispatchRef;
735
+ return event.type === 'agent/inbox/spliced' && Array.isArray(event.data?.inserted)
736
+ && event.data.inserted.some(item => item === null || typeof item !== 'object' || item.id !== refs.runtimeDispatchRef);
737
+ });
738
+ if (!foreign)
739
+ executionObservation = reconstructExecutionObservation({ header: {}, events: projection.events }, refs.runtimeDispatchRef);
572
740
  }
573
741
  return {
574
742
  executionObservation,
575
743
  sessionObservation,
576
- evidence: { kingdomDispatchId, runtimeDispatchRef: refs.runtimeDispatchRef ?? null, observedAt: new Date().toISOString() },
744
+ evidence: {
745
+ kingdomDispatchId,
746
+ runtimeDispatchRef: refs.runtimeDispatchRef ?? null,
747
+ source,
748
+ located,
749
+ eventsRead: projection.events.length,
750
+ complete: projection.complete,
751
+ reason: projection.reason !== 'READABLE' ? projection.reason === 'EVENT_LIMIT' ? 'EVENT_LIMIT' : 'OBSERVATION_UNREADABLE'
752
+ : located ? 'EXACT_INPUT_FOUND' : 'INPUT_NOT_LOCATED',
753
+ observedAt: new Date().toISOString(),
754
+ },
577
755
  };
578
756
  }
579
757
  }