@whaletalk/dsh-codex-collab 0.1.7 → 0.1.10

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 (32) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +197 -26
  3. package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
  4. package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
  5. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
  6. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
  7. package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
  8. package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
  9. package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
  10. package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
  11. package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
  12. package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
  13. package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
  14. package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
  15. package/docs/DSH_TO_CODEX_INBOX.md +167 -0
  16. package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
  17. package/docs/codex-desktop-attachment-evidence.md +116 -0
  18. package/harness/audit-tail.mjs +32 -0
  19. package/harness/codex-approvals.mjs +1078 -0
  20. package/harness/codex-associations.mjs +297 -0
  21. package/harness/codex-outbox.mjs +432 -0
  22. package/harness/dsh-bridge.mjs +2429 -167
  23. package/harness/fs-lock.mjs +208 -0
  24. package/harness/model-route-host.mjs +530 -0
  25. package/harness/model-routing.mjs +500 -0
  26. package/harness/session-observer.mjs +217 -0
  27. package/harness/session-target.mjs +291 -2
  28. package/harness/task-table.mjs +34 -0
  29. package/mcp/codex-desktop-probe.mjs +407 -0
  30. package/mcp/codex-inbox.mjs +271 -0
  31. package/mcp/dsh-mcp.mjs +616 -24
  32. package/package.json +4 -2
@@ -0,0 +1,75 @@
1
+ # DSH 插件动态模型选择与有界故障切换
2
+
3
+ 状态:已获用户授权的实施计划。2026-10-16,用户要求读取其已配置模型、优先 DeepSeek、按任务选择,模型失效时自行切换;并明确选择“已有普通对话中的插件任务也启用”。原 DSH 项目对话实施,Root 独立验收。
4
+
5
+ ## 目标与范围
6
+
7
+ 覆盖插件管理的编码/评审子代理,以及明确来源绑定的普通会话 queue 任务。queue 各自拥有模型策略;steer 回执沿用父任务策略,不另建策略、审批或结果。人工后续回合、其他任务、其他 session、全局默认模型/preset 不被接管。保存原有182项审批/投递回归与本地README修改。
8
+
9
+ 模型候选只能来自用户当前运行时已注册 provider 的目录,provider/model 组合是唯一身份。优先选择兼容的 DeepSeek,再用其它已配置兼容模型;没有 DeepSeek 时仍可工作。允许用户明确选择模型或关闭本任务故障切换。不读、复制或展示密钥/登录配置,不添加 provider、凭据、模型套餐或依赖。
10
+
11
+ 冻结基线:`E:/07-Workspace_codex/.tmp/dsh-codex-install/model-routing-baseline-ygW13z/baseline.json`(51文件,HEAD383528c,前182项已验收,均未提交)。任何恢复都不能直接拿HEAD覆盖该基线。
12
+
13
+ ## 已核实的官方接口与限制
14
+
15
+ 以本机官方 `D:/Program Files (x86)/DSH/resources/app.asar` SDK0.2.0-rc.2为准。Root只读笔记:`E:/07-Workspace_codex/.tmp/dsh-codex-install/model-routing-sdk-notes.md`。
16
+
17
+ - `llm.listProviders()` + `listModels(provider)` 枚举实际注册路由与其模型;`resolveModelInfo/resolveCallConfig` 提供能力/effort检查。不存在getModelsList。目录/能力解析成功不等于凭据、额度或远端健康已验证。
18
+ - 元数据不提供质量/速度/价格评分。`toolUpdate`表示工具历史投影规则,不可当作“支持工具调用”的布尔字段。未知能力诚实标未知;任务适配按真实模态、context、effort及可解释偏好,不制造SDK评分。
19
+ - `agent/request` 每个attempt都执行,能够按任务覆盖本次请求的provider/model。`agent/request-error`仅对结构化模型请求失败决定同turn/step retry。失败attempt的工具尚未执行,前序step工具保留,不能重派prompt、另建child、cancel/resume整任务或从头跑工具来冒充故障恢复。
20
+ - 首次request发生在user/message写入前,不能只凭事后日志rpc绑定。官方 `agent/inbox/claimed` 提供真实agent/message/turn,需在派prompt之前监听,并用已登记queue requestId严格认领首次请求。回执不成为owner;worker亦须在首次请求之前绑定可信child/owner身份。
21
+ - `sessionController.selectModel`会触发全局default保存,而且retry不重新assemble;不得用它作为本次任务无副作用切换,也不得写readonly options或配置。
22
+ - 实际route覆盖会写官方request/header;后续请求proposal/缺省selection可能继承该header。所以完成时仅删map/卸hook仍会泄漏fallback模型到人工下一回合。须保留用户选择意图并通过受支持的请求层/选择投影做条件恢复;期间人类明确新选模型必须优先,不调用selectModel/saveSelection。此项必须真实SDK验证,不能以mock options没变代替。
23
+ - prepareCall部分异常在request-error之外;必须在请求阶段预检,有准备期竞态无法恢复时明确报告边界,不能通过重派任务掩饰。
24
+ - 原生retry策略有normal与always。自己的路由耗尽不能交给always继续无限重试;需实际验证listener顺序、单模型次数、模型数与总时限,以及abort/unload。
25
+
26
+ ## 对外契约
27
+
28
+ 1. 默认 `model:auto`(未指定的新插件任务同样auto);`fast/pro`兼容为偏好,摆脱固定DeepSeek型号。保留显式 `default` 继承当前选择的兼容意图。支持精确 `provider` + `model`,未给provider但存在同名模型歧义时明确拒绝并给候选,不静默选错。
29
+ 2. `modelFallback` 可显式关闭。默认auto/fast/pro开启;精确指定模型默认固定,调用方显式开启才允许故障降级。所有选择仍须在已配置目录和任务能力约束内。
30
+ 3. 新增只读 `GET /api/dsh-bridge/models`、MCP `dsh_models`、CLI `--models`,提供候选pair、受支持能力/未知项、选择理由、已知临时失效及有效时间;不显示凭据、认证URL、endpoint正文。工具数由8变9是本次有意变更,CI握手与文档同步。
31
+ 4. 任务派活/查询回显实际选择、选择模式、失败/切换次数与限制。提供 `capabilities.modelRouting`(真实scope、版本、发现/首请求/运行中恢复能力);新客户端使用新参数前只读预检,旧网关不得忽略新字段后照常执行。旧调用维持清楚的兼容行为,普通无来源session仍不获得跨任务自动控制。
32
+ 5. 模型选择/切换仅覆盖本任务发出的请求配置,不持久修改会话手选模型。source/task/session/parent绑定、唯一审批owner、never、队列FIFO与取消保护不变。已有会话首次请求、后继步骤、同回合重试、下一队列任务、人工下一回合均须分开验证。
33
+
34
+ ## 选择、失败与恢复
35
+
36
+ - 在任务开始取新鲜配置快照。provider目录失败隔离;有界缓存/拓扑更新失效,不能永久缓存null。不为了“健康检测”额外付费请求所有模型。
37
+ - 策略先满足输入模态、上下文/输出预算、effort兼容性和用户显式选择,再按DeepSeek偏好、任务类型与可解释的fast/pro等名称提示排序。不能把提示当质量或价格保证;保留adapter自身顺序作为稳定回退。
38
+ - 路由必须同时传provider/model,不能沿用当前provider却只替换model名。跨模型时不得带入不支持的effort/maxTokens,不得删图片、丢历史或重复工具来凑兼容。
39
+ - 用官方结构化failure.code/status分类。认证/凭据缺失、额度、确定模型失效等可直接选下一兼容候选;限流/服务端/传输/超时按有界策略并尊重retry-after。拒答/安全拦截、用户取消、审批拒绝、工具错误、未知插件异常不靠切模型绕过。HTTP404只说明路由不可用,不能断言一定是模型下架。
40
+ - 默认最多3个不同候选(含首次),每个候选/整个任务恢复有次数和时间上限;不绕圈重选已失败候选。临时失败可有有界冷却,仅影响相应pair/实际失败provider,不能永久封禁所有模型或跨profile污染。
41
+ - 相同taskId、原source RPC、同session、同turn/step重试。保留历史与已完成工具结果;失败流的部分tool-call不执行/不拼成成功消息。备选耗尽如实error/明确原因,不能done加空输出,也不能以观察unknown当模型故障触发重发。
42
+ - 编码子代理的模型切换不清childId或另起child冒充继续;复用/冷恢复也必须证明首次请求绑定。原有cold-resume的one-shot兜底不能拿来处理模型故障或不确定的已接受任务。
43
+ - 生命周期在完成/错误/取消/卸载时准确收口。已在途异步返回不得复活策略;重启只恢复审计事实,不重派任务或假恢复执行。
44
+
45
+ ## 日志与回溯
46
+
47
+ 本任务范围内补结构化模型路由审计(独立JSONL或现有关联记录的最小扩展),字段包括时间、origin/task/session/父关系、turn/step、旧/新provider+model、选择理由/稳定错误码、序号、冷却与耗尽结果。任务查询及重启后可检索非秘密的最后选择/切换摘要。原始failure.message、prompt、工具参数、key、认证URL不得作为模型路由日志输出。不得只console打印便宣称跨重启可回溯。不给已完成审批记录增加原始命令内容。
48
+
49
+ ## 实施阶段与交付
50
+
51
+ M0:原DSH核验上述接口,尤其首request归属、worker首请求预绑定、原生retry层顺序,并写 `docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md`。用真实SDK组件及可控离线adapter证明可行,不冒充远端模型在线健康测试。证据支持即继续,不等人工确认;若无法满足安全恢复/精确归属则反馈具体缺口,不搭大框架。
52
+
53
+ M1:最小路由决策/目录模块,保留可解释排序与有界健康状态。
54
+
55
+ M2:真实宿主hook与worker/session/queue/receipt接线、MCP/CLI/HTTP模型发现/输入/响应/能力预检、持久日志;同步README/SKILL/开发文档与CI9工具契约。不碰Rootcanonical计划。
56
+
57
+ M3:自测交付真实diff、完整回归、接口/负例与离线官方SDK组件证明。所有模型故障测试用隔离假adapter,不拿用户付费模型逐个探活,不读登录凭据。
58
+
59
+ M4:Root独立审查和反例、完整回归、打包/干净安装;仅在新建的独立临时官方DSH profile挂最终包,用有结构化失败的本地测试adapter验证模型切换与唯一nonce效果,确认真实AgentLoop/日志;若必要单次真实已配置模型冒烟,先保证隔离且严格有界,不重启现有客户端。验证后停自建进程,核验绝对路径后清理自建profile/key,保留证据。最终才报告通过。
60
+
61
+ ## 验收矩阵
62
+
63
+ - 多provider/同名模型/只有非DeepSeek/DeepSeek不可用/目录返回空或部分失败/配置变更/缓存过期;目录列出但不可调用不误报健康。
64
+ - auto/fast/pro/default/精确pair/禁fallback;meta未知、模态/effort/context不兼容;编码/评审的选择依据如实可读。
65
+ - 首request实际使用所选pair,先执行一个nonce工具后下一step模型失败,原turn/step内换模型恢复,nonce恰好1次;失败attempt流出的tool-call delta不执行。
66
+ - 已有普通session中A运行/B排队/S补充,各自策略准确;S沿父;A故障不改B策略,A收口不清B策略/审批;人工后继回合和另一并发session不被接管,全局default保存次数0。
67
+ - 可恢复与不可恢复错误区分、信号取消、所有候选失败、SDK always重试、生命周期卸载以及准备期竞态均有界,任务不误报done、不重派prompt、不丢原历史。
68
+ - CLI/MCP真进程+真HTTP:目录、策略输入、旧网关预检零执行、来源严格回显、重启回溯model审计;原审批和queue/steer182项保护继续通过。
69
+ - 最终pack含新模块/文档;9工具握手/版本号一致;最终包真实SDK测试profile的request/header pair变化、原RPC/turn关联和nonce文件直接作证,而非只拿测试计数验收。
70
+
71
+ ## 边界
72
+
73
+ 用户追加的交付要求(2026-10-16):README首页须显式标注fork自 `FuJackGao/dsh-collab` 并链接上游;保留“项目来源与鸣谢”章节,感谢FuJackGao及上游贡献者,清楚区分原项目与Whale-Talk fork的后续扩展。Root已按GitHub实际parent核实并写入README(证据 `E:/07-Workspace_codex/.tmp/dsh-codex-install/readme-upstream-credit.json`)。后续模型功能文档更新不得覆盖这段鸣谢,原LICENSE版权/许可声明保持原样。
74
+
75
+ 生产实现仍由原 `session-dd9655aa-b99f-4e36-b914-a5035fd094e1` 编码,Root独立验收;所有派活/查询显式originThreadId=`01a100f1-045c-7e10-84c7-ac8ec37eff3f`。不新开编码会话,不加依赖,不读复制凭据,不改上游SDK/全局权限/preset/原never,不重启现有客户端,不commit/push/tag/npm发布。已delivered不重发,unknown只读原request官方证据,不猜最近助手。日志通用中心/轮转和普通聊天新建工具不在本次范围。
@@ -0,0 +1,206 @@
1
+ # Codex → DSH 派活与回报关联方案(R0 核实 + R1 已实现)
2
+
3
+ - 日期:2026-10-12
4
+ - 仓库:`E:/11-Github/dsh-codex-collab`
5
+ - 关系:本文件是**补充方案**,不改动 `docs/DSH_TO_CODEX_MESSAGING_PLAN.md`(那份仍是 M0–M3 的原始计划,M0 结论仍为 blocked)。
6
+ - 一句话:主要使用方式收敛为 **Codex 发起任务 → DSH 执行 → Codex 按来源对话 + taskId 精确收取该任务回报**;这条链路**不依赖**"唤醒任意空闲桌面对话"。
7
+ - 状态:**R0 已核实、R1 已实现**(代码 + 文档 + 真实回归),R2 仍未做。
8
+
9
+ ## 1. 范围与结论
10
+
11
+ | | 结论 |
12
+ |---|---|
13
+ | 已实现 | **R1**:派活时绑定来源对话 id,持久记录「来源对话 → taskId → 执行目标 → 状态/结果」,跨回合、跨 MCP 进程、网关重启后仍能精确收取 |
14
+ | 不做且不需要 | 不需要"唤醒任意空闲桌面对话"才能完成派活回报——按来源 + taskId 的路径**已能正确收取** |
15
+ | 未做(后续) | R2:空闲自动唤醒(M0 仍 blocked);恢复时**重新观察**活任务(当前明确标 unknown,不猜) |
16
+ | 能力边界 | 桌面自动唤醒仍为 `canWakeIdle: false`;本条只讲派活回报的关联与收取 |
17
+
18
+ ## 2. 已具备的链路(R0 核实,代码位置已逐条核对)
19
+
20
+ ### 2.1 taskId:服务端生成、内存表、唯一
21
+
22
+ - 生成:`harness/dsh-bridge.mjs:82` —— `const uid = () => 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)`
23
+ - 分配:会话目标 `:679`、worker(`enqueue`)`:785`、评审(`submitReview`)`:824`,均写入 `taskId: uid()`。
24
+ - 内存表:`const tasks = []`(`:210`)。**淘汰改用 `harness/task-table.mjs` 的 `rememberTask`**:只在超限时淘汰**终态**任务,
25
+ 永不淘汰 `accepted`/`running`(否则完成回调/会话观察器 `find(taskId)` 找不到它),全是活任务时宁可超过 200。
26
+ - 持久记录**不受**内存上限影响(见 §3),因此"超过 200 条已完成记录后旧结果仍可查"。
27
+
28
+ ### 2.2 MCP 侧按 taskId 等待与查询
29
+
30
+ - `mcp/dsh-mcp.mjs` 的 `waitTask(taskId, timeoutSec, originThreadId)`:每 3s `GET /status?taskId=…[&originThreadId=…]`;
31
+ 终态 `done|error|cancelled` **以及 `unknown`** 都立即停止轮询(不会无限等);超时返回 `status:'waiting'`。
32
+ - `dsh_task`:`wait:false` → `{ok:true, phase:'submitted', …}`;默认等待 → `{phase:'finished'}`;
33
+ 会话目标 `delivered=true` 但报错 → `{phase:'queued-then-failed', resend:false}`;**恢复到的未知状态** → `{phase:'unconfirmed', resend:false}`。
34
+ - `dsh_task_status`:两种用法——`taskId [+ originThreadId]` 精确取值;**只给 `originThreadId`** 列出该对话登记过的任务(找回丢失的 taskId)。
35
+ - 工具**仍然是 6 个**(未新增);`dsh_task` / `dsh_review` 的说明里明确要求**每个回合显式传当前对话 id**,并写明"不要用常驻进程环境或最近对话推断"。
36
+
37
+ ### 2.3 /status 回传的字段
38
+
39
+ ```
40
+ taskId, status, kind, cwd, lane, target, deliver, instruction(≤300),
41
+ result, error, reason, fallback,
42
+ delivered?, originThreadId?, associationError?, matchedBy?, candidates?,
43
+ watcher? / sawRunning? // 仅会话目标且仍在观察时
44
+ ```
45
+
46
+ - `status` ∈ `accepted | running | done | error | cancelled`,**另加恢复专用 `unknown`**。
47
+ - 会话目标带 `target: { kind:'session', sessionId }`(**这个 `sessionId` 就是"执行方 DSH 会话"**);worker 目标带 `target: { kind:'worker', cwd, lane }`。
48
+
49
+ ### 2.4 回报回到哪条 Codex 对话
50
+
51
+ MCP 是**客户端发起**的调用:Codex 在哪条对话里调用了 `dsh_task`,工具结果就回到那条对话——**等待模式今天就能正确收取**。
52
+ `originThreadId`(来源对话 id)解决的是另一件事:**同一条对话的多次派活彼此区分**,以及**跨回合/跨进程之后还能精确取回**。
53
+
54
+ ## 3. R1 实现:来源关联(已落地)
55
+
56
+ ### 3.1 接口
57
+
58
+ | 位置 | 参数/行为 |
59
+ |---|---|
60
+ | `POST /api/dsh-bridge/task`、`/review` | 可选 `originThreadId`(非空字符串 ≤200 字符、无控制字符);**非法值在执行前拒绝**(400 `invalid-origin`) |
61
+ | `dsh_task` / `dsh_review`(MCP) | `originThreadId` 显式透传;**不使用进程环境或"最近对话"推断** |
62
+ | `dsh_collab_send` / `dsh_collab_review`(DSH 侧原生工具)、WS 指令 | 同样支持 `originThreadId` |
63
+ | `GET /status?taskId=…&originThreadId=…` | 精确取值(内存优先;内存里没有则从**持久记录**恢复) |
64
+ | `GET /status?originThreadId=…`(或 `/tasks?originThreadId=…`) | 列出该来源登记过的任务(含只存在于持久记录里的) |
65
+ | `GET /tasks` | 不带来源时行为不变 |
66
+ | CLI `task.mjs` | `--origin <id>`(显式优先)→ 其次**本次调用**的 `CODEX_THREAD_ID`;`--my-tasks`、`--status <taskId> --origin <id>`、`--require-origin`;`--origin` 缺值按用法错误 |
67
+
68
+ 成功/等待/终态响应都回显 `originThreadId` + `taskId` + 执行目标(`taskResponse` 统一附加)。
69
+
70
+ ### 3.2 持久记录与存储
71
+
72
+ - 模块 `harness/codex-associations.mjs`;文件 `<root>/<namespace>/bindings.jsonl`(**追加型事件日志**,读取折叠成记录)。
73
+ 默认根 `~/.dsh-codex-task-assoc`(`DSH_CODEX_TASK_ASSOC_DIR` 可覆盖),namespace 由 `DSH_BRIDGE_ASSOC_NAMESPACE` 指定(默认 `default`)。
74
+ - 记录字段:`{originThreadId, taskId, kind, target, profile, status, createdAt, updatedAt, delivered, result, error, reason, stopReason}`。
75
+ - **执行前落盘**:`bind` 在派活 / 投递 prompt **之前**调用;**失败即拒绝执行**(映射为 `association-bind-failed`)——绝不出现"执行了却没绑定"。
76
+ - **终态写回**:`onChildEnd`(worker/review 完成)、`settleSessionTask`(会话完成/失败/取消/超时)、fallback 完成、异常路径、
77
+ `pollOne` 静默超时、`/cancel` 两条分支,全部调用 `persistTask`;写失败记到任务上的 `associationError` 并打日志(**不静默**)。
78
+ - **绑定不可改投**:同一 taskId 换来源 → `origin-conflict`。
79
+ - **隔离与路径安全**:namespace 只允许 `[A-Za-z0-9._-]{1,64}` 且排除 `.`/`..`;`originThreadId`/`taskId` **从不参与路径拼接**。
80
+ 日志追加型 + taskId 全局唯一 ⇒ 多个网关/多 profile 共用同一命名空间也不会互相覆盖记录;需要分隔时用命名空间。
81
+ - **读写失败明确报错**:读不动 → `store-unreadable`;文件有内容但一行都解析不出 → `store-corrupt`;**绝不降级成空记录**。
82
+ 写入串行化复用 `harness/fs-lock.mjs`(与收件箱**同一把独占锁算法**)。
83
+ - 写入前修**记录边界**(末字节不是换行就补一个),残尾不会吞掉后续写入。
84
+
85
+ ### 3.3 用户流程(含异常处置)
86
+
87
+ 1. **定目标**:确定执行方(`--session`/`--find` 只读查找;**不按"最近使用的聊天"猜**)。
88
+ 2. **派活并带来源**:`--origin <当前 Codex 对话 id>`(MCP:`originThreadId` 参数)——执行前完成持久绑定。
89
+ 3. **等待或后查同一个 taskId**:`--status <taskId> --origin <id>`;忘了 taskId 就用 `--my-tasks --origin <id>` 找回。
90
+ 4. **拿到 done → 验收 → 继续**:读 `result`,必要时 `dsh_review` / `dsh_read_file`。
91
+ 5. **异常处置**:
92
+ - 超时但 `delivered: true` → 消息已在目标会话队列里 → **查原任务,绝不重发**。
93
+ - **网关重启/新实例**:已完成 → 按 `origin+taskId` 取回结果;没跑完 → `status: "unknown"` + `resend: false` + 原目标 + 已知 `delivered`,
94
+ **不谎报完成、不重新观察、不自动重发**(要确认就自己去看目标会话/工作目录)。
95
+ - 错来源/缺来源 → 明确拒绝(`origin-mismatch` / `origin-required`),**绝不返回别的对话的结果**。
96
+ - 取消:`/cancel` 支持可选 `originThreadId`;已关联任务被别的来源取消 → 拒绝。
97
+
98
+ ### 3.4 会话任务的精确请求回合关联(带来源时)
99
+
100
+ 会话目标不能再用"派发前基线 + 全会话 running/idle"来收结果——那会把**别的请求**的输出算给当前 taskId。
101
+ 带来源的会话任务改为**按请求关联回合**(依据本机 controller 0.2.0-rc.2 实测字段):
102
+
103
+ | 事实 | 做法 |
104
+ |---|---|
105
+ | `prompt.requestId` 会写进 `user/message.data.source.rpcId` | 用 `taskId` 当 `requestId`;匹配 `rpcId === taskId` 的那条 user 消息 = 本请求 |
106
+ | **`user/message.data.turn` 不存在** | 按 `seq` 有序跟踪"已 `turn/start` 但未 `turn/end`"的回合;rpcId 匹配时归到当时**未闭合**的那个 turn |
107
+ | `assistant/message.data = {turn, step, message:{content}}` | 只收 `turn === 本请求 turn` 的助手文本 |
108
+ | `turn/end.data = {turn, reason:{kind}}` | 本请求的结束依据;`kind ∈ completed/aborted/blocked/error/max-tokens/interrupted/forked`,**只有 completed 算成功**,其余明确终态失败(保留部分文字为 `partialResult`) |
109
+ | follow 开场 = `{type:'snapshot', records:[{type:'event',event}], …}`(不是 `snapshot.events`) | **必须检查 records**:快任务可能在派发与订阅之间就跑完,答案只在这个快照里 |
110
+ | `api-session/status` 只表示 agent 是否 running(连续跑 queued turn 时可能没有新的 `running=true`) | 只当作"读一次日志"的机会;**绝不是**某请求完成的证明;quiet/总预算到期而无匹配 `turn/end` → 报未确认(`resend:false`),绝不猜 done |
111
+ | 事件可能被 snapshot / 轮询 / live 重复投递 | 按 `seq` 去重,但**只标记"真正产生效果"的事件**:当时因为还没关联上自己 turn 而无法使用的事件必须能在完整日志回填时被重新消费(否则快照缺前半段会永久丢答案) |
112
+ | `steer` 会把整批 next-step 合并进同一个 turn | 带来源的会话派发**执行前拒绝 steer**(`origin-steer-unsupported`,零 prompt);旧的无来源 steer 行为保留 |
113
+
114
+ **回显与持久化(按最终实现,区分落盘与仅内存)**:
115
+
116
+ - **落盘**(关联记录 `bindings.jsonl`;`/status` 在内存分支与恢复分支都回显):`originThreadId`、`taskId`、`kind`、`target`、
117
+ `status`、`delivered`、`result`、`error`、`reason`、`stopReason`、`requestId`(=taskId,会话任务)、`dshTurn`(匹配到的回合号)。
118
+ - **仅内存可见**(活任务的 `/status`,不落盘):`turnEndKind`、`partialResult`,以及未确认路径的 `resend`/`note`。
119
+ 恢复分支不带这三项,而是按记录状态自己给出:终态记录回放 `status`/`result`/`error`/`reason`;非终态记录合成
120
+ `status: "unknown"` + `reason: execution-state-unconfirmed` + `resend: false` + `note`(这是**恢复时的结论**,不是从盘里读出的字段)。
121
+
122
+ **未验证/限制**:相关性依赖有序日志(若日志缺 `turn/start`,只能归到最近未闭合的 turn);`api-session/error` 不带 requestId,因此**不**用它判定某个排队请求失败;"恢复后重新观察活任务"仍未实现(只报未确认)。
123
+
124
+ ## 4. 验收(本轮真实跑过)
125
+
126
+ ### 4.1 契约收紧(独立复现脚本 `review-r1-contracts.mjs` 曾 2/8,现 **8/8**)
127
+
128
+ | 契约 | 实现 |
129
+ |---|---|
130
+ | 显式非法来源零执行 | 只有**字段未传/undefined** 才是旧的无关联模式;显式 `''`/`null`/数字/布尔/数组/对象/纯空白 → `invalid-origin`,400 且零执行零关联。MCP 与 CLI 同样先本地拒绝(不丢字段、不改用 ENV;CLI `--origin` 缺值/下一项是旗标/空值 → 退出码 2,**请求前**报错) |
131
+ | 已关联任务必须带来源 | **内存与磁盘一致**:缺来源 → `origin-required`、错来源 → `origin-mismatch`,都不返回 `result`/`instruction`;`/cancel` 同样检查。`openAssociations.fetch` 失败时**不再带出 record** |
132
+ | 同通道并发必须可归属 | 同一 `cwd+lane`(评审=同目录)有在途任务时,第二个明确 `worker-bucket-busy` / `reviewer-bucket-busy` 拒绝:**零 followup、零二次执行**,绝不用"桶里最老的 pending"去猜归属。不同 lane 照旧并行;上一任务结束后可继续复用同一子代理 |
133
+ | 旧网关零执行 | `/status`(无参数)、`/tasks`、`/debug` 上报 `capabilities.originBinding.supported/version`;MCP/CLI 带来源时**先只读核验**(GET,不派活),不支持则拒绝且**一个派活 POST 都不发**。按来源查询也不把旧网关的普通响应当成精确恢复 |
134
+ | 列表不谎报 | 只给来源列任务时,本实例没有运行上下文的非终态记录一律 `status:"unknown"` + `persistedStatus` + `resend:false` + 说明(与精确查询同一套投影) |
135
+
136
+ ### 4.2 端到端(真实进程/真实请求)
137
+
138
+ **独立验收脚本**(Codex 侧,正向契约测试):`review-r1-contracts.mjs` **12/12**、
139
+ `review-session-correlation.mjs` **4/4**(含"排队请求忽略先前回合"、"只有自己 turn/end 才完成"、
140
+ "自己回合 error/blocked 不得 done"、"snapshot.records 里的快速完成"、"快照缺前半段回填"、
141
+ "旧网关零执行拒绝"、"能力通过但网关未确认来源 → 未确认 + 禁止重发")。
142
+
143
+ `test/codex-association-e2e.test.mjs` —— **真实 `http.Server` + 独立 MCP 进程 + 独立 CLI 进程**:
144
+
145
+ - 回合 1(MCP 进程 A)带 `originThreadId` 派活 → 执行前落盘;**环境里故意放一个 `CODEX_THREAD_ID`,MCP 不得采用它**(断言来源仍是显式传的那个)。
146
+ - 回合 2(**另一个** MCP 进程):只给来源 → 列出该任务;再按 `origin+taskId` → 取回结果;错来源 → `origin-mismatch` 且无结果;
147
+ 缺来源 → `origin-required` 且无结果;非法来源 → `invalid-origin` 且零派活。
148
+ - **网关新实例**(同持久 root、新端口、全新内存表):已完成 → `done` + 结果(`recovered: true`);未完成 → `unknown` + `resend:false` + 原目标;
149
+ 缺来源 → `origin-required`;**新实例零执行**(`starts`/`prompts` 均为 0)。
150
+ - CLI:`--origin` 派活 → 新进程 `--my-tasks` 找回 → `--status … --origin` 取结果;缺来源/错来源退出码 6;无来源 `--my-tasks` 退出码 2;
151
+ `--require-origin` 拒绝派活且零派活;`CODEX_THREAD_ID` 兜底可用;无来源的旧调用仍兼容;`--origin` 缺值按用法错误。
152
+ - 测试卫生:CLI 一律**异步 spawn**(网关 HTTP 服务器与测试同进程,同步 spawn 会自锁),MCP 收尾**等待子进程 close**,
153
+ 失败路径也 `finally` 关网关/杀子进程(宿主与清理统一在 `test/helpers/dsh-host.mjs`)。
154
+
155
+ ### 4.3 项目内回归
156
+
157
+ `test/codex-r1-contracts.test.mjs`(8 项,把独立场景固化:C1 非法来源、C2 缺/错来源、C3 通道 busy + 不同 lane 并行 + 评审 busy、
158
+ C4 旧网关零执行 + 能力上报、C4b 能力上报放行、C4c 能力通过但未确认来源 → 未确认 + 禁止重发、C5 列表 unknown 语义、CLI 严格参数);
159
+ `test/codex-session-correlation.test.mjs`(8 项,真实 SDK 字段:排队请求忽略先前回合、自己 turn/end 才完成且不需要新 running、
160
+ error/blocked/aborted/max-tokens 不得 done、snapshot.records、重复投递不串文本、快照缺前半段回填、无匹配 rpcId → 未确认、steer 拒绝 + 旧路径保留、worker/取消不受影响);
161
+ `test/session-target.test.mjs` 另有 5 项关联器纯函数单测;
162
+ `test/dsh-bridge.test.mjs`(13 项)与 `test/codex-associations.test.mjs`(13 项)另覆盖:执行前落盘、worker/评审/取消写回、
163
+ **200+ 已完成记录后旧关联结果仍可查且活关联任务仍能完成**、**关联存储不可用 → 拒绝派活(绝不假成功)**、绑定不可改投、
164
+ 命名空间隔离、fetch 失败不泄漏 record、真多进程并发 bind、任务表淘汰不丢活任务。
165
+
166
+ ## 5. 验收重点(对应关系)
167
+
168
+ | 要求 | 实现/证据 |
169
+ |---|---|
170
+ | 两个来源的任务不串号 | 错来源一律 `origin-mismatch`;e2e 用两个来源分别派活并交叉查询 |
171
+ | 同一对话多个任务可区分 | 靠 taskId;`--my-tasks` 列出该来源全部任务 |
172
+ | 超时继续查原 taskId、不重发 | `waiting` + `queued-then-failed(resend:false)` + `unknown(resend:false)` |
173
+ | 不按"最近聊天"猜目标 | 来源必须显式;缺来源一律拒绝;代码/测试/文档无任何真实对话 id 默认值 |
174
+ | 对话 id 负责归属、接收工具负责收取 | `originThreadId` 归属 + `dsh_task_status`/`--status` 收取 |
175
+ | 不把"支持来源"当成默认 | 派活/查询前只读核验 `capabilities.originBinding`;旧网关零执行拒绝 |
176
+ | 结果归属不能靠猜 | 同通道在途任务时明确 busy 拒绝(不 followup、不猜 pending) |
177
+ | 自动唤醒能力 | 仍为 `false`(未实现,未宣称) |
178
+
179
+ ## 6. 分阶段计划
180
+
181
+ | 阶段 | 内容 | 状态 |
182
+ |---|---|---|
183
+ | **R0** | 核实现有 taskId 回报链路并写清边界 | ✅ 完成 |
184
+ | **R1** | 显式来源元数据 + 持久关联记录 + 跨回合/跨进程/重启恢复 + CLI/MCP/文档 | ✅ **本轮完成** |
185
+ | **R2** | 只有在明确需要"空闲自动唤醒"**且**存在受支持桌面入口时,才接回 M0–M3;可另加"重新观察活任务" | ⏳ 未做(M0 仍 blocked) |
186
+
187
+ ## 7. 明确不做 / 未验证
188
+
189
+ - 不新增第 7 个 MCP 工具;不改动既有六个工具的语义与既有参数行为。
190
+ - **`originThreadId` 是关联元数据,不是认证/授权凭据**:本机 loopback 调用者知道 taskId+origin 就能查;本包不做权限模型。
191
+ - **未实现"重新观察活任务"**:恢复到的未完成任务只报 `unknown`,不读"最近助手文本"去猜。
192
+ - 不做空闲唤醒、不做自动重发、不按最近/标题/时间猜来源对话。
193
+ - **通道忙时的取舍**:同 `cwd+lane` 有在途任务就拒绝第二条(宁可让调用方等/换 lane,也不让完成结果归属变成猜测);
194
+ 这是有意的行为收紧,不是 bug。
195
+ - **能力握手靠网关自报**(`capabilities.originBinding`),不靠包版本号:从旧版本升级到本版本后,客户端会自动放行;
196
+ 若 profile 里装的是旧插件,客户端会拒绝来源模式(旧的无来源调用不受影响)。
197
+ - 关联日志不自动清理(追加型,增长很慢);归档属后续工作。
198
+ - 不写入任何真实对话 id 作为默认值(代码/测试/文档均无硬编码)。
199
+
200
+ ## 8. 与 standalone inbox 的关系(并列,不互相替代)
201
+
202
+ - `mcp/codex-inbox.mjs` + `harness/codex-outbox.mjs` 的**收件箱**是将来"存放待收消息"的基础设施(持久、幂等、按序、重启可恢复),
203
+ 与 **来源关联的 taskId 工具回报路径并列**,不是它的替代品;两者现在**共用同一套独占写锁算法**(`harness/fs-lock.mjs`)。
204
+ - 因此:**"桌面自动唤醒尚未完成"不等于"已有派活回报失效"**。前者是"DSH 主动叫醒 Codex"的能力(blocked);
205
+ 后者是"Codex 派活后按来源 + taskId 收结果"的能力(**已工作**)。
206
+ - 收件箱的当前能力仍如实标注:`deliveryMode: 'inbox'`、`canWakeIdle: false`。
@@ -0,0 +1,142 @@
1
+ # Codex 单次审批委托:架构与使用(独立验收修正版)
2
+
3
+ 状态:已实现并自测(144/144)。方案见 `CODEX_DSH_APPROVAL_DELEGATION_PLAN.md`,官方接口取证见 `CODEX_DSH_APPROVAL_M0_EVIDENCE.md`,
4
+ 本轮针对 root 独立探测发现的问题(跨会话参数替换 / hash-as-bearer / 同 callId 两次 ask / 归属与接口一致性 / 生命周期 / 确认与恢复)逐项修正。
5
+ 基线 `383528c`;本轮**未**安装、未重启、未发布、未改动全局权限/preset/never。
6
+
7
+ ## 1. 一句话
8
+
9
+ 在 DSH 官方 `approval/request` waterfall 上 prepend 注册一个**严格谓词**答复者:只有"该任务被显式委托 + 该调用能被宿主侧证据完整证明 + 官方 ask 身份唯一"的请求才由 Codex 单次决定(`approve-once`);其余一律 `next()` 交还人工链。**绝不吞链、绝不凭超时放行、绝不写 asked/decided。**
10
+
11
+ ## 2. 组成
12
+
13
+ | 文件 | 职责 |
14
+ |---|---|
15
+ | `harness/codex-approvals.mjs` | 控制凭据(离线初始化、raw key 认证、域分离摘要)、grant(身份/范围/版本/期限/租约/撤销)、**不可变执行快照**、动作指纹、官方 ask 身份台账与唯一认领、单次状态机、幂等、严格失败语义、恢复视图 |
16
+ | `harness/dsh-bridge.mjs` | 三条宿主接线(`tools/pre-execute` 快照、prepend 答复者、官方 asked/decided 观测)+ 鉴权路由 + 委托派活时序 + `needs-approval` 非终态 |
17
+ | `mcp/dsh-mcp.mjs` | `dsh_approvals`、`dsh_approval_decide`、`dsh_task{delegate}`(带认证头)、`dsh_task_status{wait}` |
18
+ | `codex-plugin/.../scripts/task.mjs` | CLI:key 初始化、`--delegate`、`--approvals/--approval-show/--approve/--reject/--revoke-grant/--renew-grant`、`--status-wait`(退出码 9=needs-approval) |
19
+
20
+ ## 3. 认证与授权
21
+
22
+ - **传输**:`X-Dsh-Control` = **私有 raw key**(离线生成的 64-hex);服务端与本机 key 文件等值比较(timingSafeEqual)。key 文件格式不合规一律按"无 key"处理,绝不把任意内容当 header 发出。
23
+ - **持久**:grant/audit 只存**域分离单向摘要** `digest:sha256('dsh-approval-grant-digest:'+key)`——**它不能用于登录**(旧版把 `sha256(key)` 同时当 header 与 grant 字段,等于 bearer 落盘,已改)。
24
+ - **缺 key = 能力关闭**:`capabilities.approvals.enabled=false`,审批路由 401,委托派活零执行。
25
+ - raw key 不进:HTTP 响应、MCP 工具输入/结果、会话日志、审计、错误正文、仓库。
26
+ - **没有任何未认证的 enable/bootstrap/领 token 路由**;`origin/task/session/callId` 只是**定位符**,永远不构成授权。
27
+
28
+ ## 4. 一次无人值守审批
29
+
30
+ ```bash
31
+ # 0) 只做一次:离线初始化(仓库外私有目录;raw key 不回显)
32
+ dsh-task --approval-key-init # 轮换 --approval-key-rotate(= 撤销全部旧 grant)
33
+
34
+ # 1) 派活时显式开启委托(grant 与 task+origin 绑定**先于** prompt;失败=零派活)
35
+ dsh-task --in "<指令>" --session <会话id> --origin <当前对话id> --delegate \
36
+ [--delegate-ttl 900] [--delegate-lease 60] [--authorization "本次授权范围说明"]
37
+
38
+ # 2) 任务等审批时以退出码 9 及时返回(非终态)→ 取真实动作
39
+ dsh-task --approvals --origin <id> --task <taskId> [--grant <grantId>] [--wait-approval 30]
40
+
41
+ # 3) 逐条单次决定(approvalKey/grantId/fingerprint 都来自上一步;多余字段/参数副本一律拒绝)
42
+ dsh-task --approve <approvalKey> --origin <id> --task <taskId> --grant <grantId> --fingerprint <fp>
43
+ dsh-task --reject <approvalKey> --origin <id> --task <taskId> --grant <grantId> --fingerprint <fp>
44
+
45
+ # 4) 决定后继续收结果(等待期间做认证续租)
46
+ dsh-task --status <taskId> --origin <id> [--grant <grantId>] --status-wait
47
+
48
+ # 撤销 / 在原授权窗口内显式续期
49
+ dsh-task --revoke-grant <grantId> --origin <id> --task <taskId>
50
+ dsh-task --renew-grant <grantId> --origin <id> --task <taskId> [--extend 3600]
51
+ ```
52
+
53
+ MCP 等价:`dsh_task{delegate:true, authorization}` / `dsh_approvals{originThreadId,taskId,…}` /
54
+ `dsh_approval_decide{originThreadId,taskId,approvalKey,grantId,fingerprint,decision}` /
55
+ `dsh_task_status{taskId,originThreadId,grantId,wait:true}`。
56
+
57
+ **CLI 的两条等待路径是同一条**(第四轮修正):`--in` 的**初次默认等待**与 `--status <id> --status-wait` 共用
58
+ `awaitTerminal()` —— 显式来源时每个 status 都带 `originThreadId` 且**逐次**核对回显(缺失/错源 → 退出码 8,不透 result、不重发)、
59
+ 按响应里的 grant 字段做自适应认证续租、`needs-approval` 立即退出 9、`done/error/cancelled/unknown` 语义不变、无委托任务不续租。
60
+ `--delegate` 的派活 POST 也带控制凭据头(缺 key 在预检阶段零派活拒绝)。
61
+
62
+ ## 5. 严格绑定(本轮修正的核心)
63
+
64
+ 1. **参数只来自宿主**:`tools/pre-execute` 快照按 `(sessionId, callId)` 存**深拷贝冻结**的 `args/cwd/rootCallId/parent`;客户端不得代传参数(多余字段直接 400)。
65
+ 2. **完整执行身份**(第二轮修正):身份 = `sessionId + callId + rootCallId + parent + toolName + cwd + argsSha256 + turn + step`,**九项全参与** `executionId` 与指纹。任何一项变化 → 旧审批被拒(`approval-context-changed`),包括:
66
+ - `root-only`(换 rootCallId)、`tool-only`(换工具名)、`parent-only`(父执行 token 变);
67
+ - **`step-only`**:仅日志里新增同 callId 的 `tool/call`(step 变、无新 pre-execute)——所以决定时必须**实时重读日志**取当前 turn/step 再比对。
68
+ 决策路径只比 coarse `snapshotId` 是不够的(第一版就是这样被 root 复现放行的)。
69
+ 3. **未知即 unsupported,绝不假装完整**:身份任一必需项拿不到(无 cwd 来源、工具名空、拿不到当前 turn/step、父身份存在但不可稳定表达、`parentKey` 与 `parent` 不一致)→ answerer 直接 `next()`(走人工、零 pending);决定路径返回 `execution-identity-unknown` 而不是放行。
70
+ 4. **官方真实 shape(已核对本机官方源码,不是 fixture 杜撰)**:
71
+
72
+ | 字段 | 官方来源 | 证据 |
73
+ |---|---|---|
74
+ | `callId` / `rootCallId` / `name` / `arguments` / `token` / `agent` / `parent` | `tools/pre-execute` 收到的 execution 对象 | `dsh-tools/lib/index.js:3143-3168`(`createExecution`) |
75
+ | `rootCallId` 归一(顶层 = callId) | 同上 | `dsh-tools/lib/index.js:3135` |
76
+ | **`parent` = 父执行的 token** | 嵌套/PTC 派发 | `dsh-tools/lib/ptc.js:439`、`dsh-tools/lib/index.js:1306` |
77
+ | **`cwd` 不在 exec 上**,只能取 `exec.agent.session.header.cwd` | 同一取值路径 | `dsh-tools/lib/index.js:1423`;`dsh-session/lib/index.js:1044-1046`(必须是非空绝对路径字符串) |
78
+ | 一个 execution 只会 pre-execute 一次 | SDK invariant | `dsh-tools/lib/types/invariant.js:88-91` |
79
+
80
+ 5. **turn/step 必须可证**:来自同一 callId 的 `tool/call` 且都是整数;缺一即 `next()`(**未知不得作为可批准默认值**)。turn 还必须等于该任务自己绑定的 turn(R1 requestId→rpcId→own turn)。
81
+ 6. **官方 ask 身份唯一认领**:观测 `approval/asked` 建立台账;答复者必须恰好命中 1 个未认领项,否则 `next()`(**歧义不猜、绝不按 callId 合并**——同一次调用合法发起两次 `request()` 时各有独立 pending 与独立决定)。
82
+ 7. **决定七重核对**:origin/task/grant/版本 + 凭据摘要 + 未撤销/未过 TTL/租约未过 + **完整执行身份(含当前日志 turn/step)仍等于 pending 的冻结身份** + 指纹一致;**审计 intent 写成功才放行**。
83
+ 8. **路径**:`…:ptc:<n>` 嵌套、`rootCallId≠callId`、以及存在父执行(`parent` 非空)一律 `next()`(PTC 能力如实报 false)。
84
+
85
+ ## 6. 生命周期
86
+
87
+ - 答复者按**有效租约/绝对 TTL 与 `req.signal`** 保持(不再有 25s 硬上限;25s 只是单次 HTTP/MCP 调用的有界等待)。认证续租(清单/详情/决定/`renew`)会延长租约;`renewGrant` 在**原授权窗口内**延长绝对 TTL(封顶 8h)。
88
+ - **观察窗口按有效认证委托**(第二轮修正):只要存在"未撤销、未过绝对 TTL、凭据摘要匹配、严格属于该 task/origin/session"的 grant,sweep **不按 quiet/总预算**收口任务——包括**还没有 ask 的长模型阶段**(默认 1h 总预算不该把 8h 授权的委托收成 unknown/superseded)。上界 = grant 绝对到期 + 宽限,且硬性不超过"授权窗口上限 + 宽限"(仍是有界的,不是无限等)。
89
+ - **失联/撤销/到期安全退出**:本机 key 消失 → 摘要取不到 → 立即回到旧预算;撤销/到期 → 同样回到旧预算并有界收口(`unknown` + `resend:false`,pending 交还人工)。**未委托任务语义完全不变。**
90
+ - **`needs-approval` 是在途状态**:它计入同会话忙锁——同一会话的第一个任务在等待审批时,第二次派活必须在**发出 prompt 之前**被拒(`session-busy`,零 prompt、不签发新 grant),第一任务的 record/观察/结果不受影响。
91
+ - **交还 `next()` 之前先关闭可答状态**(`superseded`/`context-changed`/`expired`),交还之后迟到的批准一律拒绝。
92
+ - 撤销/过期/卸载/收口都会安全关闭在途 pending;并发多审批时任务保持 `needs-approval`,最后一条解决才回 `running`。
93
+
94
+ ## 7. 确认与恢复
95
+
96
+ - `submitted ≠ confirmed`:只有观测到官方 `approval/decided` 才算确认,且**由官方 outcome 决定**:`allowed-once → confirmed`;`cancelled/rejected → 对应终态`(**绝不算批准**,取消可赢过晚到的 allowed-once);其它 → `decision-unknown`。**reject 的终态是 `rejected`**,提示语不再引导去等只对批准有意义的 `confirmed`。
97
+ - handler 只返回 SDK(不在 handler 里等自己的 decided,也不把自己返回当采纳)。
98
+ - 重启后:`submitted` 未确认 → `decision-unknown / live:false`;无决定记录 → `unanswerable / live:false`(不静默消失、不可再次批准)。
99
+ - **运行中绝不重复上报**(第三轮修正):`unanswerable` 只列**本进程内存里没有对应 pending** 的记录——同一 `approvalKey` 不会既出现在 `approvals`(`live:true`)又出现在 `unanswerable`(真实 profile 曾把活跃 pending 当成"重启后无 callback")。恢复能力本身不变。
100
+ - **权限记录(grants.jsonl)任何坏行/截断 → 安全失败**(`grant-log-corrupt`,列表为空、撤销/续期拒绝),不沿用旧有效授权;审计展示可另论。
101
+
102
+ ## 7.1 客户端定位字段(第三轮修正 A)
103
+
104
+ `POST /task`(委托派活)、`GET /status`、`dsh_task` / `dsh_task_status` 响应都带**非秘密**的 grant 定位/期限:
105
+
106
+ | 字段 | 含义 |
107
+ |---|---|
108
+ | `grantId` | 该任务的委托 grant id(等待循环据此做**认证续租**,不需要先手动查 grant) |
109
+ | `grantExpiresAt` | grant 绝对到期(授权窗口;续期只在其内延长) |
110
+ | `grantLeaseUntil` | 当前租约到期(每次 `status` 都刷新;客户端据此自适应续租节奏) |
111
+ | `grantStatus` | `active` / `revoked` / … |
112
+
113
+ MCP `waitTask` 的续租**跟着租约走**:轮询间隔 = 租约剩余/3(250ms–3s),剩余不足一个周期时发一次认证续租;1–5s 的短租约也不会被固定 10s 的节奏放过期。不传 `extendSec` 时只续**租约**,不延长绝对授权窗口(不扩大永久授权)。CLI 的**初次等待与 `--status-wait` 共用同一条路径**,同样自适应;两条路径的续租都必须由**认证调用**(带控制凭据头、匹配 task/origin/grant)触发,公开 `status` 不续。
114
+
115
+ **CLI 回归**(`test/approval-cli.test.mjs`,真实 CLI 进程 + 真实 HTTP):`--delegate --no-wait` → exit 0 + 单个 prompt/grant + `grantId` 回显(缺 key → exit 2 且零派活);`--delegate --delegate-lease 2` 的**初次默认等待**遇到 4.2s 才到的慢 ask → **exit 9**、初次路径上发生 ≥2 次认证续租、pending 随后可决定(不需要二次 `--status-wait`、不预查 grant);同一次初次等待精确取回自己那条任务的 `completed` 结果;错来源 echo 不透 `result`。
116
+
117
+ ## 8. 能力(capabilities.approvals)
118
+
119
+ | 能力 | 值 |
120
+ |---|---|
121
+ | supported / enabled | true / 看 key(缺 key 默认关闭) |
122
+ | sessionTargetSupported | true(`req.agent.session.id` 严格绑定;native 已证根监听可达) |
123
+ | workerSupported | **false**(child 身份未证明;worker+delegate 派活前拒绝,旧 worker 无委托路径不受影响) |
124
+ | ptcSupported | **false**(嵌套直接 `next()`) |
125
+ | canWakeIdle | **false**(不宣称 MCP/HTTP/收件箱能唤醒模型) |
126
+ | requiresExplicitEnable / activeOnly | true(提示/文本不构成授权;只在活跃循环内生效) |
127
+
128
+ ## 9. 加载前提与测试
129
+
130
+ - 答复者是宿主 bundle 插件:**安装更新包 + 重启 profile** 后才在真实桌面生效;真机正例还需会话 `approval/policy=ask`(never 会先于答复者拒绝)、用户显式启用委托、动作限定授权范围内的无害操作。native/e2e 都是隔离证据,**不冒充真实桌面验收**。
131
+ - 测试分层:
132
+ ```bash
133
+ node --test test/*.test.mjs # 全套 159
134
+ node test/codex-approvals.test.mjs # 核心(凭据/grant/快照/完整身份/认领/决定/恢复)
135
+ node test/approval-e2e.test.mjs # HTTP + 独立 MCP + 独立 CLI 闭环与负向
136
+ node test/approval-hardening.test.mjs # 第一轮 root 探测对应的加固回归(跨会话替换/上下文变更/双 ask/cancelled/wait 来源)
137
+ node test/approval-window.test.mjs # 第二轮 root 三个阻塞的回归(代际变化/忙锁/观察窗口)
138
+ node test/approval-renewal.test.mjs # 第三轮收尾回归(默认路径跨短租约续租/重复上报/reject 文案)
139
+ node test/approval-cli.test.mjs # 第四轮 CLI 入口回归(派活带认证/初次等待跨短租约/取回结果/错来源不透结果)
140
+ node test/approval-native.test.mjs # 真实 cordis/ApprovalService/scopeTarget(无 asar → skip 并显示原因)
141
+ ```
142
+ - 观察窗口的环境变量:`DSH_BRIDGE_SESSION_QUIET_TIMEOUT_MS` / `DSH_BRIDGE_SESSION_TOTAL_TIMEOUT_MS` 仍是**未委托**任务的旧预算;`DSH_BRIDGE_DELEGATED_GRACE_MS`(默认 5 分钟)是委托 grant 到期后的收尾宽限。测试用 50ms 预算证明"有效认证委托不被误收口"。
@@ -0,0 +1,69 @@
1
+ # DSH 投递控制(queue / steer)M0 官方契约证据
2
+
3
+ 状态:D0 完成。证据来自本机官方 `app.asar` **只读**提取(`D:/Program Files (x86)/DSH/resources/app.asar`),
4
+ 由 `test/delivery-native.test.mjs` 用真实组件/真实源码契约复现(3/3 通过)。SDK 不进运行时依赖,只写临时目录、测后删除;
5
+ 无 asar 的环境整体 skip 并显示原因——**不把 native/mock 冒充真实 Desktop 验收**。
6
+
7
+ 基线:`delivery-baseline-EK14CG`(20 文件、SHA256 逐字节核对一致、原 159 测试通过、HEAD `383528c`)。
8
+
9
+ ## 1. 投递入口:`sessionController.prompt(request)`
10
+
11
+ `@deepseek-ai/dsh-api-session-controller/lib/index.js:850–899`(真实源码,测试逐条断言)
12
+
13
+ | 事实 | 证据 |
14
+ |---|---|
15
+ | 请求身份写进 user 消息:`source = { kind: 'user', rpcId: request.requestId }` | `:856–860` |
16
+ | **顺序重复去重(有界)**:`if (hasPromptRequest(agent, request.requestId)) return { accepted: true }` —— 同 requestId 已在内存队列**或整份会话日志**里可见时,本次调用不再落一次 | `:855` |
17
+ | 去重窗口 = 内存队列(next-turn/next-step)**加整个会话日志**(`agent.session.snapshotEvents()` 里任何 `user/message.source.rpcId` 匹配即算已有) | `:1063–1074` |
18
+ | 模式分派:`if (request.mode === 'steer') agent.steer(message); else agent.followup(message);` | `:882–883` |
19
+ | **admission 是异步的**(附件/模型校验/`fileUploads.bindPrompt` 都在写入之前 `await`)→ **没有原子的 expectedTurn 条件** | `:862–898` |
20
+ | 返回值 `{ accepted: true }` 只表示"已接收",**不是已执行**、也不是已插入 | `:896` |
21
+
22
+ 结论(首次验收修正 G,措辞收窄到**源码可证**的范围):这个检查发生在 **async admission 之前**,因此它只证明"**在 inbox/日志里已经可见之后的顺序重复投递**会被去重";
23
+ 它**不能**证明并发原子幂等,也**不能**证明"任意时刻重试都安全"(两个并发请求可能都通过检查)。所以我们仍然**不主动重发**:拿不准时按 R1 语义报 `unknown + resend:false`。
24
+ 但**接收 ≠ 执行**这一点不变,且**没有原子父回合校验**。
25
+
26
+ ## 2. 原生队列:`agent-loop` 的 durable Inbox
27
+
28
+ `@deepseek-ai/dsh-agent-loop/lib/index.js`(真实源码契约由测试断言)
29
+
30
+ | 事实 | 证据 |
31
+ |---|---|
32
+ | `followup(input)` → `send(input, 'next-turn', true)` | `:806–808` |
33
+ | `steer(input)` → `send(input, 'next-step', true)` | `:809–811` |
34
+ | `inject(input)` → `send(input, 'next-step', false)`(不唤醒) | `:812–814` |
35
+ | 入队是**尾插 = FIFO**:`this.inbox.splice(resolvedTarget, Infinity, 0, [message])` | `:800–805` |
36
+ | **真实竞态**:`const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted; const resolvedTarget = wakingAfterAbort ? 'next-turn' : target;` —— 回合已 abort 时**steer 会被官方改道成 next-turn** | `:800–803` |
37
+ | `claim(target, turn)`:`claimed = mutate('next-step', 0, nextStep.length, …)`(**全部** next-step);仅当 `target === 'next-turn'` 时再 `mutate('next-turn', 0, 1, …)`(**恰好一条** next-turn) | `:103–111` |
38
+ | 回合结束条件:`if (turnEnds && this.inbox.nextStep.length === 0) break;`(steer 在**下一个步进边界**被消费;有 next-step 就不结束回合) | `:998–1006` |
39
+ | `clear()` = `splice('next-step',0,len)` + `splice('next-turn',0,len)`(**会清空整个队列**) | `:92–96` |
40
+ | `cancel(cause,{keepInbox})` 默认 `inbox.clear()` → 取消回合会连带清掉排队输入 | `:815–821` |
41
+
42
+ **durable**:pending 列表由投影 `inbox` 从会话事件 `agent/inbox/spliced` 物化(`splice()` → `session.append('agent/inbox/spliced', {target,start,removedCount,inserted,outcome})`,`:159–205`),
43
+ 投影在校验失败时**抛错**(`invalid persisted inbox splice`),并对跨列表重复的 `message.id` 抛 `already pending`(`:26–56`)。
44
+ → 队列是**会话持久状态**:网关重启不丢队列,官方自己保证顺序;`removedCount` 是**事件字段**(记录"这次 splice 移除了几条"),
45
+ 它描述的是**官方自己写事件时**的语义,**不等于**我们可以伪写会话日志去做"撤队"——我们不向会话日志写任何 inbox 事件(首次验收修正 G)。
46
+
47
+ **导出的边界投影**(测试用真实组件跑):`turnBoundaryProjectionDefinition`(`key:'turnBoundary'`, `:1353–1398`)物化 `openTurnStartSeq / lastStepStartSeq / lastStepBoundary / lastTurn`
48
+ —— 这就是"父回合是否仍开放"与"步进边界"的官方判据,队列消费时机依赖它。
49
+
50
+ ## 3. 设计结论(对实现的硬约束)
51
+
52
+ 1. **不另造持久队列框架**:queue 直接用原生 `mode != 'steer'`(`followup`)→ 官方一次 claim 一条 next-turn 的 FIFO;steer 用原生 `steer`(next-step)。
53
+ 2. **一次 turn 只有一个执行 owner**:因为 `claim` 一次只取一条 next-turn,排队任务各自独立回合;`steer` 进 next-step、与父任务**同 turn**,因此**回执绝不能当审批/结果 owner**。
54
+ 3. **接收 ≠ 执行**:queue 任务的 `accepted` 只证明官方接收;必须靠自身 `rpcId` 在日志里的 `user/message` + 自己那个 `turn/end` 结算;不得用"最近助手文本"或全会话 idle 判完成。
55
+ 4. **父回合竞态必须如实报告**:校验通过后官方仍可能(a)把 steer 改道 next-turn(`wakingAfterAbort`)或(b)父回合刚好结束而落到另一 turn。此时**保留真实 receipt + `observedTurn`,明示未确认/偏离**,不静默改模式、不 cancel 用户回合、不重发。
56
+ 5. **不能用 session cancel 清队列**:`clear()`/`cancel()` 会清掉整队列;`agent/inbox/spliced` 的 `removedCount` 只是官方事件字段,
57
+ 我们无法从 SDK 调用点安全证明"逐项撤回",因此对无法证明安全的取消组合(排队项/回执/同会话多任务)**明确拒绝**。
58
+ 6. **去重是有界的**:官方按 rpcId 去重(含已消费历史),但检查在 async admission 之前 → 只保证"日志/队列里可见之后的顺序重复";
59
+ 我们**不主动重发**,拿不准时报 `unknown + resend:false`。
60
+ 7. **steer 需要显式父任务**:官方只按 `mode` 分派,不校验父;父/回合校验只能由我们在**投递前**做(同 origin/profile/session + 父回合开放),且必须接受"校验后仍可能偏离"的事实。
61
+
62
+ ## 4. 复现方式
63
+
64
+ ```bash
65
+ node test/delivery-native.test.mjs # 3/3:真实 turnBoundary 投影 + 真实源码契约(steer/followup/claim/send/clear/dedupe/admission)
66
+ ```
67
+
68
+ 未证明/不声称:真实 Desktop 端到端(queue 在忙时被接受、steer 在同 turn 被模型消费)由 Root 用独立临时 profile 验收;
69
+ 本文件与 native 测试只证明**官方契约**,不冒充真机结果。