Repository navigation
v0.3.0
TeamFlow v0.3.0
发布日期:2026-10-17。变更清单按 release-npm-package 铁律 1 由
git log v0.2.9..HEAD生成
(4 笔提交 / 10 个文件 / +349 −18),不凭会话记忆。
⚠️ 本版含行为变更(PRD 确认单必须由人批准)。逐条论证见各提交信息。
中文
⬆️ 升级要点
engines未变(宿主仍须 ≥0.2.1-alpha.2)。升完重启dsh --profile web(桌面版重启桌面应用)。- 宿主若跑在 Node 24 上,本版修掉「续跑与面板启动 100% 起不来」的硬故障,务必升级。
- 无
teamflow_*工具 schema / journal / 任务夹契约改动,不需要迁移。
① Node 24 上续跑 / 面板启动全挂(修复)
现象:宿主跑在 Node 24 上时,任何「续跑」与「面板启动」都在 1–3ms 内失败,报
failed to start/execute: signals[1] is not of type AbortSignal.;只有模型工具 teamflow_start(带真
exec.signal)幸免。Node 22 上完全正常。
根因链:
- 兜底
SAFE_SIGNAL是鸭子对象{aborted:false, addEventListener(){}, removeEventListener(){}, throwIfAborted(){}},
不是真正的AbortSignal; normalizeSignal的判定也是鸭子式(有addEventListener/aborted/throwIfAborted即收)⇒ 伪对象一路通过,
被塞进request.signal与spec.signal;- 宿主把调用方 signal 原样丢进
AbortSignal.any([...])(dsh-api-gateway的
AbortSignal.any([token.abort.signal, callerSignal])与dsh-subagent的
AbortSignal.any([inputs.signal, controller.signal])); - Node 22 的
AbortSignal.any只做鸭子校验(是对象且有aborted即收,composite 行为甚至完全正确)
⇒ 三年零症状;Node 24 改走 WebIDLsequence<AbortSignal>真 brand 校验 ⇒ 抛错。
传给宿主 undefined 的入口共三条,全部中招:resumeRun(续跑)、api.start(面板启动)、
clarificationPreflight(triage 子代理)。
修复:SAFE_SIGNAL 改为 new AbortController().signal(真 signal、永不 abort,语义不变);
normalizeSignal 只认 brand(instanceof + Object.prototype.toString 跨 realm 兜底 + throwIfAborted 复核),
不再接受鸭子形状。
门禁(关键):断言必须落在契约上(normalizeSignal(x) instanceof AbortSignal),而不是
「跑一遍宿主那行看抛不抛」—— 后者在 Node 22 上对伪对象是绿的,同一份代码换个运行时才红。新增
test/signal-brand.test.js(29 条)+ 编排级 brand 断言([N] 每个阶段交给宿主的 signal、
[23] 真 resumeRun 链路)。变异:复原伪 SAFE_SIGNAL → 编排 2 红 + brand 12 红;放宽成鸭子判定 → brand 4 红。
CI 另加 Node 24 矩阵,但注释写明它抓不住这一类(同一套测试当年在 22/24 上全绿)。
真机取证:tf-mvc4jrl7-7g6hnb(Node 24.9.0)—— 08:34 续跑后 PRD 子会话在续跑日志后约 200ms 创建成功;
09:04 起 dev 三路并发同时 spawn,全轮 signals[ / failed to start 零命中。
② PRD 确认单必须由人批准(行为变更 + 修复)
现象:确认单暂停后,主会话模型自己调 teamflow_resume 把单批了,而代价是「立即开始写代码」。
实锤 tf-mvc4jrl7-7g6hnb:08:33:36 暂停,08:34:02 模型自调 resume 并附上自己撰写的 398 字「批准文」,
该时间窗内零条人类消息。
根因:插件把暂停汇报作为 user/message 注入主会话,而汇报原文给的是可执行样例
「批准:调 teamflow_resume(runId=…)」,还强调「批准 = 立即开始写代码」——
「等用户批准」与「给模型一份操作手册」自相矛盾。
修复(判据下沉到宿主,不依赖模型自律):
- 会话日志里人类消息带
data.source.kind === 'user',插件注入的是plugin:dsh-plugin-teamflow,
另有time-context/model-selection/skill-catalog等系统来源 ⇒ 「暂停之后有没有人答复过」可机器判定。 resumeRun新增origin(tool|ui):tool 路径且停在确认单形态(afterPrd && prdOnly)时,
要求存在严格晚于interruptedAt的人工答复,否则拒绝并留痕、要求先把确认单要点问用户;
界面入口(api.resume)标注ui,不受此限(那次点击本身就是人的动作)。- 人工答复认两类,缺一不可:
- 人会话说 ——
user/message+source.kind === 'user'; - 人点了
ask_user_question的选项 —— 宿主把回答落盘为tool/result(data.message.source={kind:'tool',callId}),
不产生user/message。只认结果不认调用(调用=模型问了,结果=人答了)。漏掉这条的症状是:
我们自己在汇报里让模型用 AUQ 问,用户点完选项却被闸门拒 —— 这条是本版验证过程中实测发现并补齐的。
- 人会话说 ——
- 读不到会话日志(宿主 API 变更)时放行 + warn:宁可降级,也不能让所有续跑全挂。
- 汇报文案不再给可执行样例,改为「这一步只能由人决定,不要替用户批准」。
- 读取接口优先
snapshotEvents()(ownEvents()在宿主 d.ts 已@deprecated,且只返回子级自有事件
⇒ fork/重启后可能漏读人类消息而误挡合法续跑)。
门禁:test/human-approval.test.js(24 条,含三态 null/false、旧消息不算、严格晚于、AUQ 结果才算)+
编排 [23]「暂停后无人工答复 ⇒ 代批被拒且不起任何子代理」。变异:关掉闸门 → 编排 1 红;
放宽成「任何 user/message」→ 5 红 + 编排 1 红;删掉 AUQ 分支 → 2 红。
真机取证:
- 文案侧生效:沙箱 run
tf-mvcd8hw3-5c8nzl(mode=medium)在确认单暂停后,模型没有自作主张,
而是用ask_user_question转述确认单要点并等答复; - 放行侧生效:同一 run 中用户只点了卡片选项(会话里唯一的痕迹是
tool/result,没有user/message),
随后模型调teamflow_resume→ 放行(journal 记「断点续跑:从「技术方案」继续」,拒绝/降级日志零命中)。
这条路径在缺 AUQ 判据时必然被误拒,因此同时证明该判据必要且生效。 - 已知边界:
patch档按设计不走确认单(shouldConfirm && mode !== 'patch'),因此该档位不受本闸约束;
沙箱 runtf-mvca1x3i-17yi63即为mode=patch,PRD 后直接进 dev 并自测收口。
验证
test/全套 38 个套件在 Node 22.22.2 与 nvm 24.9.0 上双绿;tsc --noEmit0;oxlint --deny-warnings0 warning。- 变异实验 6 组(signal brand 2 组 / 闸门 3 组 / snapshotEvents 优先级 1 组),每组均使新增断言变红。
- 真机两条路径(Node 24 上的续跑与并发、确认单的文案侧与放行侧)均已复现并验证。
English
Upgrade notes
enginesunchanged (host still needs ≥0.2.1-alpha.2). Restartdsh --profile web(or the desktop app) after upgrading.- If your host runs on Node 24, this release fixes a hard failure where every resume and panel-start died
within 1–3ms (signals[1] is not of type AbortSignal.). Upgrade.
① Node 24 broke every resume / panel start (fix)
The fallback SAFE_SIGNAL was a duck-typed fake ({aborted:false, addEventListener(){}, …}) handed to the host as
request.signal / spec.signal, and normalizeSignal accepted it because its check was duck-typed too. The host feeds
caller signals into AbortSignal.any([...]): Node 22 validates leniently (any object with aborted passes, and the
composite even behaves correctly) — hence no symptoms for years; Node 24 goes through WebIDL
sequence<AbortSignal> and rejects it outright.
Fixed by using a real new AbortController().signal (same semantics: never aborts) and by making normalizeSignal
accept only genuine brand (instanceof + cross-realm toString fallback).
Gates assert the contract (x instanceof AbortSignal), not “does the host line throw” — the latter is green on
Node 22 for the very same code. New test/signal-brand.test.js (29 assertions) plus orchestration-level brand
assertions; a Node 24 CI matrix is added but explicitly documented as unable to catch this class.
② The PRD confirm sheet could be self-approved by the model (behaviour change + fix)
The pause report is injected into the main session as a user/message, and it used to hand the model an executable
recipe (“to approve: call teamflow_resume”). The model complied and approved on the user's behalf — with zero human
messages in that window — and approving means code immediately starts being written.
Now the host decides: on the tool path, when a run sits at the confirm-sheet pause, a resume requires a human
reply strictly after the pause, otherwise it is refused and the model is told to ask the user first. A human reply is
either a user/message with source.kind === 'user', or the answer to an ask_user_question — which the host
persists as a tool/result and never as a user/message (missing this half would reject users who merely clicked
an option, so both halves ship together). The UI entry (api.resume) is exempt because the click itself is the human
action; an unreadable session log degrades to allow + warn rather than blocking every resume.
Gates: test/human-approval.test.js (24 assertions) plus an orchestration case proving a self-approval attempt is
refused without spawning any sub-agent.