dsh-agent-board 1.9.2 → 1.9.4
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/README.md +6 -4
- package/lib/client/task-detail.js +30 -1
- package/lib/client.js +30 -1
- package/lib/core.mjs +18 -1
- package/lib/dispatch.mjs +13 -2
- package/lib/hung.mjs +11 -0
- package/lib/policy.mjs +34 -3
- package/lib/rpc.mjs +52 -6
- package/lib/settle.mjs +136 -1
- package/package.json +2 -2
- package/test/core.test.mjs +240 -2
- package/test/hung-frame-signal.test.mjs +197 -0
- package/test/settle-stop-interrupt.test.mjs +172 -0
package/test/core.test.mjs
CHANGED
|
@@ -15,6 +15,9 @@ import * as core from '../lib/core.mjs'
|
|
|
15
15
|
import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE, TEAM_DECISION_TREE, TEAM_CONTEXT_PACKING, TEAM_DECOMPOSE_FLOW, splitRuleOf } from '../index.mjs'
|
|
16
16
|
import { aggregateUsageSummary, readRunUsage, findRunLog, effectiveTokens, taskEffectiveTokens } from '../index.mjs'
|
|
17
17
|
import { createRpc } from '../lib/rpc.mjs'
|
|
18
|
+
// 拆解官 prompt 契约 + 判档/pipeline 文案常量(task-mvxpzcr9):直接读 policy 层(契约同源头;本卡 touches
|
|
19
|
+
// 不含 index.mjs 薄壳,故新常量不新增 re-export,测试按 policy 层直引——与 decompose.test 同款导入面)
|
|
20
|
+
import { buildDecomposerPrompt, TEAM_SIZE_GUIDE, TEAM_PIPELINE_GUIDE, TEAM_SIZE_PIPELINE_GUIDE, SIZE_TIER_SAMPLES, SIZE_PICK_QUESTIONS, PIPELINE_SCENE_CONTRACT } from '../lib/policy.mjs'
|
|
18
21
|
// no-root 刷屏根治(task-muuf0o7a)专项:会话层幻影板防线 + 派发层 root 闸门直调断言
|
|
19
22
|
import { createSession, isFullSessionId } from '../lib/session.mjs'
|
|
20
23
|
import { createDispatch } from '../lib/dispatch.mjs'
|
|
@@ -159,6 +162,83 @@ test('史诗收口⑥:checkParentAuto 兼容名委托共享 helper(单一判
|
|
|
159
162
|
assert.equal(p.resolution, 'all subtasks resolved') // 口径文案统一(旧 resolveApply 分支的 'all subtasks done' 已退役)
|
|
160
163
|
})
|
|
161
164
|
|
|
165
|
+
// ===== direct 父卡收口分态(task-mvxmxl00,反馈 n-mvxmue8zggw0,2026-11-01 本体板实证)=====
|
|
166
|
+
// 设计矛盾:旧 checkParentAuto 一律把收口的父卡推进 verifying,而 pickDispatch 的 verifier 候选过滤是
|
|
167
|
+
// `(!t.pipeline || t.pipeline === 'full')`——direct 父卡被推进 verifying 后永远不进 verifs,
|
|
168
|
+
// verifier 永不 spawn,卡尸停在「验证中」(实证:task-mvwk48c2/48rr/49fw 三张 direct 史诗,只能人工收口)。
|
|
169
|
+
// 修法(方案乙):按父卡 pipeline 分态收口——direct 直接 resolved(direct「不进派发池」对 verifier
|
|
170
|
+
// 同样成立),full/work 维持推进 verifying;派发侧过滤闸一字不动(单一口径留在派发侧)。
|
|
171
|
+
test('史诗收口⑪:direct 父卡子任务全了结 → 直接 resolved,不进 verifying(无 verifier 记录)', () => {
|
|
172
|
+
const p = mkTask({ id: 'epic', status: 'in-progress', pipeline: 'direct' })
|
|
173
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
|
|
174
|
+
const d = mkBoard([p, c])
|
|
175
|
+
const r = core.resolveApply(d, c, 'w', 'verifying', 'done')
|
|
176
|
+
assert.equal(c.status, 'resolved')
|
|
177
|
+
assert.equal(r.parentUpdated, true)
|
|
178
|
+
assert.equal(p.status, 'resolved') // 旧实现此处为 verifying → verifier 永不 spawn 的卡死态
|
|
179
|
+
assert.equal(p.resolution, 'all subtasks resolved')
|
|
180
|
+
assert.ok(p.resolvedAt)
|
|
181
|
+
assert.equal((p.runs || []).some((x) => x && x.role === 'verifier'), false) // 父卡不走 verifier run
|
|
182
|
+
assert.equal(p.verifiedBy, undefined) // 无验收戳:形式收口(子任务各自已验收)
|
|
183
|
+
assert.deepEqual(core.pickDispatch(d, 1, 4, {}).verifs, []) // 派发侧过滤闸未动:direct 恒不进 verifier 候选
|
|
184
|
+
// verifyApply 路径同样分态(子任务 full 档走完验收 → direct 父卡直接收口)
|
|
185
|
+
const p2 = mkTask({ id: 'epic2', status: 'in-progress', pipeline: 'direct' })
|
|
186
|
+
const c2 = mkTask({ id: 'c2', parentId: 'epic2', status: 'verifying', pipeline: 'full' })
|
|
187
|
+
const d2 = mkBoard([p2, c2])
|
|
188
|
+
assert.equal(core.verifyApply(d2, c2, 'v', 'approved', 'ok').parentUpdated, true)
|
|
189
|
+
assert.equal(p2.status, 'resolved')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('史诗收口⑫:full 父卡 → 仍推进 verifying 并进 verifier 候选(回归锁:照派 verifier)', () => {
|
|
193
|
+
const p = mkTask({ id: 'epic', status: 'in-progress', pipeline: 'full' })
|
|
194
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'verifying', pipeline: 'full' })
|
|
195
|
+
const d = mkBoard([p, c])
|
|
196
|
+
const r = core.verifyApply(d, c, 'v', 'approved', 'ok')
|
|
197
|
+
assert.equal(r.parentUpdated, true)
|
|
198
|
+
assert.equal(p.status, 'verifying') // 与修改前逐字一致
|
|
199
|
+
assert.deepEqual(core.pickDispatch(d, 1, 4, {}).verifs.map((t) => t.id), ['epic']) // 照派 Verifier
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('史诗收口⑬:work 父卡与 full 同态 → 推进 verifying(分态只切 direct,work 零变化)', () => {
|
|
203
|
+
const p = mkTask({ id: 'epic', status: 'in-progress', pipeline: 'work' })
|
|
204
|
+
const c = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
|
|
205
|
+
const d = mkBoard([p, c])
|
|
206
|
+
core.resolveApply(d, c, 'w', 'verifying', 'done')
|
|
207
|
+
assert.equal(p.status, 'verifying') // 不被 direct 直收口分支吃掉
|
|
208
|
+
assert.equal(p.resolution, 'all subtasks resolved')
|
|
209
|
+
assert.equal(core.pickDispatch(d, 1, 4, {}).verifs.length, 0) // verifier 过滤闸保持现状(work 不含在内,本卡不改)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('史诗收口⑭:流转文案分态——direct 带「direct 父卡直接收口」标注 / full·work 原文案逐字不变', () => {
|
|
213
|
+
const pd = mkTask({ id: 'epic', status: 'in-progress', pipeline: 'direct' })
|
|
214
|
+
const cd = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
215
|
+
core.checkParentAuto(mkBoard([pd, cd]), cd)
|
|
216
|
+
const hd = pd.history[pd.history.length - 1]
|
|
217
|
+
assert.equal(hd.from, 'in-progress')
|
|
218
|
+
assert.equal(hd.to, 'resolved')
|
|
219
|
+
assert.equal(hd.actor, 'system')
|
|
220
|
+
assert.equal(hd.note, 'auto: all subtasks resolved(direct 父卡直接收口)')
|
|
221
|
+
const pf = mkTask({ id: 'epic', status: 'in-progress', pipeline: 'full' })
|
|
222
|
+
const cf = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
|
|
223
|
+
core.checkParentAuto(mkBoard([pf, cf]), cf)
|
|
224
|
+
const hf = pf.history[pf.history.length - 1]
|
|
225
|
+
assert.equal(hf.to, 'verifying')
|
|
226
|
+
assert.equal(hf.note, 'auto: all subtasks resolved') // full 文案不变
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('README 双份同步记录 epic 收口分态契约(direct 直接 resolved / full·work 转 verifying)', () => {
|
|
230
|
+
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
231
|
+
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
232
|
+
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
|
|
233
|
+
for (const s of [
|
|
234
|
+
'按父卡 `pipeline` 分态收口',
|
|
235
|
+
'`full`/`work` 父卡转 verifying 派 Verifier 验收',
|
|
236
|
+
'`direct` 父卡直接 resolved',
|
|
237
|
+
'`direct` 不进派发池对 verifier 同成立',
|
|
238
|
+
]) assert.ok(pkg.includes(s), 'README 应记录 epic 收口分态口径:' + s)
|
|
239
|
+
assert.ok(!pkg.includes('子任务全了结转 verifying 后派 Verifier 验收不受影响'), 'README 不应再保留「一律转 verifying」旧口径')
|
|
240
|
+
})
|
|
241
|
+
|
|
162
242
|
// ===== 史诗自动收口漏触发根修(task-mux3uvx3,反馈 n-muw706h1uymy,2026-10-06 实证)=====
|
|
163
243
|
// 实证根因(repro 脚本逐条复现,嫌疑点逐个排除):
|
|
164
244
|
// ① isChildSettled 旧口径不含 archived——「resolve 一张归档一张」的交错序列下,末子卡 resolve
|
|
@@ -3534,6 +3614,94 @@ test('hooks 只许主窗口设置:create-task/update-task RPC 与 task_create/
|
|
|
3534
3614
|
assert.equal(b4.tasks[0].hooks.pre.state, 'idle') // 状态机不被越权写入扰动
|
|
3535
3615
|
})
|
|
3536
3616
|
|
|
3617
|
+
// ===== touches 更新通道(task-mvxw7hn5):主窗口/GUI 替换式更新锁声明,Worker 禁 + 生命周期门禁 =====
|
|
3618
|
+
// 断言族(与卡上口径同步·主窗口 2026-11-01 生命周期修正版):
|
|
3619
|
+
// ① 工具 schema 含 touches 参数(顶层,与 hooks 同级)
|
|
3620
|
+
// ② draft 卡主窗口可改:归一化(非字符串过滤+trim,与 create-task normTouches 同款)+ history 留痕含原值摘要
|
|
3621
|
+
// ③ Worker actor 双通道拒(RPC update-task / task_update 工具,文案「touches 更新仅主窗口可用」)
|
|
3622
|
+
// ④ 运行中(in-progress/verifying)拒写——主窗口也不可直改,边界由 Worker 以实际覆盖面申报
|
|
3623
|
+
// (board_report escalate → task_arbitrate expandTouches);拒写不改卡
|
|
3624
|
+
// ⑤ 落定后(resolved/cancelled)可写(锁已释放,修正权回主窗口)
|
|
3625
|
+
// ⑥ 空数组=清除声明(t.touches=[] 且 effectiveTouches 回 [])
|
|
3626
|
+
// ⑦ 上限 20 条(与 create-task 同款)⑧ 工具通道直改 pending 成功 ⑨ GUI TouchesEditor 走 update-task
|
|
3627
|
+
test('task-mvxw7hn5 touches 更新通道:schema/角色门禁/生命周期门禁/归一化留痕/GUI 编辑', async () => {
|
|
3628
|
+
const child = { resolveRoot: () => 'root-other' } // 子代理身份(resolveRoot 指向别处)
|
|
3629
|
+
const h = mkRpcHandlers(mkBoard([]))
|
|
3630
|
+
// ① task_update 工具 schema 含 touches 参数(顶层属性)
|
|
3631
|
+
const tool = h.__tools['task_update']
|
|
3632
|
+
assert.ok(tool, 'task_update 工具已注册')
|
|
3633
|
+
assert.ok(tool.parameters.properties.touches, 'task_update schema 含 touches 参数')
|
|
3634
|
+
assert.equal(tool.parameters.properties.touches.type, 'array')
|
|
3635
|
+
assert.equal(tool.parameters.properties.touches.items.type, 'string')
|
|
3636
|
+
assert.match(tool.description, /touches 可替换式更新/)
|
|
3637
|
+
assert.match(tool.parameters.properties.touches.description, /仅主窗口/)
|
|
3638
|
+
assert.match(tool.parameters.properties.touches.description, /in-progress\/verifying(运行中持锁)拒写/)
|
|
3639
|
+
// ② draft 卡主窗口可改:非字符串过滤 + trim + history 留痕(原值进摘要)
|
|
3640
|
+
const b2 = mkBoard([mkTask({ id: 'd1', status: 'draft', touches: ['old/x.mjs'] })])
|
|
3641
|
+
const r2 = await mkRpcHandlers(b2)['update-task']({ taskId: 'd1', touches: ['lib/a.mjs ', 42, '', 'lib/b.mjs'] })
|
|
3642
|
+
assert.equal(r2.ok, true)
|
|
3643
|
+
assert.deepEqual(b2.tasks[0].touches, ['lib/a.mjs', 'lib/b.mjs'])
|
|
3644
|
+
const note2 = b2.tasks[0].history[b2.tasks[0].history.length - 1].note
|
|
3645
|
+
assert.match(note2, /touches 更新:1 项 → 2 项(原:old\/x\.mjs)/)
|
|
3646
|
+
// ③ Worker actor 双通道拒:角色门禁文案,且不改卡
|
|
3647
|
+
const b3 = mkBoard([mkTask({ id: 'd2', status: 'draft', touches: [] })])
|
|
3648
|
+
const r3 = await mkRpcHandlers(b3, child)['update-task']({ taskId: 'd2', touches: ['x.mjs'] })
|
|
3649
|
+
assert.equal(r3.ok, false); assert.match(r3.error, /touches 更新仅主窗口可用/)
|
|
3650
|
+
assert.deepEqual(b3.tasks[0].touches, [])
|
|
3651
|
+
const r3b = await mkRpcHandlers(b3, child).__tools['task_update'].execute({ taskId: 'd2', touches: ['x.mjs'] })
|
|
3652
|
+
assert.equal(r3b.ok, false); assert.match(r3b.error, /touches 更新仅主窗口可用/)
|
|
3653
|
+
assert.deepEqual(b3.tasks[0].touches, [])
|
|
3654
|
+
// ④ 运行中拒写(生命周期门禁):in-progress(持锁)与 verifying——主窗口也不可直改;拒写不改卡
|
|
3655
|
+
const b4 = mkBoard([
|
|
3656
|
+
mkTask({ id: 'w1', status: 'in-progress', claimedBy: 'agent-1', claimedAt: '2026-01-01T00:00:00Z', touches: ['keep/x.mjs'] }),
|
|
3657
|
+
mkTask({ id: 'v1', status: 'verifying', touches: ['keep/y.mjs'] }),
|
|
3658
|
+
])
|
|
3659
|
+
const r4 = await mkRpcHandlers(b4)['update-task']({ taskId: 'w1', touches: ['other/z.mjs'] })
|
|
3660
|
+
assert.equal(r4.ok, false)
|
|
3661
|
+
assert.match(r4.error, /运行中的任务 touches 由 Worker 以实际覆盖面申报——请 escalate 或由主窗口仲裁扩权;待任务落定后再修正/)
|
|
3662
|
+
const r4b = await mkRpcHandlers(b4)['update-task']({ taskId: 'v1', touches: [] })
|
|
3663
|
+
assert.equal(r4b.ok, false); assert.match(r4b.error, /待任务落定后再修正/)
|
|
3664
|
+
assert.deepEqual(b4.tasks[0].touches, ['keep/x.mjs'])
|
|
3665
|
+
assert.deepEqual(b4.tasks[1].touches, ['keep/y.mjs'])
|
|
3666
|
+
const r4c = await mkRpcHandlers(mkBoard([mkTask({ id: 'w2', status: 'in-progress', claimedBy: 'agent-2', touches: ['a.mjs'] })])).__tools['task_update'].execute({ taskId: 'w2', touches: ['b.mjs'] })
|
|
3667
|
+
assert.equal(r4c.ok, false); assert.match(r4c.error, /运行中的任务 touches/)
|
|
3668
|
+
// ⑤ 落定后可写:resolved / cancelled(锁已释放,修正权回主窗口)
|
|
3669
|
+
const b5 = mkBoard([
|
|
3670
|
+
mkTask({ id: 'rs', status: 'resolved', touches: ['old.mjs'] }),
|
|
3671
|
+
mkTask({ id: 'cc', status: 'cancelled', touches: [] }),
|
|
3672
|
+
])
|
|
3673
|
+
const r5 = await mkRpcHandlers(b5)['update-task']({ taskId: 'rs', touches: ['new.mjs'] })
|
|
3674
|
+
assert.equal(r5.ok, true); assert.deepEqual(b5.tasks[0].touches, ['new.mjs'])
|
|
3675
|
+
const r5b = await mkRpcHandlers(b5)['update-task']({ taskId: 'cc', touches: ['c.mjs'] })
|
|
3676
|
+
assert.equal(r5b.ok, true); assert.deepEqual(b5.tasks[1].touches, ['c.mjs'])
|
|
3677
|
+
// ⑥ 空数组=清除声明:t.touches=[] 且 effectiveTouches 回 [](无锁态)
|
|
3678
|
+
const b6 = mkBoard([mkTask({ id: 'd3', status: 'draft', touches: ['x.mjs'] })])
|
|
3679
|
+
const r6 = await mkRpcHandlers(b6)['update-task']({ taskId: 'd3', touches: [] })
|
|
3680
|
+
assert.equal(r6.ok, true); assert.deepEqual(b6.tasks[0].touches, [])
|
|
3681
|
+
assert.deepEqual(core.effectiveTouches(b6.tasks[0]), [])
|
|
3682
|
+
assert.match(b6.tasks[0].history[b6.tasks[0].history.length - 1].note, /touches 更新:1 项 → 0 项(原:x\.mjs)/)
|
|
3683
|
+
// ⑦ 上限 20 条(与 create-task normTouches 同款:slice(0, 20))
|
|
3684
|
+
const many = []; for (let i = 0; i < 25; i++) many.push('f' + i + '.mjs')
|
|
3685
|
+
const b7 = mkBoard([mkTask({ id: 'd4', status: 'draft' })])
|
|
3686
|
+
const r7 = await mkRpcHandlers(b7)['update-task']({ taskId: 'd4', touches: many })
|
|
3687
|
+
assert.equal(r7.ok, true); assert.equal(b7.tasks[0].touches.length, 20)
|
|
3688
|
+
// ⑧ 工具通道直改 pending 成功(主窗口 tester 身份;留痕「0 项 → 1 项(原:无)」)
|
|
3689
|
+
const b8 = mkBoard([mkTask({ id: 'd5', status: 'pending', touches: [] })])
|
|
3690
|
+
const r8 = await mkRpcHandlers(b8).__tools['task_update'].execute({ taskId: 'd5', touches: ['t.mjs'] })
|
|
3691
|
+
assert.equal(r8.ok, true); assert.deepEqual(b8.tasks[0].touches, ['t.mjs'])
|
|
3692
|
+
assert.match(b8.tasks[0].history[b8.tasks[0].history.length - 1].note, /touches 更新:0 项 → 1 项(原:无)/)
|
|
3693
|
+
// ⑨ GUI TouchesEditor:源码 + 组装产物都走 update-task touches 通道;运行态只读指引在
|
|
3694
|
+
const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
|
|
3695
|
+
assert.match(src, /function TouchesEditor\(props\)/)
|
|
3696
|
+
assert.match(src, /rpc\('update-task', \{ taskId: task\.id, touches: touches \}\)/)
|
|
3697
|
+
assert.match(src, /running = task\.status === 'in-progress' \|\| task\.status === 'verifying'/)
|
|
3698
|
+
assert.match(src, /运行中任务的 touches 由 Worker 以实际覆盖面申报(escalate→仲裁扩权);落定后可在此修正/)
|
|
3699
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
3700
|
+
assert.match(built, /function TouchesEditor\(props\)/)
|
|
3701
|
+
assert.match(built, /rpc\('update-task', \{ taskId: task\.id, touches: touches \}\)/)
|
|
3702
|
+
assert.match(built, /运行中任务的 touches 由 Worker 以实际覆盖面申报/)
|
|
3703
|
+
})
|
|
3704
|
+
|
|
3537
3705
|
// ===== hooks 接线(host 侧):pre 闸门 spawn / post 补 spawn / hook run 结算分派(直调 + 源码级)=====
|
|
3538
3706
|
// poolCycle 直调 harness(带 subagents):spawnOneShot 走真实路径,捕获 provider/label/prompt;
|
|
3539
3707
|
// run.result 用永不落定的 Promise(本用例只验证「谁被 spawn 了、prompt 对不对」,不触发结算)。
|
|
@@ -4253,8 +4421,8 @@ test('hooks 主窗口限定(源码级):RPC 与工具双通道门禁 + 浅
|
|
|
4253
4421
|
// create-task RPC:锁外角色门禁 + 锁内形状校验(与建卡同处一次持锁段)
|
|
4254
4422
|
assert.match(rpc, /if \(args\.hooks !== undefined && hooksDenied\(actor\)\) return \{ ok: false, error: HOOKS_MAIN_ONLY \}/)
|
|
4255
4423
|
assert.match(rpc, /var nHooks = null; if \(args\.hooks !== undefined\) \{ var hn = normalizeHooks\(args\.hooks\)/)
|
|
4256
|
-
// update-task RPC:同一门禁 +
|
|
4257
|
-
assert.match(rpc, /if \(args\.hooks !== undefined && hooksDenied\(actor\)\) return \{ ok: false, error: HOOKS_MAIN_ONLY \}; var existsFn = existsInSession\(sid\)/)
|
|
4424
|
+
// update-task RPC:同一门禁 + 浅合并;touches 门禁(task-mvxw7hn5)随其后插在同一锁外位置,同族角色门禁
|
|
4425
|
+
assert.match(rpc, /if \(args\.hooks !== undefined && hooksDenied\(actor\)\) return \{ ok: false, error: HOOKS_MAIN_ONLY \}; if \(args\.touches !== undefined && hooksDenied\(actor\)\) return \{ ok: false, error: TOUCHES_MAIN_ONLY \}; var existsFn = existsInSession\(sid\)/)
|
|
4258
4426
|
assert.match(rpc, /if \(args\.hooks !== undefined\) \{ var __he = applyHooks\(t, args\.hooks\)/)
|
|
4259
4427
|
// 工具通道:两个工具的 hooks 专属门禁(在通用门禁之后,子代理拿到更准确的错误文案)
|
|
4260
4428
|
assert.equal((rpc.match(/if \(args\.hooks !== undefined && resolveRoot\(__ra\) !== __ra\) return \{ ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' \}/g) || []).length, 2)
|
|
@@ -5398,6 +5566,13 @@ test('epicSplit 行为①:Team 提示词第 6~8 条随门禁注入/消失(
|
|
|
5398
5566
|
// 第 9 条需求卡工作流(task-mvneoy1u):随注入段出现,且注入位置在 splitRuleOf 出口内容(第 8 条)之后
|
|
5399
5567
|
assert.ok(on.includes(TEAM_DECOMPOSE_FLOW)); assert.match(on, /需求卡工作流/)
|
|
5400
5568
|
assert.ok(on.indexOf(TEAM_DECOMPOSE_FLOW) > on.indexOf(TEAM_CONTEXT_PACKING), '第 9 条注入位置在 splitRuleOf 之后')
|
|
5569
|
+
// 第 10/11 条(判档指引 + pipeline 场景,task-mvxpzcr9):恒拼接在第 9 条之后(同样不受 epicSplit 门禁)
|
|
5570
|
+
assert.ok(on.includes(TEAM_SIZE_PIPELINE_GUIDE), 'Team 注入含判档/pipeline 引导段')
|
|
5571
|
+
assert.ok(on.indexOf(TEAM_SIZE_GUIDE) > on.indexOf(TEAM_DECOMPOSE_FLOW), '判档段接在第 9 条之后')
|
|
5572
|
+
assert.match(on, /判档三问:几个文件?动不动判定核心?要不要全新机制?/)
|
|
5573
|
+
assert.match(on, /拿不准按低一档报/)
|
|
5574
|
+
assert.match(on, /work=回归测试\/巡检\/文档\/纯查询\/纯报表/)
|
|
5575
|
+
assert.match(on, /用户说做回归\/巡检\/盘点 → 归 work/)
|
|
5401
5576
|
const off = textOf(false)
|
|
5402
5577
|
// 第 6~8 条整条消失(连 parentId/checkParentAuto 都不再出现),基础 1~5 条引导一字不少
|
|
5403
5578
|
assert.doesNotMatch(off, /大任务必须拆分|拆解决策树|上下文装配/)
|
|
@@ -5406,9 +5581,72 @@ test('epicSplit 行为①:Team 提示词第 6~8 条随门禁注入/消失(
|
|
|
5406
5581
|
// 第 9 条**不受 epicSplit 门禁**(流程引导不是劝拆):6~8 条整条消失时它仍在
|
|
5407
5582
|
assert.ok(off.includes(TEAM_DECOMPOSE_FLOW), 'epicSplit=false 时第 9 条仍注入')
|
|
5408
5583
|
assert.match(off, /确认或调档(task_update 改 size\/model)后统一 publish/)
|
|
5584
|
+
// 档位/pipeline 与拆分正交:关掉拆分引导时第 10/11 条照样注入(否则「全落标档」盲区原样复发)
|
|
5585
|
+
assert.ok(off.includes(TEAM_SIZE_PIPELINE_GUIDE), 'epicSplit=false 时判档/pipeline 段仍注入')
|
|
5586
|
+
assert.match(off, /按样例选档,别一律落到标档/)
|
|
5587
|
+
assert.match(off, /拿不准按低一档报/)
|
|
5588
|
+
assert.match(off, /用户说做回归\/巡检\/盘点 → 归 work/)
|
|
5409
5589
|
assert.equal(on.replace(splitRuleOf(true), ''), off.replace(splitRuleOf(false), '')) // 除第 6~8 条外逐字相同
|
|
5410
5590
|
})
|
|
5411
5591
|
|
|
5592
|
+
// ===== Team 提示词判档/pipeline 指引(task-mvxpzcr9):三档具象样例 + 判档三问 + pipeline 场景 =====
|
|
5593
|
+
// 病灶(用户 2026-11-01 两连报):① 判档文案原只写在拆解官一处、且原文「standard=常规模块改动(缺省)」
|
|
5594
|
+
// = 引力井 → 主窗口建卡全落标档;② pipeline 只有定义没场景(「回归/巡检类归 work」从未写过)→ 一律 full。
|
|
5595
|
+
// 本族断言把「样例化文案 + 三处同源(Team 注入 / task_create 静态描述 / 拆解官 prompt)」钉死。
|
|
5596
|
+
test('判档/pipeline 指引文案:三档具象样例 + 判档三问 + 拿不准报低档 + pipeline 三场景(回归点名)', () => {
|
|
5597
|
+
const g = TEAM_SIZE_PIPELINE_GUIDE
|
|
5598
|
+
// 三档样例逐字(每档 = 判据 + 具象例子),且与静态契约/SIZE_TIER_SAMPLES 同源
|
|
5599
|
+
for (const s of [
|
|
5600
|
+
'light=单文件小改/文案/配置/断言增补/≤15 分钟(样例:改一句提示文案、加一条断言、调一个常量)',
|
|
5601
|
+
'standard=单模块多文件/常规特性/需新断言(样例:加一个开关、修一个判定分支)',
|
|
5602
|
+
'heavy=跨模块/改判定链核心/拆分类/高风险(样例:新机制、重构、动结算链)',
|
|
5603
|
+
]) {
|
|
5604
|
+
assert.ok(g.includes(s), '判档样例缺:' + s)
|
|
5605
|
+
assert.ok(SIZE_TIER_SAMPLES.includes(s), '样例常量应逐字同源:' + s)
|
|
5606
|
+
assert.ok(TASK_SIZE_CONTRACT.includes(s), 'task_create 静态契约应同款样例:' + s)
|
|
5607
|
+
}
|
|
5608
|
+
// 判档三问 + 拿不准报低档(带 sizeHint 自修正出口,别把「低报」说成死路)
|
|
5609
|
+
assert.match(TEAM_SIZE_GUIDE, /判档三问:几个文件?动不动判定核心?要不要全新机制?/)
|
|
5610
|
+
assert.match(TEAM_SIZE_GUIDE, /拿不准按低一档报/)
|
|
5611
|
+
assert.match(TEAM_SIZE_GUIDE, /sizeHint/)
|
|
5612
|
+
assert.ok(SIZE_PICK_QUESTIONS.length > 0 && TEAM_SIZE_GUIDE.includes(SIZE_PICK_QUESTIONS))
|
|
5613
|
+
// 编号接第 9 条(6~9 已在用);旧「(缺省)」引力井措辞必须清零
|
|
5614
|
+
assert.match(TEAM_SIZE_GUIDE, /^10\. /)
|
|
5615
|
+
assert.doesNotMatch(TEAM_SIZE_GUIDE, /(缺省)/)
|
|
5616
|
+
assert.doesNotMatch(TASK_SIZE_CONTRACT, /(缺省)/)
|
|
5617
|
+
// pipeline 三场景 + 回归/巡检显式点名(work 不再靠猜)
|
|
5618
|
+
assert.match(TEAM_PIPELINE_GUIDE, /^11\. /)
|
|
5619
|
+
for (const s of ['work=回归测试/巡检/文档/纯查询/纯报表', 'full=代码改动/修 bug/新特性', 'direct=主窗口亲办、不进派发池']) {
|
|
5620
|
+
assert.ok(TEAM_PIPELINE_GUIDE.includes(s), 'pipeline 场景缺:' + s)
|
|
5621
|
+
assert.ok(PIPELINE_SCENE_CONTRACT.includes(s), 'task_create 静态契约应同款 pipeline 场景:' + s)
|
|
5622
|
+
}
|
|
5623
|
+
assert.match(TEAM_PIPELINE_GUIDE, /用户说做回归\/巡检\/盘点 → 归 work/)
|
|
5624
|
+
assert.match(PIPELINE_SCENE_CONTRACT, /用户说做回归\/巡检\/盘点 → 归 work/)
|
|
5625
|
+
// 注入出口 = 两条合成(每条含前导换行,调用方直接字符串相加)
|
|
5626
|
+
assert.equal(TEAM_SIZE_PIPELINE_GUIDE, '\n' + TEAM_SIZE_GUIDE + '\n' + TEAM_PIPELINE_GUIDE)
|
|
5627
|
+
})
|
|
5628
|
+
|
|
5629
|
+
test('判档/pipeline 同源落三处:Team 注入位 + task_create 工具描述 + 拆解官 prompt(静态契约层)', () => {
|
|
5630
|
+
const src = hostSrc()
|
|
5631
|
+
// dispatch 注入:接在第 9 条之后恒拼接(不进 epicSplit 门禁出口——档位与拆分正交)
|
|
5632
|
+
assert.ok(src.includes("+ '\\n' + TEAM_DECOMPOSE_FLOW + '\\n' + TEAM_SIZE_PIPELINE_GUIDE"))
|
|
5633
|
+
assert.ok(!splitRuleOf(true).includes(TEAM_SIZE_PIPELINE_GUIDE), '判档段不进 epicSplit 门禁出口')
|
|
5634
|
+
// task_create 工具描述(真注册出来的 description,不是源码拼串)
|
|
5635
|
+
const tc = mkRpcHandlers(mkBoard([])).__tools['task_create']
|
|
5636
|
+
assert.ok(tc.description.includes(SIZE_TIER_SAMPLES), 'task_create 描述含三档样例')
|
|
5637
|
+
assert.ok(tc.description.includes(PIPELINE_SCENE_CONTRACT), 'task_create 描述含 pipeline 场景指引')
|
|
5638
|
+
assert.match(tc.description, /回归\/巡检\/盘点 → 归 work/)
|
|
5639
|
+
// 参数级描述同步(模型按参数描述选档/pipeline 的那一面)
|
|
5640
|
+
assert.ok(tc.parameters.properties.size.description.includes('加一个开关'), 'size 参数描述同款具象样例')
|
|
5641
|
+
assert.match(tc.parameters.properties.pipeline.description, /work=回归测试\/巡检/)
|
|
5642
|
+
// 拆解官 prompt 第 3 条:与注入同口径(样例 + 三问),旧「(缺省)」引力井消失
|
|
5643
|
+
const p = buildDecomposerPrompt(mkTask({ id: 'pc1', title: 'P' }), '')
|
|
5644
|
+
for (const s of ['加一个开关', '修一个判定分支', '新机制', '动结算链', '判档三问', '拿不准按低一档报']) {
|
|
5645
|
+
assert.ok(p.includes(s), '拆解官判档文案缺:' + s)
|
|
5646
|
+
}
|
|
5647
|
+
assert.ok(!p.includes('常规模块改动(缺省)'), '拆解官旧「缺省」引力井必须消灭')
|
|
5648
|
+
})
|
|
5649
|
+
|
|
5412
5650
|
test('withSplitHint: epicSplit=false 不附 suggestSplit(与未命中同形),缺省/true 零变化', () => {
|
|
5413
5651
|
const big = mkTask({ title: '全量重写派发引擎' })
|
|
5414
5652
|
const off = withSplitHint({ ok: true, task: {} }, big, false)
|
|
@@ -628,3 +628,200 @@ test('帧⑦:verifier 在跑 run 帧路径覆盖——零帧 ≥600s 判死走
|
|
|
628
628
|
await env.cleanup()
|
|
629
629
|
}
|
|
630
630
|
})
|
|
631
|
+
|
|
632
|
+
// ===== 帧⑧:「继续观察」(dismiss-suspect)重置判定基线 + 120s 防抖窗(task-mvxmxkq1)=====
|
|
633
|
+
// 旧病(用户 2026-11-01 实测):dismiss 只清 t.stuckSince,帧静默基线 frameSilenceSince 未动——模型仍静默
|
|
634
|
+
// (≥120s)时下一 sweep 立刻重标,「继续观察」等于没点。新语义两层:①基线置 now(需再静默满 120s 才可能
|
|
635
|
+
// 重标,「观察一轮」本义)②卡上 stuckRearmUntil 防抖窗(兜 sweep 在飞读到旧基线的竞态边缘),一次性
|
|
636
|
+
// (重标成功/帧恢复清除)。本组锁:窗内不重标、窗过期正常重标、字段一次性。
|
|
637
|
+
function dismissSuspect(env, taskId) {
|
|
638
|
+
return env.rpc('dismiss-suspect', { sessionId: env.sid, taskId: taskId })
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
test('帧⑧:「继续观察」重置帧静默基线 + 120s 防抖窗——窗内不重标 / 窗过期重标(字段一次性)', async () => {
|
|
642
|
+
var env = createMockCtx()
|
|
643
|
+
var boot = null
|
|
644
|
+
try {
|
|
645
|
+
boot = await bootWorkerBoard(env, 'task-frame-dismiss')
|
|
646
|
+
var child = boot.child
|
|
647
|
+
await primeFrameBaseline(env, 'task-frame-dismiss', child.childId)
|
|
648
|
+
await pushBackBaseline(env, 'task-frame-dismiss', child.childId, 'frameSilenceSince', 125000)
|
|
649
|
+
await env.clock.tick()
|
|
650
|
+
await env.waitFor(function () {
|
|
651
|
+
var x = env.task('task-frame-dismiss')
|
|
652
|
+
return x && typeof x.stuckSince === 'number' ? x : null
|
|
653
|
+
}, '疑似落卡(前置状态)', W)
|
|
654
|
+
// ---- dismiss:清标记 + 基线重置 + 防抖窗落卡 ----
|
|
655
|
+
var tBefore = Date.now()
|
|
656
|
+
var dr = await dismissSuspect(env, 'task-frame-dismiss')
|
|
657
|
+
assert.equal(dr.ok, true, 'dismiss-suspect 成功')
|
|
658
|
+
var t1 = env.task('task-frame-dismiss')
|
|
659
|
+
assert.equal(t1.stuckSince, undefined, 'dismiss 清除疑似标记')
|
|
660
|
+
assert.ok(typeof t1.stuckRearmUntil === 'number' && t1.stuckRearmUntil >= tBefore + 120000, 'stuckRearmUntil 落卡(now + 120s)')
|
|
661
|
+
var row1 = runningRow(env, 'task-frame-dismiss', child.childId)()
|
|
662
|
+
assert.ok(row1 && row1.frameSilenceSince >= tBefore, '帧静默基线重置为 now(不再按旧基线秒重标)')
|
|
663
|
+
var notes1 = (t1.history || []).filter(function (h) { return /继续观察/.test(h.note || '') })
|
|
664
|
+
assert.equal(notes1.length, 1, 'dismiss 留史一条')
|
|
665
|
+
assert.match(notes1[0].note, /基线已重置/, 'dismiss 留史文案带「基线已重置」')
|
|
666
|
+
// ---- ①基线重置生效:正常一轮 sweep(基线=now)不重标 ----
|
|
667
|
+
await env.clock.tick()
|
|
668
|
+
await new Promise(function (r) { setTimeout(r, 300) })
|
|
669
|
+
assert.equal(env.task('task-frame-dismiss').stuckSince, undefined, '基线重置:正常 sweep 不重标')
|
|
670
|
+
// ---- ②竞态边缘:sweep 在飞读到旧基线(倒推回 125s)→ 防抖窗兜住,不重标 ----
|
|
671
|
+
await pushBackBaseline(env, 'task-frame-dismiss', child.childId, 'frameSilenceSince', 125000)
|
|
672
|
+
await env.clock.tick()
|
|
673
|
+
await new Promise(function (r) { setTimeout(r, 300) })
|
|
674
|
+
var t3 = env.task('task-frame-dismiss')
|
|
675
|
+
assert.equal(t3.stuckSince, undefined, '120s 窗内:旧基线也不重标(防抖窗生效)')
|
|
676
|
+
assert.ok(typeof t3.stuckRearmUntil === 'number', '窗未过期:字段保留(一次性语义未消耗)')
|
|
677
|
+
// ---- ③窗过期:仍静默 → 正常重标(豁免不永续);重标即消耗字段 ----
|
|
678
|
+
env.patchBoard(function (d) {
|
|
679
|
+
var x = d.tasks.find(function (y) { return y.id === 'task-frame-dismiss' })
|
|
680
|
+
x.stuckRearmUntil = Date.now() - 1
|
|
681
|
+
})
|
|
682
|
+
await env.clock.tick()
|
|
683
|
+
var t4 = await env.waitFor(function () {
|
|
684
|
+
var x = env.task('task-frame-dismiss')
|
|
685
|
+
return x && typeof x.stuckSince === 'number' ? x : null
|
|
686
|
+
}, '窗过期后正常重标疑似', W)
|
|
687
|
+
assert.equal(t4.stuckRearmUntil, undefined, '重标即消耗防抖窗(一次性)')
|
|
688
|
+
assert.equal((t4.history || []).filter(function (h) { return /疑似卡顿/.test(h.note || '') }).length, 2, '第二次疑似留史(重标正常发生)')
|
|
689
|
+
} finally {
|
|
690
|
+
if (boot) boot.unpin()
|
|
691
|
+
await env.cleanup()
|
|
692
|
+
}
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
// ===== 帧⑨:判死 600s 闸不受防抖窗影响(豁免不永续、不拦判死)=====
|
|
696
|
+
// 防抖窗只挂在疑似判定前(hung.mjs 疑似分支内部),kill 分支先于它且零守卫:窗内静默真到 600s 仍判死。
|
|
697
|
+
test('帧⑨:防抖窗不拦判死——dismiss 后静默真到 600s 仍正常判死重派', async () => {
|
|
698
|
+
var env = createMockCtx()
|
|
699
|
+
var boot = null
|
|
700
|
+
try {
|
|
701
|
+
boot = await bootWorkerBoard(env, 'task-frame-dismiss-kill', function (d) { d.workerModel = 'prov/hung' })
|
|
702
|
+
var child = boot.child
|
|
703
|
+
await primeFrameBaseline(env, 'task-frame-dismiss-kill', child.childId)
|
|
704
|
+
await pushBackBaseline(env, 'task-frame-dismiss-kill', child.childId, 'frameSilenceSince', 125000)
|
|
705
|
+
await env.clock.tick()
|
|
706
|
+
await env.waitFor(function () {
|
|
707
|
+
var x = env.task('task-frame-dismiss-kill')
|
|
708
|
+
return x && typeof x.stuckSince === 'number' ? x : null
|
|
709
|
+
}, '疑似落卡(前置状态)', W)
|
|
710
|
+
await dismissSuspect(env, 'task-frame-dismiss-kill')
|
|
711
|
+
assert.ok(typeof env.task('task-frame-dismiss-kill').stuckRearmUntil === 'number', 'dismiss 后防抖窗在位(判死前置)')
|
|
712
|
+
// 窗内把静默推过 600s → 判死照常(防抖窗不在 kill 分支上)
|
|
713
|
+
await pushBackBaseline(env, 'task-frame-dismiss-kill', child.childId, 'frameSilenceSince', 601000)
|
|
714
|
+
await env.clock.tick()
|
|
715
|
+
var t = await env.waitFor(function () {
|
|
716
|
+
var x = env.task('task-frame-dismiss-kill')
|
|
717
|
+
var rows = (x && x.runs) || []
|
|
718
|
+
for (var i = rows.length - 1; i >= 0; i--) {
|
|
719
|
+
if (rows[i] && String(rows[i].id) === child.childId && rows[i].outcome === 'timeout/error') return x
|
|
720
|
+
}
|
|
721
|
+
return null
|
|
722
|
+
}, '窗内静默 ≥600s 判死关账', W)
|
|
723
|
+
assert.match(String(t.lastError || ''), /帧静默 600s/, '帧路径判死文案')
|
|
724
|
+
assert.equal(String(t.lastError || '').indexOf('run 挂死:'), 0, 'HUNG_KILL_PREFIX 前缀锚定(判死链语义不变)')
|
|
725
|
+
} finally {
|
|
726
|
+
if (boot) boot.unpin()
|
|
727
|
+
await env.cleanup()
|
|
728
|
+
}
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
// ===== 帧⑩:dismiss 后帧恢复 → 防抖窗清除 + 基线删档;再静默按新基线计时(③)=====
|
|
732
|
+
// 「继续观察」是豁免不是免死:帧恢复(活体证据)即清防抖窗(与 stuckSince 同款一次性);再静默按新基线
|
|
733
|
+
// 重新计 120s 才重标。
|
|
734
|
+
test('帧⑩:dismiss 后帧恢复清防抖窗 + 删基线 → 再静默按新基线重标', async () => {
|
|
735
|
+
var env = createMockCtx()
|
|
736
|
+
var boot = null
|
|
737
|
+
try {
|
|
738
|
+
boot = await bootWorkerBoard(env, 'task-frame-dismiss-recover')
|
|
739
|
+
var child = boot.child
|
|
740
|
+
await primeFrameBaseline(env, 'task-frame-dismiss-recover', child.childId)
|
|
741
|
+
await pushBackBaseline(env, 'task-frame-dismiss-recover', child.childId, 'frameSilenceSince', 125000)
|
|
742
|
+
await env.clock.tick()
|
|
743
|
+
await env.waitFor(function () {
|
|
744
|
+
var x = env.task('task-frame-dismiss-recover')
|
|
745
|
+
return x && typeof x.stuckSince === 'number' ? x : null
|
|
746
|
+
}, '疑似落卡(前置状态)', W)
|
|
747
|
+
var tBefore = Date.now()
|
|
748
|
+
await dismissSuspect(env, 'task-frame-dismiss-recover')
|
|
749
|
+
// 帧恢复:emitFrame 置 dirty → 下一轮 sweep 走 recovery:删基线 + 清防抖窗(stuckSince 已无)
|
|
750
|
+
env.emitFrame(child.childId)
|
|
751
|
+
await env.clock.tick()
|
|
752
|
+
await new Promise(function (r) { setTimeout(r, 300) })
|
|
753
|
+
var t1 = env.task('task-frame-dismiss-recover')
|
|
754
|
+
assert.equal(t1.stuckRearmUntil, undefined, '帧恢复清除防抖窗(一次性语义)')
|
|
755
|
+
assert.equal(t1.stuckSince, undefined, '帧恢复不标疑似')
|
|
756
|
+
assert.equal(runningRow(env, 'task-frame-dismiss-recover', child.childId)().frameSilenceSince, undefined, '帧恢复删持久基线')
|
|
757
|
+
// 再静默:下一轮零帧 sweep 重建基线(= lastFrameAt)→ 倒推 125s → 按新基线重标
|
|
758
|
+
await env.clock.tick()
|
|
759
|
+
var row2 = await env.waitFor(function () {
|
|
760
|
+
var r = runningRow(env, 'task-frame-dismiss-recover', child.childId)()
|
|
761
|
+
return r && typeof r.frameSilenceSince === 'number' ? r : null
|
|
762
|
+
}, '帧恢复后重建帧基线', W)
|
|
763
|
+
assert.ok(row2.frameSilenceSince >= tBefore, '新基线取帧恢复时刻(非 dismiss 前旧值)')
|
|
764
|
+
await pushBackBaseline(env, 'task-frame-dismiss-recover', child.childId, 'frameSilenceSince', 125000)
|
|
765
|
+
await env.clock.tick()
|
|
766
|
+
var t2 = await env.waitFor(function () {
|
|
767
|
+
var x = env.task('task-frame-dismiss-recover')
|
|
768
|
+
return x && typeof x.stuckSince === 'number' ? x : null
|
|
769
|
+
}, '再静默 ≥120s 按新基线重标', W)
|
|
770
|
+
assert.ok(t2.stuckSince >= tBefore, '重标时间戳按新基线计时落卡')
|
|
771
|
+
} finally {
|
|
772
|
+
if (boot) boot.unpin()
|
|
773
|
+
await env.cleanup()
|
|
774
|
+
}
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
// ===== 帧⑪:日志兜底路径(无帧能力宿主)dismiss 同款重置 zeroGrowthSince——600s 墙钟重新起算 =====
|
|
778
|
+
// ⑥ 语义:无帧能力宿主(ctx.on 缺席 → frameStreamSeen 恒 false)走原日志双闸;dismiss 同步重置
|
|
779
|
+
// zeroGrowthSince(同款两闸的另一半),轮数到线但墙钟未到不判死;重置后重新满 600s 判死照常。
|
|
780
|
+
test('帧⑪:日志兜底路径 dismiss 重置 zeroGrowthSince——墙钟重新起算,600s 后判死照常', async () => {
|
|
781
|
+
var env = createMockCtx()
|
|
782
|
+
var unpin = pinHomedir(env)
|
|
783
|
+
try {
|
|
784
|
+
delete env.ctx.on // 老宿主:帧订阅静默跳过 → 日志双闸
|
|
785
|
+
plugin.apply(env.ctx)
|
|
786
|
+
await createTask(env, 'task-frame-dismiss-log', { pipeline: 'full', acceptance: 'npm test' })
|
|
787
|
+
env.patchBoard(function (d) { d.workerModel = 'prov/hung' })
|
|
788
|
+
await env.clock.advance(50)
|
|
789
|
+
await env.waitFor(function () { return env.continuables.length >= 1 }, 'worker spawn 发生(无帧能力宿主)', W)
|
|
790
|
+
var child = env.continuables[0]
|
|
791
|
+
await env.waitFor(runningRow(env, 'task-frame-dismiss-log', child.childId), '落卡 in-progress 且 runs[0] running', W)
|
|
792
|
+
await env.clock.tick()
|
|
793
|
+
await env.waitFor(function () {
|
|
794
|
+
var r = runningRow(env, 'task-frame-dismiss-log', child.childId)()
|
|
795
|
+
return r && typeof r.zeroGrowthSince === 'number' ? r : null
|
|
796
|
+
}, '日志双闸首见记基线', W)
|
|
797
|
+
// 推到墙钟线附近 + 39 轮零增长(差一轮判死)
|
|
798
|
+
await pushBackBaseline(env, 'task-frame-dismiss-log', child.childId, 'zeroGrowthSince', 601000)
|
|
799
|
+
await driveZeroGrowth(env, 'task-frame-dismiss-log', child.childId, 39)
|
|
800
|
+
var tBefore = Date.now()
|
|
801
|
+
var dr = await dismissSuspect(env, 'task-frame-dismiss-log')
|
|
802
|
+
assert.equal(dr.ok, true, 'dismiss-suspect 成功(日志路径)')
|
|
803
|
+
var row1 = runningRow(env, 'task-frame-dismiss-log', child.childId)()
|
|
804
|
+
assert.ok(row1.zeroGrowthSince >= tBefore, 'zeroGrowthSince 重置为 now(600s 墙钟重新起算)')
|
|
805
|
+
assert.ok(typeof env.task('task-frame-dismiss-log').stuckRearmUntil === 'number', '防抖窗同样落卡(与帧路径语义一键对齐)')
|
|
806
|
+
// 下一轮:轮数到 40,但墙钟距新基线 < 600s → 不判死
|
|
807
|
+
await env.clock.tick()
|
|
808
|
+
await env.waitFor(function () {
|
|
809
|
+
var r = runningRow(env, 'task-frame-dismiss-log', child.childId)()
|
|
810
|
+
return r && r.zeroGrowthRounds === 40 ? r : null
|
|
811
|
+
}, '第 40 轮零增长计数落盘', W)
|
|
812
|
+
var t1 = env.task('task-frame-dismiss-log')
|
|
813
|
+
assert.equal(t1.runs[0].outcome, 'running', '墙钟未到:不判死(基线重置生效)')
|
|
814
|
+
assert.ok(!t1.lastError, '零 lastError')
|
|
815
|
+
// 再推过 600s → 判死照常(豁免不永续、日志闸不失效)
|
|
816
|
+
await pushBackBaseline(env, 'task-frame-dismiss-log', child.childId, 'zeroGrowthSince', 601000)
|
|
817
|
+
await env.clock.tick()
|
|
818
|
+
await env.waitFor(function () {
|
|
819
|
+
var x = env.task('task-frame-dismiss-log')
|
|
820
|
+
return x && x.runs[0] && x.runs[0].outcome === 'timeout/error' ? x : null
|
|
821
|
+
}, '重置后重新满 600s 判死关账', W)
|
|
822
|
+
assert.match(String(env.task('task-frame-dismiss-log').lastError || ''), /连续 40 轮会话日志零增长/, '日志双闸文案照旧')
|
|
823
|
+
} finally {
|
|
824
|
+
unpin()
|
|
825
|
+
await env.cleanup()
|
|
826
|
+
}
|
|
827
|
+
})
|