@whaletalk/dsh-codex-collab 0.1.9 → 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.
- package/CHANGELOG.md +9 -0
- package/README.md +105 -25
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +63 -3
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +443 -39
- package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
- package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
- package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
- package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
- package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
- package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
- package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
- package/harness/audit-tail.mjs +32 -0
- package/harness/codex-approvals.mjs +1078 -0
- package/harness/codex-associations.mjs +39 -7
- package/harness/dsh-bridge.mjs +1950 -191
- package/harness/model-route-host.mjs +530 -0
- package/harness/model-routing.mjs +500 -0
- package/harness/session-observer.mjs +217 -0
- package/harness/session-target.mjs +161 -6
- package/harness/task-table.mjs +6 -4
- package/mcp/dsh-mcp.mjs +474 -12
- package/package.json +2 -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,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 测试只证明**官方契约**,不冒充真机结果。
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# DSH 动态模型路由 M0 证据(本插件侧独立复核)
|
|
2
|
+
|
|
3
|
+
范围:`session-dd9655aa-b99f-4e36-b914-a5035fd094e1`(原 DSH 项目会话)实施,Root 独立验收。
|
|
4
|
+
本文件只记录**可复现的真实证据**与**明确缺口**;不冒充远端模型健康、不冒充 Desktop 验收。
|
|
5
|
+
|
|
6
|
+
复核对象:`D:/Program Files (x86)/DSH/resources/app.asar`(SDK 0.2.0-rc.2,只读)。
|
|
7
|
+
复核方式:本插件独立从 asar 只读提取 `lib/*.js` 文本并按行定位(脚本 `.m0-check.mjs`,一次性),
|
|
8
|
+
与 Root 只读笔记 `model-routing-sdk-notes.md` 交叉对照;**下表的行号由本插件自己复核得到**(个别与笔记不同处按本表为准)。
|
|
9
|
+
|
|
10
|
+
## 1. 目录与能力 API(真实存在,非镜像)
|
|
11
|
+
|
|
12
|
+
| 事实 | 本插件复核位置 | 对实现的意义 |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `listProviders()` 只列**当前已注册 adapter** 的路由,按注册顺序 | `dsh-llm/lib/index.js:1899` | 候选只能来自运行时已注册配置;不读 settings/凭据 |
|
|
15
|
+
| `listModels(provider)` 异步、按 provider 取目录,逐 provider 失败可隔离 | `dsh-llm/lib/index.js:2073` | `createModelCatalog` 的 per-provider try/catch 与失败隔离依据 |
|
|
16
|
+
| 无 `getModelsList`(全文 0 处) | — | 不存在的 API 不得使用 |
|
|
17
|
+
| `llm/adapters-updated` 拓扑变更通知 | `dsh-llm/lib/index.js:1806, 1883` | 缓存失效信号(有界 TTL + 主动 invalidate) |
|
|
18
|
+
| 显式不支持的 effort **直接抛错**,不自动 alias/clamp | `dsh-llm/lib/index.js:2178, 2182` | 跨模型时必须清掉不兼容 `reasoningEffort`,绝不沿用 |
|
|
19
|
+
| 失败分类要求按**稳定 code**(禁止解析 message 文本) | `dsh-llm/lib/index.js:127, 150, 253` | 本插件 `classifyFailure` 只认 code/status |
|
|
20
|
+
|
|
21
|
+
**已配置 ≠ 已列出 ≠ 真可调用**:目录/能力解析成功**不**等于凭据、额度或远端健康已验证;元数据不含质量/速度/价格评分,
|
|
22
|
+
`toolUpdate` 是历史投影规则而**不是**"支持工具调用"的布尔。本插件的 `/models`、`capabilities.modelRouting`
|
|
23
|
+
与 MCP/CLI 输出都按此措辞如实标注。
|
|
24
|
+
|
|
25
|
+
## 2. 同 turn/step 的请求恢复点(M2 接线的依据)
|
|
26
|
+
|
|
27
|
+
| 事实 | 位置 | 对实现的意义 |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `prepareRequest(turn, step, signal)` 在每次 attempt 前重新组装 | `dsh-agent-loop/lib/index.js:1050, 1163` | 每个 attempt 都会过 `agent/request` → 可在下一次 attempt 换 pair |
|
|
30
|
+
| `agent/request-error` waterfall 只在**结构化模型失败**时触发 | `dsh-agent-loop/lib/index.js:1124` | 有界切换处理器挂在这里;返回 `{kind:'retry'}` 才同 turn/step 重试 |
|
|
31
|
+
| `firstAttempt` 才追加用户输入 | `dsh-agent-loop/lib/index.js:1048, 1061` | retry **不重派 prompt**(我们的切换不产生重复用户消息) |
|
|
32
|
+
| 首 attempt 的 `agent/request` 早于 accepted `user/message` 落盘 | `:1050`(prepareRequest)先于 `:1061`(append user/message) | 不能靠事后日志 rpc 绑定首请求 → 必须用 `claimed` |
|
|
33
|
+
| `agent/inbox/claimed` 在 claim 时同步发出(真实 agent/message/turn) | `dsh-agent-loop/lib/index.js:106` | 首请求归属的唯一可信入口(`message.source.rpcId` 匹配派活前登记的 requestId) |
|
|
34
|
+
| 工具只在**完整 assistant 消息**之后执行 | `:476, :488`(step 工具调度) | 失败 attempt 的部分 tool-call delta **不会执行**;已执行的 nonce 工具不会重跑 |
|
|
35
|
+
|
|
36
|
+
## 3. 不得使用的旁路(M2 明确回避)
|
|
37
|
+
|
|
38
|
+
| 事实 | 位置 | 本插件的做法 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `sessionController.selectModel` 会 `agentDefaultModel.saveSelection(...)` | `dsh-api-session-controller/lib/index.js:720, 736` | **完全不用**;测试断言全局 default 保存次数为 0 |
|
|
41
|
+
| 原生 retry 有 `normal` / `always` 两种模式,`always` 在**外层**并用 `settleDownstream(next)` 捕获下游抛错后继续 | `dsh-llm-retry/lib/index.js:51, 119, 153, 164, 169`;`settleDownstream` :31–42 | **当前实现(已是修正后行为)**:托管任务的 `agent/request-error` 以 **prepend** 注册并**拥有终态权**——候选耗尽抛 `MODEL_ROUTE_EXHAUSTED`、其它终态直接 `return undefined`(**不调 next**),因此 always 拿不到继续重试的机会;只有**未托管**请求才 `next()` 交还下游。详见 §5 R1 与 §6 W 前的对照表 |
|
|
42
|
+
|
|
43
|
+
## 4. 第一版验收反例与修正(Root 独立复验指出,A–F;**历史记录:旧实现的问题与当时修法**)
|
|
44
|
+
|
|
45
|
+
第一版交付被 Root 独立验收否决,原因**不是**测试计数,而是真实生产行为。逐条修正与对应回归:
|
|
46
|
+
|
|
47
|
+
| # | 反例(Root 实测) | 修正 | 回归 |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| A1 | 完整 bridge 任务 done 后,原 backup 的人工下一 turn 仍走 deepseek(request/header 泄漏) | 恢复改为**逐 agent 真实基线**(首请求时从 `next()` 捕获机器本会用的配置),不再用全局 `lastOverride`/`agents.options[0]`;人类改选优先 | `model-route-host.test.mjs`「泄漏条件恢复(逐 agent)」 |
|
|
50
|
+
| A2 | 单个旧 A 绑定跨 turn 覆盖人工显式 alternate,并把人工 turn 记给 A | 绑定**只在本任务那一个 turn 内有效**:turn 不符立即失活(`retireBinding('turn-moved-on')`),并尝试订阅官方 turn/end | 「A/B 反例回归:绑定只在本任务 turn 内有效」 |
|
|
51
|
+
| A3 | 登记 sessionX 的 task 被真实 sessionY claim 仍绑定 | claim 时核对 **exact live agent + session**(ID 只是关联标识):`agents.get(id)===agent` 且 claimed/live session 与登记一致,否则拒绝并留 `session-mismatch` 审计 | 「A 反例回归:sessionX 被 sessionY claim → 拒绝」 |
|
|
52
|
+
| B | worker 首输入是 `kind:user` 无 rpcId → 一直是 backup;且预分配 ID 错放 `request.childId` | `onClaimed` 增加 **worker 路径**:按预分配 childId == 真实 agent id 唯一匹配;bridge 把 id 放到 **startContinuable 顶层 `spec.childId`**,复用驻留 child 时沿用其 childId | 「B 反例回归:worker 首请求按 childId 绑定」 |
|
|
53
|
+
| C1 | 供应商 retry-after=1000ms 实际 2ms 就重试 | `boundedDelay` 有界等待(尊重 signal/取消/卸载),上限 `maxRetryAfterMs` | 同 C 回归(实测 ≥250ms) |
|
|
54
|
+
| C2 | 原生 `always` 下单候选预算 max2 仍 6 次请求 + 4 个官方 retry | 耗尽时**抛出结构化终止**(`MODEL_ROUTE_EXHAUSTED`)+ 通知宿主 abort 本任务 signal;不再 `return next()` 把控制交回 always | 「C 反例回归:候选耗尽 → 真终止」 |
|
|
55
|
+
| D1 | 官方 `reasoning.efforts` 是 `[{id,name}]`,旧实现只滤 string → 空数组,误拒支持 high 的模型 | `capabilityOf` 同时接受对象/字符串并取 `id`;`defaultEffort` 也兼容对象 | 「E 反例回归」第 1 项(含 `cap.efforts==['low','high']` 断言) |
|
|
56
|
+
| D2 | `resolveModelInfo` 明确抛 `UNKNOWN_MODEL` 仍被当"未知能力"首选 → 不可恢复的准备期失败 | 已知不可解析码(UNKNOWN_MODEL/INVALID_CONFIG/NO_ADAPTER/…)**排除候选**;只有真正的"未知"才标 `capability-unknown` | 「E 反例回归」第 2 项(全部不可用 → `capability-unresolvable`) |
|
|
57
|
+
| D3 | 继承 high 切到不支持 reasoning 的模型 → `UNSUPPORTED_REASONING_EFFORT`,stream 0 | 每次切换按**实际目标能力**处理 `reasoningEffort`(不支持则用其 `defaultEffort`,能力未知则不带)与 `maxTokens`(收敛到目标上限);不删图片/不截历史/不重放工具 | 「D 反例回归:按目标能力处理 effort/maxTokens」 |
|
|
58
|
+
| E1 | 自定义 provider 下的 DeepSeek 模型未获偏好 | 偏好同时看 provider 与**模型 id/name** | 「E 反例回归」第 3 项 |
|
|
59
|
+
| E2 | exact 不产生 alternatives → 显式 `modelFallback:true` 无池可用 | exact 也生成备选池(`allowFallback:false` 时空池=固定);`inherit+allowFallback` 预生成池、首 attempt 用机器配置 | 「E 反例回归」第 4 项 |
|
|
60
|
+
| E3 | 首 provider 目录挂死 → 整次 snapshot 不返回 | 目录发现**逐 provider 有界**(`PROVIDER_DISCOVERY_TIMEOUT_MS`,默认 3s),失败隔离 | 「E 反例回归」第 5 项(实测 <2s 且有其它候选) |
|
|
61
|
+
| F1 | 扁平 `sessionId+model` 202,但 `target.kind=session+model` 400 `invalid-target` | 决策层允许 session 目标带 model(两种书写一致) | 新回归 + 更新旧断言 `session-target.test.mjs`(cwd/lane/commit 仍拒绝) |
|
|
62
|
+
| F2 | 重启后同 origin/task 查询 route/routeAudit 消失;旧"重启测试"只断言空数组也算 array | route/routePolicy 在 **bind 时即落盘**并随每次 persist 更新;恢复分支对**同 origin** 返回 `route`/`routeAudit`(执行状态仍如实 unknown),错 origin 404 | 「F 反例回归」:非空 + 每条 taskId/origin 匹配 + 错 origin 拒绝 |
|
|
63
|
+
|
|
64
|
+
## 5. 第二轮验收反例与修正(Root 第二轮独立复验指出;**历史记录:旧实现的问题与当时修法**)
|
|
65
|
+
|
|
66
|
+
第一轮修正后 Root 复验:完整回归 203/203、policy 5/5、HTTP/恢复 6/6 通过,第一版 10 个原生反例 8 项翻转;仍剩 4 类真实缺陷:
|
|
67
|
+
|
|
68
|
+
| # | 反例(Root 实测) | 修正 | 回归 |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| R1 | 原生 `always` 下单候选预算仍 6 请求 / 4 retry(fixture 取消才停) | 根因:`dsh-llm-retry` 的 always 在**外层**并用 `settleDownstream` 吞掉下游抛错。修正:`agent/request-error` 改 **prepend**;托管任务**拥有终态权**——耗尽抛 `MODEL_ROUTE_EXHAUSTED`、其它终态直接 `return undefined` **不调 next**,非托管才 `next()` | 「第二轮1」:断言 `prepend:true`、托管终态 `nextCalled===0` |
|
|
71
|
+
| R2 | 目录/能力 `await` 期间卸载,返回后仍写 plan/审计、仍把 backup 改成 DeepSeek | **closed + generation 门闩**:`planRoute` 每 await 后校验(取消则 `cancelled:true`、零写零计数);`onRequest` 在 `next()`、选择 seq、`resolveInfo` 三处 await 后复核 `isLive()/binding.active`,不满足即返回机器原配置且不写审计;卸载即释放等待 | 「第二轮2/3e」:await 中卸载 → plan 不落盘、请求返回 backup、无 `first-request` 审计 |
|
|
72
|
+
| R3 | 连续托管 A/B 后 B 把 A 的覆盖当成新用户基线,人工 turn3 仍 DeepSeek | 意图按 **agent** 记录且**跨连续托管任务保留**:仅当机器提出的是"我们上一次的覆盖"时沿用旧基线,否则才更新 | 「第二轮3」①:A→B→人工 turn3 恢复最初 `backup/high` |
|
|
73
|
+
| R4 | 用户真实 `model/selection` 明确重选**同值**仍被恢复成 backup | 用**官方选择事件/seq** 判定:会话里最后一条 `model/selection` 的 seq 晚于我们记录时 → `respect-human-selection`,绝不恢复(不再只比字符串) | 「第二轮3」②:同值重选保持 DeepSeek 且有该审计 |
|
|
74
|
+
| R5 | 恢复回"原无 reasoning 的人工模型"时残留我们带的 `low` → `UNSUPPORTED_REASONING_EFFORT` | 基线保存**完整配置快照**(含"原本没有 effort/maxTokens"):基线没有的字段一律删除 | 「第二轮3」③:恢复后 `reasoningEffort===undefined` |
|
|
75
|
+
| R6 | 真实 registry 中属于**另一 parent** 的 child 因同 childId 仍被接管 | worker 登记 `expectedOwner`(bridge 传真实 owner id);claim 时解析成**当前 live owner 对象**并调官方 `agents.isOwnedBy(childId, owner)`;拿不到/不在 registry/不属于它 → fail-closed | 「B/D」:无 owner、错 owner 都不接管并留审计;正确 owner 才绑定;registry epoch 变化后旧绑定失效 |
|
|
76
|
+
| R7 | `persistTask` 在 bind 前调用,打印 `association update failed: task has no association record` | 持久化移到 `bindAssociation` **成功之后**(route/routePolicy 已由 `bind()` 携带,行为不变) | 「F 反例回归」仍通过且不再出现该日志错误(不隐藏日志、不动旧锁) |
|
|
77
|
+
|
|
78
|
+
## 6. 完整 worker 复用路径修正(Root 用真实 SubagentRuntime/SpawnInProcessProvider 发现;**历史记录:旧实现的问题与当时修法**)
|
|
79
|
+
|
|
80
|
+
> **warm 证据边界(2026-10-17 收尾澄清)**:官方参考脚本证明 **warm(同一 live child 直接复用)** 是官方支持的通道;
|
|
81
|
+
> 但本插件的 **bridge 业务入口** 同一 lane 串行(同桶有在途任务会明确 busy 拒绝),所以实际可达的时序是
|
|
82
|
+
> **"连续任务 + 自然释放后 cold 恢复"**——参见 `test/manual/model-routing-worker-native.mjs` 与 §7 的诚实说明。
|
|
83
|
+
> 下面 W1 中"warm 直接复用"描述的是官方 `subagents.prompt` 的语义(官方参考证据),**不是** bridge 业务入口已证 warm。
|
|
84
|
+
|
|
85
|
+
第一、二轮修正后 Root 用 **44 个与本机 ASAR 字节一致的官方包** + 真实 SubagentRuntime / SpawnInProcessProvider /
|
|
86
|
+
JsonlSessionPersistence / SessionQueryEngine 复现:第一条 worker 成功,**第二条**却调用不存在的
|
|
87
|
+
`subagents.followup` → 降级 one-shot 新建 child(丢 nonce 历史、实际跑 backup 却回选 DeepSeek、attempts=0 却 done)。
|
|
88
|
+
|
|
89
|
+
| # | 反例 | 修正 | 回归 |
|
|
90
|
+
|---|---|---|---|
|
|
91
|
+
| W1 | 继续第二个任务时调用 `subagents.followup`(本机官方包里**不存在**) | 改用官方 **`subagents.prompt({requestId, parentSessionId, childSessionId, mode:'continuable', delivery:'queue', content}, signal)`**:warm 直接复用 live child,cold 由官方 `coldResume` 自然恢复;`requestId` = 本次 taskId(首请求归属靠它);父 = 真实 live owner **Agent id**(官方内部 `agents.get(parentSessionId)` 查找) | 「真实官方 worker」回归:创建→warm→cold 全程同一 durable child |
|
|
92
|
+
| W2 | 失败即 one-shot 新建 child 冒充恢复,并报 done/chosen | **删除 one-shot 兜底**:继续失败 → 任务如实 `error`(`worker-continuation-degraded`),不清 `childId`、不新建 child、不重派 | 同回归 + 兜底分支改错误语义 |
|
|
93
|
+
| W3 | 未保存 durable child 的 `childSessionId` | `startChildWith` 记录 `bucket.childSessionId`(= `started.childId`;warm/cold 同一 id) | 同回归 |
|
|
94
|
+
| W4 | worker 带 rpcId 的 warm/cold claim 跳过 owner 核验 | `onClaimed` 对所有 worker plan(含带 rpcId)核验 `expectedOwner` + 当前 live owner 对象 + 官方 `agents.isOwnedBy`;owner 不在 registry/不匹配 → fail-closed | 「B/D」回归扩展(每 epoch 重新核验) |
|
|
95
|
+
|
|
96
|
+
**本插件侧实测**(Root 探针的同款完整官方服务,`native-workers-production.mjs` 的 scratch 副本):
|
|
97
|
+
第一条 `done`;第二条 **`SAME_DURABLE_WORKER_SUCCESS`**、请求为 `dsh-bridge-child-*/turn 2/priorNoncePresent=true`、
|
|
98
|
+
nonce 仍 **1**、`fallback` 字段消失、routeAudit 含 `plan → first-request-bound → first-request → finish`(此前只有 plan→finish、attempts=0)。
|
|
99
|
+
|
|
100
|
+
## 7. 故障收口与长任务恢复预算修正(Root 第 55 轮独立证据;**历史记录:旧实现的问题与当时修法**)
|
|
101
|
+
|
|
102
|
+
| # | 反例(Root 实测) | 修正 | 回归 |
|
|
103
|
+
|---|---|---|---|
|
|
104
|
+
| F1 | 会话任务三候选全 AUTH:原生已出现 `MODEL_ROUTE_EXHAUSTED` + 同 turn 的 `turn/end reason=error`,但 HTTP 任务仍 `accepted`、审计只有 `stop` 无 `finish` | 根因:`onExhausted` **abort 了自己的观察者/prompt 信号**,丢掉原生结束证据。修正:**不 abort 观察者**;把"路由已给本任务终态"作为**可信本任务终止事实**(`onTerminal`),先给原生日志一次机会(`turn/end` 非 completed → 按原生原因收口),否则 250ms 内用该事实收口 `status=error` + 稳定 `reason=model-route-exhausted` + `routeStopReason` + `finish` 审计;不 cancel 用户 Agent、不清别人 inbox、不碰其他 queue、不重派 | 「C 收口」host 断言(耗尽/禁 fallback 都通知 `onTerminal`、不 abort 观察者)+ Root `probe-exhaustion-status`(→ `status=error, reason=model-route-exhausted, routeStopReason=candidate-limit`,审计含 `finish`) |
|
|
105
|
+
| F2 | 评审第 4 阶段 expert primary + `modelFallback:false` 真实 AUTH 失败一次,网关却 **done / result="(无输出)"** | 根因:`subagent/end` 一到就 done。修正:按官方 `SubagentResult.stopReason` 收口——`error/aborted/refusal/max-tokens/interrupted/failed/cancelled` → **error**(带 `subagent-turn-failed`/`model-route-exhausted` 与真实 stopReason,部分文本只作 `partialResult`,绝不当成功);成功结束文本为空仍 done(**不凭文本空判失败**) | 「失败收口」自动回归(失败 stopReason → error、成功且空文本 → done、评审同样收口)+ Root `native-reviewer-final-contracts`(`reviewerContractsAccepted=true`) |
|
|
106
|
+
| F3 | 默认 `totalTimeoutMs=300000` 从**建任务**算:健康执行 5 分钟后首次 AUTH 直接 `total-time-limit`,备选闲置;`perPairTimeoutMs` 只声明不参与决策 | 预算改为**恢复 episode**:从第一次故障开始计时(健康运行/工具等待不计入);恢复成功(turn/step 前进且无失败)关闭 episode,下次故障重新获得完整恢复权;`perPairTimeoutMs` 真实参与决策(该 pair 在 episode 内超时 → 不再重试它,改切换);retry-after 等待走真实时钟自然计入,**不因每次 retry 重置**;候选/次数/总恢复时长上限保留 | 「恢复预算」M1 假时钟回归(长健康→首次故障可切、单 pair 超时→切换、retry-after 计入总预算、成功后重置)+ Root `probe-recovery-budget-age`(`firstFailureCanSwitch=true`、`timeLimitCausedSwitch=true`) |
|
|
107
|
+
|
|
108
|
+
## 8. 本插件侧结论:哪些已证、哪些仍是缺口
|
|
109
|
+
|
|
110
|
+
**已证(本插件自己的可复现测试)**
|
|
111
|
+
|
|
112
|
+
1. 目录:provider 失败隔离、有界缓存/失效、失败不永久缓存、pair 身份、**逐 provider 有界发现**(E3)。
|
|
113
|
+
2. 选择:DeepSeek 偏好(provider+model,E1)、对象形 efforts(D1)、已知不可解析排除(D2)、
|
|
114
|
+
模态/上下文/effort 不兼容拒绝、精确/歧义拒绝、exact 备选池(E2)、inherit 语义。
|
|
115
|
+
3. 分类与有界:稳定 code 分类、可切/先重试后切/绝不切、HTTP404 只表示路由不可用、未知 code 不切、
|
|
116
|
+
每 pair 次数/候选上限/不重复/冷却/total 时限、**retry-after 有界等待**(C1)、**耗尽真终止**(C2)。
|
|
117
|
+
4. 宿主归属与身份(ctx 替身,语义与官方 hook 签名一致):`agent/inbox/claimed` 首请求认领、
|
|
118
|
+
**exact live agent + session + turn 三重核对**(A2/A3)、worker childId 绑定(B)、
|
|
119
|
+
逐 agent 条件恢复(A1)、按目标能力调 effort/maxTokens(D3)、卸载后绑定失效。
|
|
120
|
+
5. HTTP/MCP/CLI:`/models`、`capabilities.modelRouting`、auto 回显、精确/歧义零投递拒绝、
|
|
121
|
+
**双入口(扁平/显式 target)一致**(F1)、**重启后按 origin/task 取回非空 route/routeAudit 且错 origin 404**(F2)、
|
|
122
|
+
旧网关新语义零派活、评审入口接入同一任务级机制 + 四入口同契约(第 54 轮)。
|
|
123
|
+
6. 失败收口与恢复预算(第 55 轮):耗尽/禁 fallback 的会话任务按**可信本任务终止事实**收口
|
|
124
|
+
`error + model-route-exhausted + routeStopReason + finish`(不 abort 观察者、不 cancel Agent、不碰其他 queue);
|
|
125
|
+
worker/评审按官方 `stopReason` 判失败(失败绝不 done、也不凭空文本判失败);
|
|
126
|
+
恢复预算按 **episode** 计(健康时长不计入、perPair 真实生效、retry-after 计入、成功后重置)。
|
|
127
|
+
|
|
128
|
+
**仍是缺口(如实声明,不假造支持)**
|
|
129
|
+
|
|
130
|
+
1. **worker 继续通道的 warm 分支**:官方参考脚本已证 warm(同一 live child 直接复用)可行,但本插件的 bridge
|
|
131
|
+
业务入口同 lane 串行(同桶在途任务会 busy 拒绝),实际可达的是**连续任务 + 自然释放后 cold 恢复**;
|
|
132
|
+
仓库里因此不提供自动 warm 回归。Root 的独立证据覆盖"worker 新建 + 两次自然 cold 恢复"
|
|
133
|
+
(同 durable child / 3 live epochs / turn 1–3 / 每任务模型策略 / 真 tool-result / nonce 1 / wrong-parent 拒绝)。
|
|
134
|
+
2. **worker `spec.childId` 与 owner 的最终判定**归 Root 独立证据;`capabilities.modelRouting.workerFirstRequestProven` /
|
|
135
|
+
`workerContinuationProven` 在本插件侧保持 **false**(不自我认证)。
|
|
136
|
+
3. 跨回合人工 turn 的 request/header 泄漏:本插件用逐 agent 基线 + 官方 `model/selection` seq 处理,
|
|
137
|
+
端到端由 Root 夹具对照(含"同值人工重选"与"连续托管"两个反向案例)。
|
|
138
|
+
4. `prepareCall` 期竞态(预检后目录再变:预检排除已证不可用候选 D2,但预检后目录变化无通用恢复 hook)
|
|
139
|
+
与 persisted child 的冷恢复通道如实不接管/不重派,不冒充恢复。
|
|
140
|
+
5. 模态/上下文按实际请求的判定:仅在调用方给出 `needs` 时过滤;运行中切换不从消息体推导图片/历史长度,
|
|
141
|
+
"目标模型上下文更小"的极端情形尚无端到端证明(不删图片/不截历史是硬约束,宁可失败也不降级凑兼容)。
|
|
142
|
+
|
|
143
|
+
## 9. 复现方式
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
cd E:/11-Github/dsh-codex-collab
|
|
147
|
+
node --test test/*.test.mjs # 全部自动回归(当前 213,含 M1/M2/HTTP/MCP/CLI/入口/收口/预算与本插件侧反例)
|
|
148
|
+
node test/model-routing.test.mjs # M1 策略矩阵(含 E 反例 5 项)
|
|
149
|
+
node test/model-route-host.test.mjs # M2 hook 语义/归属/身份(含 A/B/C/D 反例 6 项)
|
|
150
|
+
node test/model-routing-integration.test.mjs # 真实 HTTP/MCP/CLI 接线(含 F 反例)
|
|
151
|
+
node test/model-routing-sdk-contracts.mjs # 本文件第 1–3 节行号复核(只读 asar)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**手工入口(不计入自动回归计数)**:`test/manual/model-routing-worker-native.mjs`(连续任务 + 两次自然释放 cold 恢复)
|
|
155
|
+
与 `test/manual/model-routing-review-native.mjs`(评审默认路由 + 同 turn/step 故障恢复 + nonce 恰一次 + 第二条评审 cold)
|
|
156
|
+
都用 Root 的 `runtime-fixture.mjs`(真实官方组合夹具,公共仓库无法把它当可移植回归):
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
DSH_ROUTING_NATIVE_FIXTURE=<...>/model-routing-native-review/runtime-fixture.mjs node test/manual/model-routing-worker-native.mjs
|
|
160
|
+
DSH_ROUTING_NATIVE_FIXTURE=<...>/model-routing-native-review/runtime-fixture.mjs node test/manual/model-routing-review-native.mjs
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
未配置该变量时以 exit 2 明确报"未运行"(不是 pass);配置了但文件缺失/加载失败则以非零失败。
|
|
164
|
+
|
|
165
|
+
`test/model-routing-sdk-contracts.mjs` 是**只读**脚本:不注册插件、不请求模型、不读凭据,只按上文行号打印命中项;
|
|
166
|
+
无 asar 的机器会打印 MISSING 而不是假装通过。
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/** Only the bounded tail is decoded; unchanged files are never decoded again. */
|
|
4
|
+
export const createAuditTailReader = ({ file, fallback, maxBytes = 1024 * 1024 }) => {
|
|
5
|
+
let identity = null, records = []
|
|
6
|
+
const counters = { reads: 0, bytes: 0, truncated: false }
|
|
7
|
+
return {
|
|
8
|
+
read(taskId, limit = 50) {
|
|
9
|
+
const name = file()
|
|
10
|
+
if (!name) return fallback().filter(e => taskId === undefined || e.taskId === taskId).slice(-limit)
|
|
11
|
+
let fd
|
|
12
|
+
try {
|
|
13
|
+
const stat = fs.statSync(name)
|
|
14
|
+
const key = [name, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':')
|
|
15
|
+
if (key !== identity) {
|
|
16
|
+
fd = fs.openSync(name, 'r')
|
|
17
|
+
const size = Math.min(stat.size, maxBytes), offset = stat.size - size
|
|
18
|
+
const buffer = Buffer.alloc(size)
|
|
19
|
+
const read = fs.readSync(fd, buffer, 0, size, offset)
|
|
20
|
+
let text = buffer.subarray(0, read).toString('utf8')
|
|
21
|
+
if (offset > 0) { const boundary = text.indexOf('\n'); text = boundary < 0 ? '' : text.slice(boundary + 1) }
|
|
22
|
+
records = []
|
|
23
|
+
for (const line of text.split('\n')) { try { const value = JSON.parse(line); if (value && typeof value === 'object') records.push(value) } catch { /* partial/bad audit rows are not execution evidence */ } }
|
|
24
|
+
identity = key; counters.reads++; counters.bytes += read; counters.truncated = offset > 0
|
|
25
|
+
}
|
|
26
|
+
return records.filter(e => taskId === undefined || e.taskId === taskId).slice(-limit)
|
|
27
|
+
} catch { return fallback().filter(e => taskId === undefined || e.taskId === taskId).slice(-limit) }
|
|
28
|
+
finally { if (fd !== undefined) fs.closeSync(fd) }
|
|
29
|
+
},
|
|
30
|
+
status: () => ({ ...counters }),
|
|
31
|
+
}
|
|
32
|
+
}
|