dsh-agent-board 1.3.0 → 1.4.0

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.
@@ -287,6 +287,166 @@ test('pickDispatch: verifying spawn-pending 占位不重复派发(防双 Verif
287
287
  assert.deepEqual(r2.verifs.map(t => t.id), ['vn'])
288
288
  })
289
289
 
290
+ test('pickDispatch: frozen(裁决挂起冻结)不参与任何自动派发', () => {
291
+ const tasks = [
292
+ mkTask({ id: 'fz', frozen: true }), // pending 冻结 → 不派 Worker
293
+ mkTask({ id: 'nz' }), // 普通 pending → 应派
294
+ mkTask({ id: 'fv', status: 'verifying', pipeline: 'full', frozen: true }), // verifying 冻结 → 不派 Verifier
295
+ mkTask({ id: 'nv', status: 'verifying', pipeline: 'full' }), // 普通 verifying → 应派
296
+ ]
297
+ const r = core.pickDispatch(mkBoard(tasks), 5, 5, null)
298
+ assert.deepEqual(r.pendings.map(t => t.id), ['nz'])
299
+ assert.deepEqual(r.verifs.map(t => t.id), ['nv'])
300
+ // 解冻(unfreeze-task 清 frozen)后立即恢复可派发
301
+ delete tasks[0].frozen; delete tasks[2].frozen
302
+ const r2 = core.pickDispatch(mkBoard(tasks), 5, 5, null)
303
+ assert.deepEqual(r2.pendings.map(t => t.id).sort(), ['fz', 'nz'])
304
+ assert.deepEqual(r2.verifs.map(t => t.id).sort(), ['fv', 'nv'])
305
+ })
306
+
307
+ // ===== touches 文件级排他:glob 最小匹配器 =====
308
+ test('patOverlap: 完全相同路径冲突 + 归一化(\\ → /、去 ./ 前缀与尾 /)', () => {
309
+ assert.equal(core.patOverlap('src/a.js', 'src/a.js'), true)
310
+ assert.equal(core.patOverlap('src\\a.js', 'src/a.js'), true) // 反斜杠归一化
311
+ assert.equal(core.patOverlap('./src/a.js', 'src/a.js'), true) // ./ 前缀
312
+ assert.equal(core.patOverlap('src/', 'src'), true) // 尾 / 归一化
313
+ assert.equal(core.patOverlap('src/a.js', 'src/b.js'), false)
314
+ // 空/非字符串不冲突(脏声明不该误拦一切)
315
+ assert.equal(core.patOverlap('', 'src/a.js'), false)
316
+ assert.equal(core.patOverlap(null, 'src/a.js'), false)
317
+ assert.equal(core.patOverlap(undefined, 'a.js'), false)
318
+ })
319
+
320
+ test('patOverlap: dir/** 目录覆盖(含两侧都 /** 时前缀互相包含)', () => {
321
+ assert.equal(core.patOverlap('src/**', 'src/a.js'), true)
322
+ assert.equal(core.patOverlap('src/a.js', 'src/**'), true) // 对称
323
+ assert.equal(core.patOverlap('src/**', 'src/deep/nested/b.js'), true)
324
+ assert.equal(core.patOverlap('src/**', 'src2/a.js'), false) // 前缀必须带 /
325
+ assert.equal(core.patOverlap('src/**', 'lib/a.js'), false)
326
+ assert.equal(core.patOverlap('src/**', 'src2/**'), false)
327
+ assert.equal(core.patOverlap('src/**', 'src/**'), true)
328
+ assert.equal(core.patOverlap('src/sub/**', 'src/**'), true) // 前缀互相包含
329
+ assert.equal(core.patOverlap('**/*.js', 'src/a.js'), false) // '*.js' 只按扩展名后缀匹配,不展开目录通配
330
+ assert.equal(core.patOverlap('**/*.js', 'src/a.ts'), false)
331
+ assert.equal(core.patOverlap('**/lib/**', '**/lib/core.mjs'), true) // 复合 glob 的 dir 段(一侧是另一侧前缀)
332
+ })
333
+
334
+ test('patOverlap: *.ext 后缀(两侧 *.ext 比扩展名;*.ext vs 具体文件看 endsWith)', () => {
335
+ assert.equal(core.patOverlap('*.js', 'src/a.js'), true)
336
+ assert.equal(core.patOverlap('src/a.js', '*.js'), true)
337
+ assert.equal(core.patOverlap('*.js', 'a.js'), true)
338
+ assert.equal(core.patOverlap('*.js', '*.js'), true)
339
+ assert.equal(core.patOverlap('*.js', '*.ts'), false)
340
+ assert.equal(core.patOverlap('*.js', 'src/a.ts'), false) // 扩展名不同 → 不冲突
341
+ assert.equal(core.patOverlap('*.jsx', 'src/a.js'), false) // 后缀比对是整段('a.js' 不以 '.jsx' 结尾)
342
+ })
343
+
344
+ test('patOverlap: 裸文件名(无 /)只与另一侧 basename 相等才算冲突', () => {
345
+ assert.equal(core.patOverlap('index.mjs', 'index.mjs'), true)
346
+ assert.equal(core.patOverlap('a/core.mjs', 'core.mjs'), false) // 具体路径不参与裸名规则
347
+ assert.equal(core.patOverlap('core.mjs', 'a/core.mjs'), false)
348
+ assert.equal(core.patOverlap('src/a.js', 'lib/a.js'), false) // 同 basename 不同目录
349
+ assert.equal(core.patOverlap('src/a.js', 'src/lib'), false) // 含 / 时只比目录包含('src/a.js' 不在 'src/lib/' 下)
350
+ })
351
+
352
+ test('touchesConflict: 与锁持有者逐条比对,返回冲突持有者 id', () => {
353
+ const holds = [
354
+ { id: 'h1', touches: ['src/**'] },
355
+ { id: 'h2', touches: ['docs/x.md'] },
356
+ ]
357
+ assert.deepEqual(core.touchesConflict({ id: 'w1', touches: ['src/a.js'] }, holds), ['h1'])
358
+ assert.deepEqual(core.touchesConflict({ id: 'w2', touches: ['lib/a.js'] }, holds), [])
359
+ assert.deepEqual(core.touchesConflict({ id: 'w3', touches: ['docs/x.md', 'src/b.js'] }, holds), ['h1', 'h2'])
360
+ assert.deepEqual(core.touchesConflict({ id: 'w4' }, holds), []) // 没声明 touches = 不参与排他
361
+ assert.deepEqual(core.touchesConflict({ id: 'h1', touches: ['src/a.js'] }, holds), []) // 不和自己冲突
362
+ })
363
+
364
+ test('holdsFiles: 仅 in-progress + claimedBy + 有 touches 持有文件锁', () => {
365
+ const tasks = [
366
+ mkTask({ id: 'live', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
367
+ mkTask({ id: 'notouch', status: 'in-progress', claimedBy: 'run-2' }),
368
+ mkTask({ id: 'noclaim', status: 'in-progress' }),
369
+ mkTask({ id: 'pending', status: 'pending', touches: ['src/a.js'] }),
370
+ mkTask({ id: 'verifying', status: 'verifying', touches: ['src/b.js'] }),
371
+ mkTask({ id: 'empty', status: 'in-progress', claimedBy: 'run-3', touches: [] }),
372
+ ]
373
+ const holds = core.holdsFiles(mkBoard(tasks))
374
+ assert.deepEqual(holds.map(h => h.id), ['live'])
375
+ assert.deepEqual(holds[0].touches, ['src/**'])
376
+ assert.deepEqual(core.holdsFiles(null), [])
377
+ })
378
+
379
+ // ===== 派发决策(touches 拦截)=====
380
+ test('pickDispatch: touches 与活动任务冲突 → 不进 pendings,记入 blockedTouches', () => {
381
+ const tasks = [
382
+ mkTask({ id: 'holder', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
383
+ mkTask({ id: 'clash', touches: ['src/a.js'], createdAt: '2026-01-01' }),
384
+ mkTask({ id: 'free', touches: ['lib/a.js'], createdAt: '2026-01-02' }),
385
+ mkTask({ id: 'untouched', createdAt: '2026-01-03' }), // 没声明 touches → 不被拦
386
+ ]
387
+ const r = core.pickDispatch(mkBoard(tasks), 5, 0, null)
388
+ assert.deepEqual(r.pendings.map(t => t.id).sort(), ['free', 'untouched'])
389
+ assert.deepEqual(r.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }])
390
+ })
391
+
392
+ test('pickDispatch: verifying 不持有文件锁(Worker 已停笔),verifier 派发(verifs)不受 touches 影响', () => {
393
+ const tasks = [
394
+ // verifying 的任务即使声明了 touches 也不持有锁
395
+ mkTask({ id: 'vt', status: 'verifying', pipeline: 'full', claimedBy: 'run-1', touches: ['src/**'] }),
396
+ mkTask({ id: 'w1', touches: ['src/a.js'] }),
397
+ ]
398
+ const r = core.pickDispatch(mkBoard(tasks), 5, 5, null)
399
+ assert.deepEqual(r.pendings.map(t => t.id), ['w1']) // 未被 verifying 任务拦住
400
+ assert.deepEqual(r.blockedTouches, [])
401
+ assert.deepEqual(r.verifs.map(t => t.id), ['vt']) // Verifier 照常派(只读,不参与排他)
402
+ // 反向:pending 任务声明 touches 也不影响 verifs 派发
403
+ const tasks2 = [mkTask({ id: 'w2', touches: ['src/**'] }), mkTask({ id: 'v2', status: 'verifying', pipeline: 'full' })]
404
+ const r2 = core.pickDispatch(mkBoard(tasks2), 5, 5, null)
405
+ assert.deepEqual(r2.verifs.map(t => t.id), ['v2'])
406
+ })
407
+
408
+ test('pickDispatch: 未声明 touches 的任务不做任何拦截;同一轮内已派发任务立即成为锁持有者', () => {
409
+ // 无锁持有者时,声明了 touches 的任务照常派发(锁只在 in-progress 时持有)
410
+ const solo = mkBoard([mkTask({ id: 'solo', touches: ['src/**'] })])
411
+ const r1 = core.pickDispatch(solo, 5, 0, null)
412
+ assert.deepEqual(r1.pendings.map(t => t.id), ['solo'])
413
+ assert.deepEqual(r1.blockedTouches, [])
414
+ // 同一轮拿两个候选:第一个把 src/ 纳入锁,第二个声明重叠 → 本轮只派第一个
415
+ const d = mkBoard([
416
+ mkTask({ id: 'one', touches: ['src/a.js'], priority: 'critical', createdAt: '2026-01-01' }),
417
+ mkTask({ id: 'two', touches: ['src/**'], createdAt: '2026-01-02' }),
418
+ ])
419
+ const r2 = core.pickDispatch(d, 5, 0, null)
420
+ assert.deepEqual(r2.pendings.map(t => t.id), ['one'])
421
+ assert.deepEqual(r2.blockedTouches, [{ id: 'two', conflicts: ['one'] }])
422
+ })
423
+
424
+ test('pickDispatch: frozen/dependsOn/escalation 优先级不变(touches 拦截附加在原有过滤之后)', () => {
425
+ const tasks = [
426
+ mkTask({ id: 'holder', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
427
+ mkTask({ id: 'fz', frozen: true, touches: ['src/a.js'] }), // 冻结 → 既有语义直接排除,不记 blockedTouches
428
+ mkTask({ id: 'dep', dependsOn: ['holder'], touches: ['src/a.js'] }), // 依赖未满足 → 既有语义排除
429
+ mkTask({ id: 'esc', escalation: { question: 'q' }, touches: ['src/a.js'] }),// 待裁决 → 排除
430
+ mkTask({ id: 'clash', touches: ['src/a.js'] }), // 唯一被 touches 拦下的
431
+ ]
432
+ const r = core.pickDispatch(mkBoard(tasks), 5, 0, null)
433
+ assert.deepEqual(r.pendings, [])
434
+ assert.deepEqual(r.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }])
435
+ // 锁释放(holder 结算/归档)后,被拦任务下一轮自动恢复可派发
436
+ mkBoard(tasks).tasks[0].status = 'verifying'
437
+ const r2 = core.pickDispatch(mkBoard(tasks), 5, 0, null)
438
+ assert.ok(r2.pendings.map(t => t.id).indexOf('clash') >= 0)
439
+ assert.deepEqual(r2.blockedTouches, [])
440
+ })
441
+
442
+ test('normalizeBoard: touches 脏值(非数组)收敛为空数组,缺字段任务照常', () => {
443
+ const legacy = { tasks: [{ id: 'a', touches: 'src/a.js' }, { id: 'b' }, { id: 'c', touches: ['src/c.js'] }] }
444
+ const d = core.normalizeBoard(legacy)
445
+ assert.deepEqual(d.tasks[0].touches, [])
446
+ assert.equal(d.tasks[1].touches, undefined) // 缺字段不动(老看板文件兼容)
447
+ assert.deepEqual(d.tasks[2].touches, ['src/c.js'])
448
+ })
449
+
290
450
  // ===== 孤儿回收 =====
291
451
  test('isOrphan: 认领者非主会话 + 无活跃 run + 超 2 分钟', () => {
292
452
  const old = new Date(Date.now() - 200000).toISOString()