← 回到 infra 主页

infra · 上游 issue 草案 · #17 · 2026-09-22

请把 goal 的轮次上限暴露给用户 —— 用户看到的「目标到此轮次用尽而停(60/60)」不是 DSH 的缺省值,而是模型在 create_goal 里自己填的一个可选参数;一填就把两层部署缺省整个旁路,而用户既看不见、也没有任何入口去设

一句话:DSH 侧的轮次上限缺省只有两层 —— 插件 schema 的 256、部署配置 $DSH_HOME/cordis.patch.yml 里的 defaultMaxGoalRounds: 10000。 那个 60 是模型建 goal 时自己填的 max_goal_rounds;它被原样持久化成该 goal 的 maxGoalRounds,此后任何部署配置都改不动它。 用户侧零可见、零可配:界面不显示上限,宿主侧没有写接口,唯一的修改通路要求人类亲自开一轮会话。

判别实验把三条来源分得很干净:上限为 60 的 25 个会话 25/25 都能在事件流里找到一次显式传参; 上限为 256 的 14 个会话里只有 2 个显式、其余 12 个取 schema 缺省;另一户同一会话三次 create 都没传参, 2026-09-20 16:39 得 256(早于补丁)、09-20 23:27 与 09-21 09:50 各得 10000(晚于补丁 + 重启) ⇒ 部署配置这条链工作正常,问题在「模型一传参就把它旁路」。全部 236 个(会话, 上限值)对里,只有 13 个来自两层缺省,其余 223 个是模型自己填的。

0 · 结论:值来自模型,可见性与可控性的缺失来自 DSH

  • 不是"DSH 默认 60 轮"。全仓/全配置里没有 60 这个缺省:DSH 的缺省只有 256(插件 schema ①)与 10000(部署配置 ②)两层。把 60 说成"默认值"是这次事故最容易犯的误判。
  • 是模型填的可选参数。create_goal 的入参 max_goal_rounds 可选、无下限无上限、描述里没有任何数值指引;代码用 request.maxGoalRounds ?? defaultMaxGoalRounds 解析,所以只要模型填了,两层缺省连读都不读。
  • 用户侧零可见。47 个 dsh-client-ui-* 包无一处引用 maxGoalRounds;唯一能看到它的时机是 /goal 命令的一次性只读输出 Rounds: n/max,且只有已经建了 goal 之后才看得到。
  • 用户侧零可配。/goal 自述没有轮次参数;dsh-api-session-controller/lib/index.js 里 goal 出现 0 次(没有写接口);改一个已建 goal 的唯一通路是人类开口让模型调 update_goal(edit),而 edit/resume 都 requireDirectHuman —— 模型在自主轮次里自己救不了自己。
  • 撞墙时只有一行英文。round-limit 停机把 goal 置为 blocked、自主续轮停止、长任务停在半路;UI 上没有"提高上限并继续"的按钮,也没有把这件事翻成人话。实测 15 个会话真的撞了墙。

一句话诉求:①把上限做成用户可读可写的设置(goal 卡片常显 Rounds: n/max);②让模型不能无声压过部署默认(移除入参 / 加下限 / 低于默认时要求确认,三选一);③round-limit 停机给一个可操作出口。详见 §5 与 §6。

1 · 三层优先级:60 不在其中任何一层

goal 的「轮次上限」=一次目标最多自动续多少轮;超过就触发 round-limit 停机。它在 DSH 里只有三层来源,没有第四层,也没有 60。 表里层号越小越"底",但只要更靠上的一层给了值,下面两层就一起失效。

层值锚点(源码 / 配置)何时生效
① 插件 schema 缺省
schema=插件给自己配置项声明的默认值
256packages/goal/goal/src/index.ts:187
已装内核对应 @deepseek-ai/dsh-goal/lib/index.js:588
部署没配、且调用方没传
② 部署配置10000$DSH_HOME/cordis.patch.yml 的 - id: goal → config.defaultMaxGoalRounds;由 ops/dsh/set-goal-rounds.py 写入(2026-09-20 19:58,49 份配置=25 户的两通道;只在 boot 读)调用方没传时
③ 逐 goal 显式值
就是模型填的那个参数
模型传什么就是什么packages/goal/goal/src/index.ts:158-161:
maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds)
建 goal 那一刻定死;此后只认 update_goal(action="edit")

关键在这个 ??(nullish coalescing:左边是 null/undefined 时才取右边): request.maxGoalRounds 一旦有值,右边的 defaultMaxGoalRounds 连读都不读 —— 模型的一次传参就把 ② 和 ① 一起旁路。这就是"部署侧明明配了 10000、实测仍有 goal 停在 60"的结构性原因。

那个可选参数是「模型的自由输入」:描述里没有任何数值指引

// packages/goal/tool-goal/src/index.ts:216-219(create_goal 的入参)
max_goal_rounds: {
  type: 'number',
  description: 'Optional positive safe-integer limit on automatic continuation rounds.',
},
// :227 —— 原样透传:无下限、无上限、不跟部署默认做任何比较
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },

描述里既没写"建议多少轮",也没写"留空即用部署默认" ⇒ 取值完全是模型即兴决定:实测它填过 3,也填过 100000。

goal 轮次上限取值分布:30 个取值 / 236 个(会话, 上限值)对;插件 schema 缺省 256 落在 14 个会话里、部署配置缺省 10000 落在 3 个会话里,其余 28 个取值共 219 个全部来自模型显式传参 goal 的轮次上限取了多少种值、各落在多少个会话里(30 个取值 / 236 个(会话, 值)对) 横条长度 = 上限集合里含该值的会话持久化件数(一个会话件最多计一次);一个会话里先后建过多个 goal、或改过上限,都会留下多个值。 155 个带 goal 的会话件合计 236 个(会话, 值)对 —— 本图数的是「含该值的会话件数」,与 README 的「会话」口径一致。 模型显式传参:28 个取值 / 219 个(会话, 值)对(占 92.8%) 插件 schema 缺省 ① = 256:14 个会话(其中 2 个为显式传参) 部署配置缺省 ② = 10000:3 个会话(其中 2 个为显式传参) 红框 ★ = 60:用户看到的「60/60」就是这个值,25 个会话全部来自显式传参 10 20 30 3 2 6 9 8 8 10 16 12 29 14 2 15 5 20 6 24 1 25 1 30 14 40 33 50 5 60 25 ★ 70 1 80 10 100 1 120 10 200 9 256 14 300 4 400 5 500 16 512 1 600 1 756 1 800 1 1200 1 10000 3 100000 2 读法:只有 256 与 10000 这两个值可能是「缺省」(合计 17 个会话);其余 28 个取值、219 个(会话, 值)对,全部(100%)来自模型在 create_goal 里自己填的 max_goal_rounds。 被用户看见的那次停机是「60/60」:60 落在 25 个会话里,每个都能对上事件流里一次显式传参(25/25),没有一个是缺省来的 —— 两层缺省里根本没有 60 这个数。
图 A · 上限取值分布:30 个取值 / 236 个(会话, 上限值)对,两层缺省只占 17 个。 怎么读:图的纵轴每一行是一个上限值,横条长度=上限集合里含该值的会话件数(一个会话件最多计一次);蓝条(256)是插件 schema 缺省 ①、橙条(10000)是部署配置缺省 ②,其余 28 个绿条全部(100%)来自模型自己填的 max_goal_rounds。 所以"用户看到的上限"绝大多数不是 DSH 定的,而是模型定的;红框 ★ 的那一行 60 就是用户实际撞到的「60/60」,它一个缺省都不是(25 个会话全部对得上显式传参)。
逐值明细(30 个取值 × 会话数 / 显式传参 / 仅取缺省 / 撞墙会话数)—— 与图 A、图 B 同源
上限值会话数其中显式传参仅取缺省撞墙会话数备注
3220—模型填的值
6990—模型填的值
88801模型填的值
1016160—模型填的值
1229290—模型填的值
14220—模型填的值
15550—模型填的值
206601模型填的值
241101模型填的值
25110—模型填的值
301417n/a1显式传参(17) > 会话数(14):同一会话里也传过别的值(多个 goal 或被 edit 过),该值已不是最终上限
403335n/a4显式传参(35) > 会话数(33):同一会话里也传过别的值(多个 goal 或被 edit 过),该值已不是最终上限
50550—模型填的值
60252504★ 用户看到的「60/60」
70110—模型填的值
80101002模型填的值
1001101模型填的值
1201012n/a2显式传参(12) > 会话数(10):同一会话里也传过别的值(多个 goal 或被 edit 过),该值已不是最终上限
200911n/a1显式传参(11) > 会话数(9):同一会话里也传过别的值(多个 goal 或被 edit 过),该值已不是最终上限
256142122插件 schema 缺省 ①
300440—模型填的值
400550—模型填的值
50016160—模型填的值
512110—模型填的值
600110—模型填的值
756110—模型填的值
800110—模型填的值
1200110—模型填的值
10000321—部署配置缺省 ②
100000220—模型填的值
合计2362231320「显式传参」列逐值取 min(会话数, 显式传参) 再求和(236 − 13 = 223):同一个会话先后传过多个值(48/160/180 传过但未留在最终上限集合里),所以这一列不能简单相加。「仅取缺省」= 逐值正差之和 = 256 的 12 + 10000 的 1。

口径:一条=一个会话持久化件(量具按文件聚合,一个件里先后建过多个 goal、或改过上限,都会留下多个值)。会话数取 budget_distribution,显式传参取 explicitly_asked,撞墙会话数取 stopped_at_limit;「仅取缺省」=两者相减。少数值出现"显式传参 > 会话数",是因为同一个会话里也传过别的值(多个 goal 或被 update_goal(edit) 改过),该值已不是最终上限 —— 此时相减无意义,按 n/a 记。

2 · 判别实验:三条来源分得很干净

判据不是"两个合计相等",而是逐个会话件地问两件事:这个会话件的上限集合里有没有这个值?它的事件流里有没有显式传参? 下面四条 jq 直接跑在机读件上,输出就是结论。

$ J=ops/dsh/upstream-issue-goal-round-cap-model-only/evidence-20260922.json
$ jq '[.goals[] | select(.budgets | index(60))] | length' $J                          # 25
$ jq '[.goals[] | select((.budgets|index(60)) and (.asked|index(60)))] | length' $J    # 25 ⇒ 25/25 全带显式传参
$ jq '[.goals[] | select(.budgets | index(256))] | length' $J                          # 14
$ jq '[.goals[] | select((.budgets|index(256)) and (.asked|index(256)))] | length' $J   # 2  ⇒ 另 12 条取 schema 缺省
上限值会话数其中显式传参仅取缺省这条说明了什么
602525(25/25)0每个都能对上事件流里一次 "max_goal_rounds": 60 的工具调用(create_goal 或 update_goal edit)⇒ 60 不可能是任何缺省值(两层缺省里没有这个数)。
25614212反向对照,证明这把尺子分得清两条路:这 12 个会话没传过参,值只可能来自 schema 缺省 ①。
10000321剩下这 1 个会话没传参 —— 正是 §2.1 判别实验里补丁+重启之后那次 create,值来自部署配置 ②。
上限 60 / 256 / 10000 的显式传参与仅取缺省对照(按会话件计):60 显式 25/仅取缺省 0、256 显式 2/仅取缺省 12、10000 显式 2/仅取缺省 1 三个上限值的来源拆解:模型显式传参 vs 模型没传、只可能来自缺省 每一行是上限集合里含该值的会话持久化件数(一个会话件最多计一次);绿=能找到 max_goal_rounds 显式传参,蓝/橙=没传参、只可能来自两层缺省。 三个值合计 42 个会话:显式传参 29 个 / 仅取缺省 13 个(256 的 12 个走 schema ①,10000 的 1 个走部署配置 ②)。 上限 = 60 两条 DSH 缺省里都没有 60 这个数;用户看到的「60/60」就是它 | 撞墙 4 个会话 25 合计 25 个会话(显式 25 + 仅取缺省 0) 上限 = 256(插件 schema 缺省 ①) packages/goal/goal/src/index.ts:187(已装内核 dsh-goal/lib/index.js:588) | 撞墙 2 个会话 2 12 合计 14 个会话(显式 2 + 仅取缺省 12) 上限 = 10000(部署配置缺省 ②) 由 ops/dsh/set-goal-rounds.py 写入 $DSH_HOME/cordis.patch.yml(只在 boot 读) 2 1 合计 3 个会话(显式 2 + 仅取缺省 1) 显式传参=模型在 create_goal/update_goal 里自己填了 max_goal_rounds 仅取缺省 256(schema ①)=模型没传参数,值来自 packages/goal/goal/src/index.ts:187 的 default(256) 仅取缺省 10000(部署配置 ②)=模型没传参数,值来自 $DSH_HOME/cordis.patch.yml 的 defaultMaxGoalRounds 读法:60 那一行没有蓝/橙段(仅取缺省 = 0)⇒ 60 只可能是模型填的,不可能是任何缺省;256/10000 上出现的蓝/橙段才是「部署侧以为配了、实际由缺省决定」的那部分。
图 B · 同样是"上限 60/256/10000",来源完全不同。 怎么读:每一行是上限集合里含该值的会话件数,绿段=模型显式传参、蓝/橙段=模型没传参(只能来自缺省)。 60 那一行没有蓝/橙段(仅取缺省 = 0)⇒ 25 个会话全是模型填的;256 的 14 个里 12 个落在蓝段(schema 缺省 ①); 10000 的 3 个里有 1 个落在橙段(部署配置 ②,补丁+重启后模型没传参那一次)。三个值合计 42 个会话:显式 29 个、仅取缺省 13 个。

2.1 ② 层本身是有效的:同一会话三次 create 都没传参

某户同一会话里的三次 create 正好构成一条天然判别实验(三次都没传 max_goal_rounds),时间戳从会话事件流原样取出:

$ grep '"kind":"goal/change"' <解压后的会话件> | grep -o '"time":[0-9]*\|"operation":"[a-z]*"\|"maxGoalRounds":[0-9]*' | …
1789893552864  create  maxGoalRounds:256     # 2026-09-20 16:39:12,早于补丁写入(19:58) ⇒ schema 缺省 ①
1789918054254  create  maxGoalRounds:10000   # 2026-09-20 23:27:34,晚于补丁+重启 ⇒ 部署配置 ② 生效
1789955404528  create  maxGoalRounds:10000   # 2026-09-21 09:50:04

⇒ 配置链(256 → 10000)工作正常。问题不在"配置写不进去",而在"模型一传参就把它整个旁路":运维把 defaultMaxGoalRounds 从 256 调到 10000 之后,实测仍有 25 个会话的上限停在模型给的 60。

2.2 量级:同一个部署里,上限可以差 3 万倍

模型填过的上限值横跨 5 个数量级:3, 6, 8, 10, 12, 14, 15, 20, 24, 25, 30, 40, 50, 60, 70, 80, 100, 120, 200, 256, 300, 400, 500, 512, 600, 756, 800, 1200, 10000, 100000。 同一个部署、同一类长任务,停机点却由模型建 goal 时随手填的一个数决定 —— 无法运维、无法审计、无法复现。

口径警告(留给下一席):60 这类上限值不是"默认值"。写报告或对用户解释时不许写成"DSH 默认 60 轮"——那是模型填的参数;DSH 的缺省只有 ①256 / ②10000 两层。本节的两个判定实验就是为拦住这个误判留的。

3 · 三条缺陷面:值错在模型,可见性/可控性/出口缺在 DSH

#缺陷可复跑判据(判据行 / 原始命令)
①零可见
没有任何界面显示这个上限
47 个 dsh-client-ui-* 包里 grep -rl maxGoalRounds dsh-client-ui-*/ ⇒ 0 命中;唯一可见时机是 /goal 的一次性输出 Rounds: n/max(@deepseek-ai/dsh-command-goal/lib/index.js:71),且要先有 goal。
②零可配
宿主侧连写接口都不存在
/goal 自述"无轮次参数"(packages/goal/command-goal/README.md:56);dsh-api-session-controller/lib/index.js 里 goal 出现 0 次、lib/client.js 只转发 "goal/change" 事件;部署配置只管新建。
③撞墙后没有出口
只有一行英文 + 停轮
packages/goal/goal-round-driver/src/index.ts:166-170 命中上限即 block(code:'round-limit');实测 15 个会话真撞墙(判据=真实 block 事件里 blockedReason.code == "round-limit");UI 无"提高上限并继续"动作。

① 零可见:用户对轮次上限没有任何界面

  • 客户端 UI 面零引用。在已装内核里数 dsh-client-ui-* 共 47 个包,无论 lib/ 还是整包,引用 maxGoalRounds 的文件数都是 0。
  • 引用它的全是 host/类型面:typert.host.js(RPC 类型 codegen)、dsh-api-remotes/lib/client.js(RPC 客户端 schema)—— 也就是"能传",但没有一处"给人看"。
  • 唯一可见时机是 /goal 的只读输出,而且只有已经建了 goal 之后才看得到:
Status: blocked
Blocker: round-limit: Goal reached its configured limit of 60 rounds.
Objective: …
Rounds: 60/60          ← 用户看到的「(60/60)」就是这一行

goal 卡片与会话视图里既不显示上限、也不显示"还剩多少轮":用户在停机前完全没有机会知道存在这个上限。

② 零可配:三个通道全都不通

通道现状证据
/goal 命令无轮次参数(官方自述)packages/goal/command-goal/README.md:56:"No per-command round-cap argument — defaultMaxGoalRounds remains deployment config"
设置页 / GUI无控件,且无可用 APIdsh-api-session-controller/lib/index.js 里 goal 出现 0 次;lib/client.js 只把 "goal/change" 当事件转发(不能写)
部署配置 defaultMaxGoalRounds只管新建,已有 goal 一律不受影响ops/dsh/set-goal-rounds.py 文件头 "not_done" 清单第 3 条(官方语义:只在 boot 读、只对之后创建的 goal 生效)

⇒ 改一个已经建好的 goal,唯一通路是人类在会话里说一句话,让模型去调 update_goal(action="edit");而 edit/resume 两个动作在 packages/goal/tool-goal/src/index.ts:265/273 都 requireDirectHuman(已装包对应 lib/index.js:343/349), 模型在自主轮次里自己救不了自己。而用户此刻往往已经离开。

③ 撞墙后的体验:一条英文句子,没有任何续期入口

// packages/goal/goal-round-driver/src/index.ts:166-170
if (goal.roundsStarted >= goal.maxGoalRounds) {
  ctx.goals.block(agent, goalRef(goal), {
    code: 'round-limit',
    message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`,
  })
  return
}

block ⇒ goal 变 blocked、自主续轮停止、整个长任务停在半路;UI 上既没有"提高上限并继续"的按钮,也没有把"要续期"翻译成人话。 实测有 15 个会话真的撞了墙(判据=真实 block 事件里 blockedReason.code == "round-limit",不是"会话里出现过这句话"——量具为此专门带了两条假阳性回归)。

撞墙时的上限会话数说明
81模型填的值
201模型填的值
241模型填的值
301模型填的值
404模型填的值
604★ 用户看到的那一次
802模型填的值
1001模型填的值
1202模型填的值
2001模型填的值
2562含 schema 缺省 ①
合计20真撞过墙的会话 15 个(同一会话可能在多个上限值上撞过墙)

本表按「撞墙时的上限值」计数、合计 20 次;上文的 15 是真撞过墙的会话数 —— 同一个会话里可能在多个上限值上撞过墙(被 edit 抬上限后继续跑、再撞一次),两个数不矛盾。

4 · 影响

  1. 长任务中途停机,而且是"计划外"的:用户交办的是"持续做到完成",停机点却由模型建 goal 时随手填的一个数决定(3 到 100000 都出现过)。
  2. 同一个部署里同类任务的上限相差 3 万倍(§2.2),无法运维、无法审计、无法复现。
  3. 部署侧唯一的旋钮被静默旁路:运维把 defaultMaxGoalRounds 从 256 调到 10000(49 份配置、25 户两通道)之后,实测仍有 25 个会话的上限停在模型给的 60 —— 配置看起来"配了",实际对绝大多数 goal 不起作用(沉默失效:看着在、其实不覆盖)。
  4. 用户没有修复路径:要抬上限必须重开人类轮次(§3②),而用户此刻往往已经离开;撞墙后只剩一行英文。
  5. 小上限还会把"长任务"退化成"短任务":模型填 3/6/8 这类值时,一个本该跑到完成的目标会在几步内被判 round-limit,用户看到的是"目标被 blocked",而不是"上限太小"。

5 · 建议(任一条都能把「60/60 静默停机」变成"用户知道的、可调的")

  1. 把上限暴露成用户可读可写的设置:设置页与 /goal 命令各加一个轮次上限的读/写项(写入走 update_goal(edit) 同一套 CAS 语义),goal 卡片上常显 Rounds: n/max。
  2. 让模型不能无声地压过部署默认(三种任选其一):①create_goal 移除 max_goal_rounds(一律走部署配置);②保留但加下限 max(传入值, defaultMaxGoalRounds);③传入值低于部署默认时要求用户确认。
  3. round-limit 停机要给可操作出口:把 code 映射成人话("目标用到第 60 轮上限已停,可提高上限继续")+ 一个"提高上限并继续"的动作 —— 现在只有一行英文,且 edit/resume 还要求人类轮次,等于把恢复成本全推给用户。

本地已做的是缓解、不是修复:我们只能把部署侧那颗旋钮(ops/dsh/set-goal-rounds.py → defaultMaxGoalRounds: 10000)拧到位;它对"模型已经填了参数"的 goal 完全无效。产品侧的能力本该由 DSH 提供,而不是每个部署各自维护一套脚本。

6 · 验收判据(改完必须能这样复跑)

# A. 用户可配:不带模型参与,把已 blocked 的 goal 上限从 60 改到 500 并续跑 ⇒ 应能成功
#    (今天不可能:宿主侧无接口,见 §3②)
# B. 模型不能旁路部署默认:把 defaultMaxGoalRounds 设为 500,模型 create_goal(max_goal_rounds=5)
#    ⇒ 落盘 maxGoalRounds 不应小于 500(按建议 2 的②)
$ sudo -n -u user-k sudo -n python3 ops/dsh/scan-session-goals.py --roots <该会话目录> --json /tmp/one.json
$ jq -r '.goals[] | "budget=\(.budgets) asked=\(.asked)"' /tmp/one.json
# C. 可见性:会话界面在 goal 存在时常显 Rounds: n/max(今天只有 /goal 命令一次性输出)
# D. 本 issue 的量具自证(尺子看得见红)
$ python3 ops/dsh/scan-session-goals.py --selftest
SCAN_SESSION_GOALS_SELFTEST PASS checks=12 bad=0

A/B 判据的落点就是本页用的量具:它逐条读会话持久化件,产出 budgets(该会话件的上限集合)与 asked(事件流里显式传过的上限), 所以"模型有没有旁路部署默认"是一次jq 就能问出来的事。

7 · 复现与证据落点

量具与判定行

  • 量具:ops/dsh/scan-session-goals.py(--selftest 12 断言,含两条假阳性回归:光"讨论"停机文案不计入、非 round-limit 的 block 不计入)。
  • 全量普查(25 户 × ~/.dsh/~/.dsh-alpha 两通道):files=20457 readable=20457 … verdict=PASS(100% 可读);带 goal 的会话数由聚焦扫描给出(下一行)。
  • 自证判定行:SCAN_SESSION_GOALS_SELFTEST PASS checks=12 bad=0。
  • 聚焦件判定行:files=160(候选会话件)、with_goal=155(带 goal 的会话件)、failed=0、errors=0、verdict=PASS —— 本页所有数字都出自这一份。

机读件与原始记录

  • 本目录瘦身版(去掉 objective 正文,免得把别户目标内容铺在页面上):ops/dsh/upstream-issue-goal-round-cap-model-only/evidence-20260922.json。
  • 逐会话机读件:[storage]/…/scan-session-goals/goals-focused.json(155 个带 goal 的会话件)、goals-all-users.json(全量 20,457 件)。
  • 扫描日志:[storage]/…/logs/scan-all.log、…/logs/scan-focused.log。
  • 相关既有件:ops/dsh/set-goal-rounds.py(部署侧那颗旋钮)、ops/dsh/round-default-recon-20260920.json(缺省值侦察)。

本页两张图的自证

  • 图 A/图 B 的每个数字都由脚本从 evidence-20260922.json 现算后写死进 SVG(没有手抄):生成器 mkfigs-figA.py/mkfigs-figB.py,判定行 FIG_A_VERDICT PASS/FIG_B_VERDICT PASS。
  • 几何自检:SVG_FIT_PASS(全部文本与矩形落在 viewBox 内、坐标不被裁)。

口径说明:本页的"会话数"=机读件 goals[] 里的一条记录,而一条记录就是一个会话持久化件(量具 scan_one() 按文件聚合上限集合)。 一个件里先后建过多个 goal、或对同一个 goal 改过上限,都会留下多个值,所以 155 个带 goal 的会话件合计 236 个(会话, 上限值)对;这与中文材料 README.md §1.3 的"25 个会话 / 14 个会话"是同一口径。

证据强度与未主张

  • 已实测:236 个(会话, 上限值)对的两层缺省/显式传参拆分(25/25、14 vs 2、3 vs 2);三次未传参 create 的时间线;15 个会话真撞墙;dsh-client-ui-* 0 命中、dsh-api-session-controller/lib/index.js 里 goal 0 次;47 个 UI 包的枚举数。
  • 代码/文件直读:三层优先级与 ?? 旁路(四处源码锚点)、requireDirectHuman 两处锚点、/goal 无参数的自述、部署配置"只管新建"的既有结论。
  • 未主张:①本页没有重启任何服务、没有改任何配置,全部只读采集;②"60 不是缺省值"由源码两处 default 直读 + 49 份部署配置写入记录支撑,未逐户重扫部署配置;③上游仓库里我们没有复现过这条缺陷,观测面是本部署的会话持久化件;④"模型为什么填 60"没有归因(本 issue 只主张"值来自模型 + 用户不可见不可配")。

8 · 可直接粘贴的英文正文(issue body)

Title

Expose the goal round cap to users: the "60/60" round-limit stop is the model's own create_goal(max_goal_rounds=...) argument, and it silently bypasses both DSH-side defaults

Summary

`create_goal` takes an optional `max_goal_rounds` parameter. Once the model supplies it, the value is
persisted verbatim as that goal's `maxGoalRounds` and both DSH-side defaults are bypassed: the plugin
schema default `256` (`packages/goal/goal/src/index.ts:187`) and the deployment default
`defaultMaxGoalRounds` in `$DSH_HOME/cordis.patch.yml`, resolved through
`request.maxGoalRounds ?? defaultMaxGoalRounds` (`packages/goal/goal/src/index.ts:158-161`).

The user cannot see the cap, cannot set it, and first learns it exists when the goal is blocked with
`round-limit: Goal reached its configured limit of 60 rounds.` and a long-running task stops mid-flight.
The cap in that message is not a DSH default: `60` appears in no default layer anywhere.

Evidence: where each cap value came from

A full census of 20,457 persisted session files (25 users, both channels, 100% readable) leaves 160
candidate files in the focused scan, 155 of which carry goal records. Counting one pair per (session,
cap value) gives 236 pairs in total.

- 25 sessions have `60` in their cap set, and 25/25 of them also passed `max_goal_rounds: 60`
  explicitly somewhere in the event stream (a `create_goal` or `update_goal edit` call). Not one of
  them came from a default.
- Of the 14 sessions whose cap set contains `256` (the schema default), only 2 passed it explicitly:
  the other 12 took the schema default.
- Of the 3 sessions whose cap set contains `10000` (the deployment default), 2 passed it explicitly
  and 1 took the deployment default.
- Only 13 of the 236 pairs come from either default layer; the other 223 were chosen by the model,
  and the values it picked span five orders of magnitude (`3` ... `100000`).

The deployment layer itself works. In one session, three `create_goal` calls that passed no
`max_goal_rounds` at all produced `maxGoalRounds: 256` at 2026-09-20 16:39:12 (before the deployment
patch was written at 19:58) and `maxGoalRounds: 10000` at 2026-09-20 23:27:34 and 2026-09-21 09:50:04
(after the patch and restart). So the problem is not "the setting does not take effect"; it is that a
model-supplied argument silently overrides it.

Defect 1 — the user cannot see the cap

- No `dsh-client-ui-*` package references `maxGoalRounds` (47 packages, 0 files, `lib/` or whole
  package). The packages that do reference it are host/type-plane only: `typert.host.js` RPC type
  codegen and `dsh-api-remotes/lib/client.js`.
- The only user-visible surface is the one-shot `/goal` command output
  (`@deepseek-ai/dsh-command-goal/lib/index.js:71`), which prints `Rounds: n/max` — and only after a
  goal already exists:

      Status: blocked
      Blocker: round-limit: Goal reached its configured limit of 60 rounds.
      Objective: ...
      Rounds: 60/60

- Goal cards and the session view show neither the cap nor the remaining rounds.

Defect 2 — the user cannot set the cap

- `/goal` has no round-cap argument; its own README says so
  (`packages/goal/command-goal/README.md:56`): "No per-command round-cap argument —
  `defaultMaxGoalRounds` remains deployment config".
- There is no settings/GUI control and no API to write one: `goal` occurs 0 times in
  `dsh-api-session-controller/lib/index.js`, and `lib/client.js` only forwards `"goal/change"` events.
- `defaultMaxGoalRounds` only applies to goals created after boot; existing goals are unaffected.
- The only way to change an existing goal is a direct human turn asking the model to call
  `update_goal(action="edit")`. Both `edit` and `resume` are gated by `requireDirectHuman`
  (`packages/goal/tool-goal/src/index.ts:265/273`), so an autonomous agent cannot recover on its own —
  and by then the user has usually walked away.

Defect 3 — the wall has no way out

`packages/goal/goal-round-driver/src/index.ts:166-170` blocks the goal as soon as
`goal.roundsStarted >= goal.maxGoalRounds`:

    ctx.goals.block(agent, goalRef(goal), {
      code: 'round-limit',
      message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`,
    })

The goal becomes `blocked`, autonomous continuation stops, and the task stops mid-flight. The UI offers
no "raise the cap and continue" action and does not translate the code into anything actionable.
15 sessions were actually stopped this way (criterion: a real block event whose
`blockedReason.code == "round-limit"`, not "the message appears somewhere in the session").

Impact

- A long-running task stops at a point chosen by a number the model happened to type when it created
  the goal. Comparable tasks in the same deployment differ by up to 30,000x (3 vs 100000).
- The one knob the operator has is silently bypassed: after raising `defaultMaxGoalRounds` from 256 to
  10000 (49 config files across 25 users and both channels), 25 sessions still sit at the model's 60. The setting looks configured but does not cover the goals it matters for.
- The user has no recovery path: raising the cap requires a fresh human turn, which is exactly what is
  unavailable when a long task is expected to run unattended.
- Very small model-chosen caps (3/6/8) turn "run until done" into "blocked after a few rounds".

Suggested fix (any one of these makes the stop visible and controllable)

1. Expose the cap as a user-readable and user-writable setting: a field in Settings and in `/goal`,
   written through the same CAS semantics as `update_goal(edit)`, plus `Rounds: n/max` shown on the goal
   card at all times.
2. Stop letting a model argument silently override the deployment default. Pick one:
   (a) drop `max_goal_rounds` from `create_goal` so the deployment config is always authoritative;
   (b) keep it but clamp: `max(passed, defaultMaxGoalRounds)`;
   (c) require user confirmation when the passed value is below the deployment default.
3. Give `round-limit` an actionable exit: human-readable text plus a "raise the cap and continue"
   action. Today it is one English sentence, and `edit`/`resume` still require a human turn, so the
   entire recovery cost lands on the user.

Acceptance criteria

A. User-configurable: without any model involvement, raise a blocked goal's cap from 60 to 500 and
   resume it — it must succeed. (Impossible today: no host-side write path.)
B. No silent override: set `defaultMaxGoalRounds` to 500, have the model call
   `create_goal(max_goal_rounds=5)`, and the persisted `maxGoalRounds` must not be below 500 (per 2b).
C. Visibility: with a goal present, the session UI shows `Rounds: n/max` continuously. (Today only the
   one-shot `/goal` output shows it.)
D. The measuring tool that produced this issue proves it can see failures:
   `python3 ops/dsh/scan-session-goals.py --selftest` -> `SCAN_SESSION_GOALS_SELFTEST PASS checks=12 bad=0`.

Reproduction assets

- `ops/dsh/scan-session-goals.py --workers 32 --json <out.json>` reads every persisted session file and
  emits, per goal, `budgets` (caps that appeared) and `asked` (caps explicitly passed in the event
  stream). `--selftest` runs 12 assertions, including two false-positive regressions.
- Machine-readable evidence: `evidence-20260922.json` (this issue's slimmed copy, objective text
  removed) and `goals-focused.json` / `goals-all-users.json` under
  `[storage]/…/tmp/scan-session-goals`.
- Deployment-side knob (workaround, not a fix): `ops/dsh/set-goal-rounds.py` writes
  `defaultMaxGoalRounds` into `$DSH_HOME/cordis.patch.yml`.