dsh-agent-board 1.7.7 → 1.7.8

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 CHANGED
@@ -255,11 +255,11 @@ Team 托管档独有(调度员体验):
255
255
  > 兼容:旧的 `set-board-mode` / `set-team-mode` 两个 RPC 原样保留(旧客户端与脚本不受影响),
256
256
  > 内部仍以 `boardMode` + `teamMode` 两个字段落盘,老看板文件无损;`get-tasks` 额外返回派生字段 `workMode` 供 UI 单点读取。
257
257
 
258
- ## 13 个 Agent 工具
258
+ ## 14 个 Agent 工具
259
259
 
260
260
  | 类别 | 工具 |
261
261
  |---|---|
262
- | 任务管理 | `task_create` / `task_list` / `task_context` / `task_update` / `task_claim` / `task_resolve` / `task_verify` / `task_archive` |
262
+ | 任务管理 | `task_create` / `task_list` / `task_context` / `task_update` / `task_claim` / `task_resolve` / `task_verify` / `task_archive` / `task_cancel` |
263
263
  | 池治理 | `task_terminate` / `task_intervene` / `task_arbitrate` |
264
264
  | 子代理上报 | `board_report` / `board_verdict` |
265
265
 
package/index.mjs CHANGED
@@ -84,6 +84,7 @@ export function apply(ctx) {
84
84
  maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone, notifyLateReject: notify.notifyLateReject,
85
85
  pushSysNote: notify.pushSysNote, sessionCwd: session.sessionCwd, // 调研门禁③:epic 发布预检汇总投递 + 预研路径相对解析根
86
86
  spawnOneShot: dispatch.spawnOneShot, accumulateRunUsage: dispatch.accumulateRunUsage, readContextPack: dispatch.readContextPack,
87
+ closeRunHistory: dispatch.closeRunHistory, // 手动终止 run 关账(doTerminate 状态无关清理,task-mv1pqy27)
87
88
  // 上报通道结算入口(task-muwkhqf8):board_report/board_verdict 把任务推进到落定态时,
88
89
  // 由 rpc 侧顺手对「该任务当前的 continuable Worker rec」补做收尾三件套
89
90
  // (关 run 结局 / usage 落账 / 摘超时臂)——治「工具上报完成后 idle 事件没到 → run 永远 running」。
@@ -192,18 +192,21 @@
192
192
  function ArchiveView() {
193
193
  var _R = React; var useState = _R.useState
194
194
  var _a = useState(state.archQ || ''), q = _a[0], setQ = _a[1]
195
- // 归档数据源口径对齐仪表盘(反馈 n-muyg3e0m9z7i):直接读 state.tasks(get-tasks 全量含归档),
195
+ // 归档 tab 数据源口径对齐仪表盘(反馈 n-muyg3e0m9z7i):直接读 state.tasks(get-tasks 全量含归档),
196
196
  // 不再依赖独立 state.archived/fetchArchived——旧路径异步填库却未接入 TopPanel 的 notify 更新项,
197
197
  // 归档 tab 打开后停在「暂无归档任务」空态,而仪表盘「已归档」计数来自 state.tasks(全量),二者对不上。
198
- // 全量 + 按归档时间倒序(archivedAt 优先,回退 resolvedAt/createdAt)+ 顶部注明条数。
199
- var archived = state.tasks.filter(function (t) { return t.status === 'archived' })
198
+ // 全量 + 按归档/取消时间倒序(archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt)+ 顶部注明条数。
199
+ // 含 cancelled(反馈 n-mv0zbqk... 取消通道):取消卡在待办列消失(cancelled 不在 COLUMNS)后,
200
+ // 归入本 tab 可见、可「恢复待办」——否则作废卡从 UI 上彻底蒸发、无法找回。
201
+ var archived = state.tasks.filter(function (t) { return t.status === 'archived' || t.status === 'cancelled' })
202
+ var cancelledCount = archived.filter(function (t) { return t.status === 'cancelled' }).length // 文案口径修正:仪表盘「已归档」只数 archived,本 tab 含 cancelled,须单列
200
203
  var list = archived.filter(function (t) {
201
204
  if (!q) return true
202
205
  var qq = q.toLowerCase()
203
206
  return (t.title || '').toLowerCase().indexOf(qq) >= 0 || (t.id || '').toLowerCase().indexOf(qq) >= 0 || (t.tags || []).join(' ').toLowerCase().indexOf(qq) >= 0
204
207
  }).sort(function (a, b) {
205
- // 归档时间倒序:archivedAt 优先,回退 resolvedAt/createdAt(老卡可能缺 archivedAt)
206
- function archTs(t) { return t.archivedAt || t.resolvedAt || t.createdAt || '' }
208
+ // 归档/取消时间倒序:archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt(老卡可能缺 archivedAt)
209
+ function archTs(t) { return t.archivedAt || t.cancelledAt || t.resolvedAt || t.createdAt || '' }
207
210
  return (archTs(b) || '').localeCompare(archTs(a) || '')
208
211
  })
209
212
  // 工作模式(与 fetchTasks 同口径):list=清单模式(恢复不重派)不弹;auto/team 恢复即重派 → confirm 门禁
@@ -216,8 +219,8 @@
216
219
  }
217
220
  return React.createElement('div', null,
218
221
  React.createElement('input', { value: q, onChange: function (e) { setQ(e.target.value); state.archQ = e.target.value }, placeholder: '检索标题 / ID / 标签…', style: { width: '100%', padding: '5px 8px', fontSize: 11, border: '1px solid ' + C.border2, borderRadius: 5, background: C.card, color: C.text, marginBottom: 8 } }),
219
- React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 } }, '共 ' + archived.length + ' 条归档(全量,与仪表盘「已归档」同口径)'),
220
- archived.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '暂无归档任务') :
222
+ React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 }, title: '归档 tab 含已归档 + 已取消两类;仪表盘「已归档」只数已归档,不数已取消' }, '共 ' + archived.length + ' 条' + (cancelledCount > 0 ? '(含已取消 ' + cancelledCount + ' 条)' : '')),
223
+ archived.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '暂无归档/已取消任务') :
221
224
  list.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '无匹配结果') :
222
225
  list.map(function (t) {
223
226
  // 归档行点击进详情(用户 2026-10-09 指令):复用 state.detailId 机制,与看板卡同路径;
@@ -227,7 +230,8 @@
227
230
  React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: C.text, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: t.title }, t.title),
228
231
  reviewPending(t) ? React.createElement('span', { title: '👁 建议过目(命中风险信号):\n' + ((t.reviewHint && t.reviewHint.reasons) || []).join('\n'), style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.warn + ' 22%, transparent)', color: C.warn, fontWeight: 600, whiteSpace: 'nowrap', flexShrink: 0 } }, '👁 建议过目') : null,
229
232
  t.verification ? React.createElement('span', { title: '验收结论: ' + (t.verification.summary || '').slice(0, 100), style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: t.verification.verdict === 'approved' ? C.ok : C.err, color: C_INV } }, t.verification.verdict === 'approved' ? '过' : '驳') : null,
230
- React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, t.archivedAt ? ago(t.archivedAt) : '')),
233
+ t.status === 'cancelled' ? React.createElement('span', { title: '已取消(可恢复待办)', style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.warn + ' 20%, transparent)', color: C.warn, fontWeight: 600, whiteSpace: 'nowrap', flexShrink: 0 } }, '已取消') : null,
234
+ React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, (t.archivedAt || t.cancelledAt) ? ago(t.archivedAt || t.cancelledAt) : '')),
231
235
  t.verification && t.verification.summary ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, '📝 ' + t.verification.summary) : null,
232
236
  React.createElement('div', { style: { display: 'flex', gap: 5, marginTop: 4, flexWrap: 'wrap' } },
233
237
  React.createElement('button', { onClick: function (e) { e.stopPropagation(); restore(t.id) }, title: wm === 'list' ? '恢复为待办' : '恢复为待办(将按当前工作模式立即重新派发,消耗一轮 Worker+Verifier token)', style: { fontSize: 9, padding: '2px 7px', border: '1px solid ' + C.brand, borderRadius: 3, background: 'transparent', color: C.brand, cursor: 'pointer' } }, '↩ 恢复待办'),
@@ -582,13 +582,14 @@
582
582
  // 近 14 天柱形通用画法(① 通过率 / ③ 超时率共用):cells = [{ k, rate(null=当日无样本), tip }]
583
583
  // 今日柱 brand 高亮 + 率值顶标;无样本日画 3px 灰基线(title 写明无样本,不假装 0%)。
584
584
  function qtDayBars(cells, color, todayKey) {
585
- return React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 3, height: 46 } },
585
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 3, height: 46, minWidth: 0, overflow: 'hidden' } },
586
586
  cells.map(function (c) {
587
587
  var isToday = c.k === todayKey
588
588
  var h = c.rate === null ? 3 : Math.max(4, Math.round(c.rate * 34))
589
+ h = Math.min(22, h) // clamp 柱高:100% 柱封顶 22px——46px 绘图容器扣掉上下率值/日期标签与 gap/描边后不越出,防单日 100% 黑柱向上盖住右列
589
590
  var kp = c.k.split('-')
590
591
  var dLabel = Number(kp[1]) + '/' + Number(kp[2])
591
- return React.createElement('div', { key: c.k, style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }, title: c.tip },
592
+ return React.createElement('div', { key: c.k, style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }, title: c.tip },
592
593
  c.rate !== null ? React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2 } }, mpPct(c.rate)) : null,
593
594
  React.createElement('div', { style: { width: '100%', maxWidth: 22, height: h + 'px', borderRadius: 2, background: c.rate === null ? C.nested : (isToday ? C.brand : color), border: '1px solid ' + (isToday ? C.brand : (c.rate === null ? C.border : color)) } }),
594
595
  React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2 } }, dLabel))
@@ -633,7 +634,7 @@
633
634
  })
634
635
  var fpHas = false; fpCells.forEach(function (c) { if (c.rate !== null) fpHas = true })
635
636
  var fpToday = fpCells[fpCells.length - 1]
636
- var block1 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230 } },
637
+ var block1 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230, overflow: 'hidden' } },
637
638
  blockHead('一次通过率(近 14 天)', React.createElement('span', null, '今日 ', React.createElement('span', { style: { color: fpToday.rate !== null ? C.brand : C.text2, fontWeight: 600 } }, fpToday.rate !== null ? mpPct(fpToday.rate) : '—')), '一次通过率 = 当天落定「已完成」任务里零驳回(rejectCount=0)的占比;驳回 = Verifier 验收判 reject'),
638
639
  fpHas ? qtDayBars(fpCells, C.ok, todayKey)
639
640
  : React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '近 14 天窗口内暂无 resolved 任务落定'))
@@ -661,7 +662,7 @@
661
662
  })
662
663
  var toHas = false; toCells.forEach(function (c) { if (c.rate !== null) toHas = true })
663
664
  var toToday = toCells[toCells.length - 1]
664
- var block3 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230 } },
665
+ var block3 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230, overflow: 'hidden' } },
665
666
  blockHead('超时率走势(近 14 天)', React.createElement('span', null, '今日 ', React.createElement('span', { style: { color: toToday.rate !== null ? C.warn : C.text2, fontWeight: 600 } }, toToday.rate !== null ? mpPct(toToday.rate) : '—')), '超时率 = 超时落定(timeout/error)的 run 占当天 run 的比例'),
666
667
  toHas ? qtDayBars(toCells, C.warn, todayKey)
667
668
  : React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '近 14 天窗口内暂无 run 落定'))
@@ -194,6 +194,11 @@
194
194
  ['circle', { cx: 12, cy: 12, r: 10 }],
195
195
  ['rect', { width: 6, height: 6, x: 9, y: 9 }]
196
196
  ],
197
+ // 取消入口图标(详情页「取消」按钮;语义=作废不派发,与删除 trash-2 区分)
198
+ 'ban': [
199
+ ['circle', { cx: 12, cy: 12, r: 10 }],
200
+ ['path', { d: 'm4.9 4.9 14.2 14.2' }]
201
+ ],
197
202
  'plus': [
198
203
  ['path', { d: 'M5 12h14' }],
199
204
  ['path', { d: 'M12 5v14' }]
@@ -351,10 +356,14 @@
351
356
  state.poolStatus = (d && d.poolStatus) || null
352
357
  // token 消耗聚合(board 级,host 端现算):范围守卫——响应回来时范围若已变,保留旧值不覆盖
353
358
  // (新范围的那次请求会带着新聚合回来;老 host 不返回该字段 → null,零渲染)
354
- // ===== usageSummary 变化检测(task-muwq9u04:选范围不重渲染)=====
355
- // 病根:范围切换只让 state.usageSummary 换对象,tasksHash 与四个 cfg 开关都不变 → 无 notify
356
- // → Token 区冻在旧数字上,要等下一次任意 notify(改任务/切开关)才翻新。
357
- // 为什么用「上一次的 JSON 串」比对而不是对象引用:host 每次 get-tasks 都现算聚合、**必然返回新对象**
359
+ // ===== usageSummary 变化检测 + React 订阅(task-muwq9u04 → task-mv10lvud 根修)=====
360
+ // 病根(task-mv10lvud 实测「点今天也没变化」):范围切换时 setRange 先换 state.dateRange(新对象
361
+ // → setDR 身份变化 → 第一帧渲染成功),随后 fetchTasks 响应把 state.usageSummary 换成新对象并 notify(),
362
+ // 但 TopPanel 的 update() 里所有 setX 值都 Object.is 全等(tasks 引用不变、布尔不变)→ React 18 全量
363
+ // bailout,零重渲染 → Token 区永远拿不到新聚合。notify ≠ React 重渲染,须有「新身份的订阅值」做载体。
364
+ // 修法:TopPanel 补 usageSummary 专属 React state(setUsage(state.usageSummary)),host 每次响应都是
365
+ // 新对象,身份变化天然触发渲染(订阅见 TopPanel 的 update())。
366
+ // 为什么本处用「上一次的 JSON 串」比对而不是对象引用:host 每次 get-tasks 都现算聚合、**必然返回新对象**
358
367
  // (引用比较恒真)→ 3s 轮询每轮都 notify,tasksHash 的渲染节约当场作废。JSON 串只在这份聚合
359
368
  // 真变了时才不等(KB 级体积、3s 一次,代价可忽略);赋值前先快照,赋值后比对。
360
369
  var usageJsonPrev = JSON.stringify(state.usageSummary || null)
@@ -498,6 +507,23 @@
498
507
  }).catch(function (e) { if (onMsg) onMsg('⚠️ ' + String(e)); return { ok: false, error: String(e) } })
499
508
  }
500
509
 
510
+ // ===== 取消通道(与 host cancel-task / task_cancel 门禁一一对应)=====
511
+ // 可取消状态:草稿/待办/阻塞(未进入执行)。in-progress/verifying 需先终止;resolved 引导归档。
512
+ // 取消=显式放弃:置 cancelled + 留档可检索、可在归档 tab「恢复待办」;与删除不同,取消不丢数据。
513
+ function canCancel(t) { return !!t && (t.status === 'draft' || t.status === 'pending' || t.status === 'blocked') }
514
+ // confirm 预警(与 note「取消后不再派发,可在归档恢复」同口径);失败时 host 门禁原因原样回显。
515
+ function cancelTask(id, title, onMsg) {
516
+ var t = getTask(id)
517
+ var name = title || (t && t.title) || id
518
+ if (!window.confirm('取消后不再派发,可在归档恢复。确认取消「' + name + '」?')) return Promise.resolve({ ok: false, cancelled: true })
519
+ return rpc('cancel-task', { taskId: id }).then(function (r) {
520
+ if (r && r.ok === false) { if (onMsg) onMsg('⚠️ ' + (r.error || '取消失败')) }
521
+ else if (onMsg) onMsg('🚫 已取消「' + name + '」(可在归档 tab 恢复待办)')
522
+ fetchTasks()
523
+ return r
524
+ }).catch(function (e) { if (onMsg) onMsg('⚠️ ' + String(e)); return { ok: false, error: String(e) } })
525
+ }
526
+
501
527
  function onDragStart(e, task) { e.dataTransfer.setData('text/plain', JSON.stringify({ id: task.id, status: task.status })); e.dataTransfer.effectAllowed = 'move'; state.dragTask = task.id; notify() }
502
528
  function onDragEnd() { state.dragTask = null; state.dragOver = null; notify() }
503
529
  function onColDragOver(e, st) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (state.dragOver !== st) { state.dragOver = st; notify() } }
@@ -576,7 +602,8 @@
576
602
  var _ndo = useState(state.notifyDispatch), ndOn = _ndo[0], setNdOn = _ndo[1]; var _nno = useState(state.notifyDone), nnOn = _nno[0], setNnOn = _nno[1]
577
603
  var _eso = useState(state.epicSplit), esOn = _eso[0], setEsOn = _eso[1] // 史诗拆分总开关勾选态(设置区「功能」)
578
604
  var _vug = useState(state.verifyUserGuide), vugOn = _vug[0], setVugOn = _vug[1] // 自测指南开关勾选态(设置区「验收」)
579
- useEffect(function () { function update() { setOpen(state.open); setTasksState(state.tasks); setModeState(state.boardMode); setDetailId(state.detailId); setDragOver(state.dragOver); setDispatchInfo(state.dispatchInfo); setViewState(state.view); setLayL(state.layoutLeft); setLayR(state.layoutRight); setMinW(state.minWorkers); setMaxW(state.maxWorkers); setMinV(state.minVerifiers); setMaxV(state.maxVerifiers); setWorkerModel(state.workerModel); setVerifierModel(state.verifierModel); setTeamMode(state.teamMode); setWorkModeState(state.workMode); setDR(state.dateRange); setSoftT(state.softTimeoutMin); setHardT(state.hardTimeoutMin); setFbEnabled(state.feedbackEnabled); setNdOn(state.notifyDispatch); setNnOn(state.notifyDone); setEsOn(state.epicSplit); setVugOn(state.verifyUserGuide); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash); setTasksErr(state.tasksErr) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
605
+ var _usg = useState(state.usageSummary), setUsage = _usg[1] // Token 区聚合订阅(task-mv10lvud 根修):usageSummary 换新对象即触发渲染
606
+ useEffect(function () { function update() { setOpen(state.open); setTasksState(state.tasks); setModeState(state.boardMode); setDetailId(state.detailId); setDragOver(state.dragOver); setDispatchInfo(state.dispatchInfo); setViewState(state.view); setLayL(state.layoutLeft); setLayR(state.layoutRight); setMinW(state.minWorkers); setMaxW(state.maxWorkers); setMinV(state.minVerifiers); setMaxV(state.maxVerifiers); setWorkerModel(state.workerModel); setVerifierModel(state.verifierModel); setTeamMode(state.teamMode); setWorkModeState(state.workMode); setDR(state.dateRange); setSoftT(state.softTimeoutMin); setHardT(state.hardTimeoutMin); setFbEnabled(state.feedbackEnabled); setNdOn(state.notifyDispatch); setNnOn(state.notifyDone); setEsOn(state.epicSplit); setVugOn(state.verifyUserGuide); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash); setTasksErr(state.tasksErr); setUsage(state.usageSummary) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
580
607
  if (!open) return null
581
608
  if (!state.isRootStable) return null // 子代理会话不渲染看板面板(读 isRootStable:瞬态 false 不闪,见 isRoot 蝶变防抖)
582
609
  var active = tasks.filter(function (t) { return t.status !== 'archived' }); var archived = tasks.filter(function (t) { return t.status === 'archived' })
@@ -461,6 +461,8 @@
461
461
  task.status === 'verifying' ? React.createElement('button', { onClick: function () { doAction(function () { return rpc('verify-task', { taskId: task.id, verdict: 'approved' }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.ok, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('check-circle', 10), '通过') : null,
462
462
  task.status === 'verifying' ? React.createElement('button', { onClick: function () { var r = window.prompt('驳回原因:'); doAction(function () { return rpc('verify-task', { taskId: task.id, verdict: 'rejected', comment: r || '' }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.err, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('x-circle', 10), '驳回') : null,
463
463
  task.status === 'resolved' ? React.createElement('button', { onClick: function () { doAction(function () { return rpc('archive-task', { taskId: task.id }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.text2, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('archive', 10), '归档') : null),
464
+ // 取消(仅草稿/待办/阻塞):confirm 预警「取消后不再派发,可在归档恢复」;与删除并列但语义=作废留档(可恢复)
465
+ canCancel(task) ? React.createElement('button', { onClick: function () { cancelTask(task.id, task.title, setActionMsg) }, title: '取消任务(取消后不再派发,可在归档恢复待办)', style: { fontSize: 10, padding: '3px 8px', border: '1px solid ' + C.warn, borderRadius: 3, background: 'transparent', color: C.warn, cursor: 'pointer', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('ban', 10), '取消') : null,
464
466
  // 删除(仅草稿/待办/阻塞):confirm 防误删;被 host 门禁拒绝时把原因回显到详情页 actionMsg
465
467
  // 容器 div 的收尾括号已挂在「归档」行;删除按钮作为**额外一行元素**排在它后面
466
468
  // (多一个实参给同一个 createElement,属于合法调用),因此本行自身必须配平。
package/lib/client.js CHANGED
@@ -207,6 +207,11 @@ function apply(ctx) {
207
207
  ['circle', { cx: 12, cy: 12, r: 10 }],
208
208
  ['rect', { width: 6, height: 6, x: 9, y: 9 }]
209
209
  ],
210
+ // 取消入口图标(详情页「取消」按钮;语义=作废不派发,与删除 trash-2 区分)
211
+ 'ban': [
212
+ ['circle', { cx: 12, cy: 12, r: 10 }],
213
+ ['path', { d: 'm4.9 4.9 14.2 14.2' }]
214
+ ],
210
215
  'plus': [
211
216
  ['path', { d: 'M5 12h14' }],
212
217
  ['path', { d: 'M12 5v14' }]
@@ -364,10 +369,14 @@ function apply(ctx) {
364
369
  state.poolStatus = (d && d.poolStatus) || null
365
370
  // token 消耗聚合(board 级,host 端现算):范围守卫——响应回来时范围若已变,保留旧值不覆盖
366
371
  // (新范围的那次请求会带着新聚合回来;老 host 不返回该字段 → null,零渲染)
367
- // ===== usageSummary 变化检测(task-muwq9u04:选范围不重渲染)=====
368
- // 病根:范围切换只让 state.usageSummary 换对象,tasksHash 与四个 cfg 开关都不变 → 无 notify
369
- // → Token 区冻在旧数字上,要等下一次任意 notify(改任务/切开关)才翻新。
370
- // 为什么用「上一次的 JSON 串」比对而不是对象引用:host 每次 get-tasks 都现算聚合、**必然返回新对象**
372
+ // ===== usageSummary 变化检测 + React 订阅(task-muwq9u04 → task-mv10lvud 根修)=====
373
+ // 病根(task-mv10lvud 实测「点今天也没变化」):范围切换时 setRange 先换 state.dateRange(新对象
374
+ // → setDR 身份变化 → 第一帧渲染成功),随后 fetchTasks 响应把 state.usageSummary 换成新对象并 notify(),
375
+ // 但 TopPanel 的 update() 里所有 setX 值都 Object.is 全等(tasks 引用不变、布尔不变)→ React 18 全量
376
+ // bailout,零重渲染 → Token 区永远拿不到新聚合。notify ≠ React 重渲染,须有「新身份的订阅值」做载体。
377
+ // 修法:TopPanel 补 usageSummary 专属 React state(setUsage(state.usageSummary)),host 每次响应都是
378
+ // 新对象,身份变化天然触发渲染(订阅见 TopPanel 的 update())。
379
+ // 为什么本处用「上一次的 JSON 串」比对而不是对象引用:host 每次 get-tasks 都现算聚合、**必然返回新对象**
371
380
  // (引用比较恒真)→ 3s 轮询每轮都 notify,tasksHash 的渲染节约当场作废。JSON 串只在这份聚合
372
381
  // 真变了时才不等(KB 级体积、3s 一次,代价可忽略);赋值前先快照,赋值后比对。
373
382
  var usageJsonPrev = JSON.stringify(state.usageSummary || null)
@@ -511,6 +520,23 @@ function apply(ctx) {
511
520
  }).catch(function (e) { if (onMsg) onMsg('⚠️ ' + String(e)); return { ok: false, error: String(e) } })
512
521
  }
513
522
 
523
+ // ===== 取消通道(与 host cancel-task / task_cancel 门禁一一对应)=====
524
+ // 可取消状态:草稿/待办/阻塞(未进入执行)。in-progress/verifying 需先终止;resolved 引导归档。
525
+ // 取消=显式放弃:置 cancelled + 留档可检索、可在归档 tab「恢复待办」;与删除不同,取消不丢数据。
526
+ function canCancel(t) { return !!t && (t.status === 'draft' || t.status === 'pending' || t.status === 'blocked') }
527
+ // confirm 预警(与 note「取消后不再派发,可在归档恢复」同口径);失败时 host 门禁原因原样回显。
528
+ function cancelTask(id, title, onMsg) {
529
+ var t = getTask(id)
530
+ var name = title || (t && t.title) || id
531
+ if (!window.confirm('取消后不再派发,可在归档恢复。确认取消「' + name + '」?')) return Promise.resolve({ ok: false, cancelled: true })
532
+ return rpc('cancel-task', { taskId: id }).then(function (r) {
533
+ if (r && r.ok === false) { if (onMsg) onMsg('⚠️ ' + (r.error || '取消失败')) }
534
+ else if (onMsg) onMsg('🚫 已取消「' + name + '」(可在归档 tab 恢复待办)')
535
+ fetchTasks()
536
+ return r
537
+ }).catch(function (e) { if (onMsg) onMsg('⚠️ ' + String(e)); return { ok: false, error: String(e) } })
538
+ }
539
+
514
540
  function onDragStart(e, task) { e.dataTransfer.setData('text/plain', JSON.stringify({ id: task.id, status: task.status })); e.dataTransfer.effectAllowed = 'move'; state.dragTask = task.id; notify() }
515
541
  function onDragEnd() { state.dragTask = null; state.dragOver = null; notify() }
516
542
  function onColDragOver(e, st) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (state.dragOver !== st) { state.dragOver = st; notify() } }
@@ -589,7 +615,8 @@ function apply(ctx) {
589
615
  var _ndo = useState(state.notifyDispatch), ndOn = _ndo[0], setNdOn = _ndo[1]; var _nno = useState(state.notifyDone), nnOn = _nno[0], setNnOn = _nno[1]
590
616
  var _eso = useState(state.epicSplit), esOn = _eso[0], setEsOn = _eso[1] // 史诗拆分总开关勾选态(设置区「功能」)
591
617
  var _vug = useState(state.verifyUserGuide), vugOn = _vug[0], setVugOn = _vug[1] // 自测指南开关勾选态(设置区「验收」)
592
- useEffect(function () { function update() { setOpen(state.open); setTasksState(state.tasks); setModeState(state.boardMode); setDetailId(state.detailId); setDragOver(state.dragOver); setDispatchInfo(state.dispatchInfo); setViewState(state.view); setLayL(state.layoutLeft); setLayR(state.layoutRight); setMinW(state.minWorkers); setMaxW(state.maxWorkers); setMinV(state.minVerifiers); setMaxV(state.maxVerifiers); setWorkerModel(state.workerModel); setVerifierModel(state.verifierModel); setTeamMode(state.teamMode); setWorkModeState(state.workMode); setDR(state.dateRange); setSoftT(state.softTimeoutMin); setHardT(state.hardTimeoutMin); setFbEnabled(state.feedbackEnabled); setNdOn(state.notifyDispatch); setNnOn(state.notifyDone); setEsOn(state.epicSplit); setVugOn(state.verifyUserGuide); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash); setTasksErr(state.tasksErr) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
618
+ var _usg = useState(state.usageSummary), setUsage = _usg[1] // Token 区聚合订阅(task-mv10lvud 根修):usageSummary 换新对象即触发渲染
619
+ useEffect(function () { function update() { setOpen(state.open); setTasksState(state.tasks); setModeState(state.boardMode); setDetailId(state.detailId); setDragOver(state.dragOver); setDispatchInfo(state.dispatchInfo); setViewState(state.view); setLayL(state.layoutLeft); setLayR(state.layoutRight); setMinW(state.minWorkers); setMaxW(state.maxWorkers); setMinV(state.minVerifiers); setMaxV(state.maxVerifiers); setWorkerModel(state.workerModel); setVerifierModel(state.verifierModel); setTeamMode(state.teamMode); setWorkModeState(state.workMode); setDR(state.dateRange); setSoftT(state.softTimeoutMin); setHardT(state.hardTimeoutMin); setFbEnabled(state.feedbackEnabled); setNdOn(state.notifyDispatch); setNnOn(state.notifyDone); setEsOn(state.epicSplit); setVugOn(state.verifyUserGuide); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash); setTasksErr(state.tasksErr); setUsage(state.usageSummary) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
593
620
  if (!open) return null
594
621
  if (!state.isRootStable) return null // 子代理会话不渲染看板面板(读 isRootStable:瞬态 false 不闪,见 isRoot 蝶变防抖)
595
622
  var active = tasks.filter(function (t) { return t.status !== 'archived' }); var archived = tasks.filter(function (t) { return t.status === 'archived' })
@@ -826,18 +853,21 @@ function apply(ctx) {
826
853
  function ArchiveView() {
827
854
  var _R = React; var useState = _R.useState
828
855
  var _a = useState(state.archQ || ''), q = _a[0], setQ = _a[1]
829
- // 归档数据源口径对齐仪表盘(反馈 n-muyg3e0m9z7i):直接读 state.tasks(get-tasks 全量含归档),
856
+ // 归档 tab 数据源口径对齐仪表盘(反馈 n-muyg3e0m9z7i):直接读 state.tasks(get-tasks 全量含归档),
830
857
  // 不再依赖独立 state.archived/fetchArchived——旧路径异步填库却未接入 TopPanel 的 notify 更新项,
831
858
  // 归档 tab 打开后停在「暂无归档任务」空态,而仪表盘「已归档」计数来自 state.tasks(全量),二者对不上。
832
- // 全量 + 按归档时间倒序(archivedAt 优先,回退 resolvedAt/createdAt)+ 顶部注明条数。
833
- var archived = state.tasks.filter(function (t) { return t.status === 'archived' })
859
+ // 全量 + 按归档/取消时间倒序(archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt)+ 顶部注明条数。
860
+ // 含 cancelled(反馈 n-mv0zbqk... 取消通道):取消卡在待办列消失(cancelled 不在 COLUMNS)后,
861
+ // 归入本 tab 可见、可「恢复待办」——否则作废卡从 UI 上彻底蒸发、无法找回。
862
+ var archived = state.tasks.filter(function (t) { return t.status === 'archived' || t.status === 'cancelled' })
863
+ var cancelledCount = archived.filter(function (t) { return t.status === 'cancelled' }).length // 文案口径修正:仪表盘「已归档」只数 archived,本 tab 含 cancelled,须单列
834
864
  var list = archived.filter(function (t) {
835
865
  if (!q) return true
836
866
  var qq = q.toLowerCase()
837
867
  return (t.title || '').toLowerCase().indexOf(qq) >= 0 || (t.id || '').toLowerCase().indexOf(qq) >= 0 || (t.tags || []).join(' ').toLowerCase().indexOf(qq) >= 0
838
868
  }).sort(function (a, b) {
839
- // 归档时间倒序:archivedAt 优先,回退 resolvedAt/createdAt(老卡可能缺 archivedAt)
840
- function archTs(t) { return t.archivedAt || t.resolvedAt || t.createdAt || '' }
869
+ // 归档/取消时间倒序:archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt(老卡可能缺 archivedAt)
870
+ function archTs(t) { return t.archivedAt || t.cancelledAt || t.resolvedAt || t.createdAt || '' }
841
871
  return (archTs(b) || '').localeCompare(archTs(a) || '')
842
872
  })
843
873
  // 工作模式(与 fetchTasks 同口径):list=清单模式(恢复不重派)不弹;auto/team 恢复即重派 → confirm 门禁
@@ -850,8 +880,8 @@ function apply(ctx) {
850
880
  }
851
881
  return React.createElement('div', null,
852
882
  React.createElement('input', { value: q, onChange: function (e) { setQ(e.target.value); state.archQ = e.target.value }, placeholder: '检索标题 / ID / 标签…', style: { width: '100%', padding: '5px 8px', fontSize: 11, border: '1px solid ' + C.border2, borderRadius: 5, background: C.card, color: C.text, marginBottom: 8 } }),
853
- React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 } }, '共 ' + archived.length + ' 条归档(全量,与仪表盘「已归档」同口径)'),
854
- archived.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '暂无归档任务') :
883
+ React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 }, title: '归档 tab 含已归档 + 已取消两类;仪表盘「已归档」只数已归档,不数已取消' }, '共 ' + archived.length + ' 条' + (cancelledCount > 0 ? '(含已取消 ' + cancelledCount + ' 条)' : '')),
884
+ archived.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '暂无归档/已取消任务') :
855
885
  list.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '无匹配结果') :
856
886
  list.map(function (t) {
857
887
  // 归档行点击进详情(用户 2026-10-09 指令):复用 state.detailId 机制,与看板卡同路径;
@@ -861,7 +891,8 @@ function apply(ctx) {
861
891
  React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: C.text, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: t.title }, t.title),
862
892
  reviewPending(t) ? React.createElement('span', { title: '👁 建议过目(命中风险信号):\n' + ((t.reviewHint && t.reviewHint.reasons) || []).join('\n'), style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.warn + ' 22%, transparent)', color: C.warn, fontWeight: 600, whiteSpace: 'nowrap', flexShrink: 0 } }, '👁 建议过目') : null,
863
893
  t.verification ? React.createElement('span', { title: '验收结论: ' + (t.verification.summary || '').slice(0, 100), style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: t.verification.verdict === 'approved' ? C.ok : C.err, color: C_INV } }, t.verification.verdict === 'approved' ? '过' : '驳') : null,
864
- React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, t.archivedAt ? ago(t.archivedAt) : '')),
894
+ t.status === 'cancelled' ? React.createElement('span', { title: '已取消(可恢复待办)', style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.warn + ' 20%, transparent)', color: C.warn, fontWeight: 600, whiteSpace: 'nowrap', flexShrink: 0 } }, '已取消') : null,
895
+ React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, (t.archivedAt || t.cancelledAt) ? ago(t.archivedAt || t.cancelledAt) : '')),
865
896
  t.verification && t.verification.summary ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, '📝 ' + t.verification.summary) : null,
866
897
  React.createElement('div', { style: { display: 'flex', gap: 5, marginTop: 4, flexWrap: 'wrap' } },
867
898
  React.createElement('button', { onClick: function (e) { e.stopPropagation(); restore(t.id) }, title: wm === 'list' ? '恢复为待办' : '恢复为待办(将按当前工作模式立即重新派发,消耗一轮 Worker+Verifier token)', style: { fontSize: 9, padding: '2px 7px', border: '1px solid ' + C.brand, borderRadius: 3, background: 'transparent', color: C.brand, cursor: 'pointer' } }, '↩ 恢复待办'),
@@ -1435,6 +1466,8 @@ function apply(ctx) {
1435
1466
  task.status === 'verifying' ? React.createElement('button', { onClick: function () { doAction(function () { return rpc('verify-task', { taskId: task.id, verdict: 'approved' }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.ok, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('check-circle', 10), '通过') : null,
1436
1467
  task.status === 'verifying' ? React.createElement('button', { onClick: function () { var r = window.prompt('驳回原因:'); doAction(function () { return rpc('verify-task', { taskId: task.id, verdict: 'rejected', comment: r || '' }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.err, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('x-circle', 10), '驳回') : null,
1437
1468
  task.status === 'resolved' ? React.createElement('button', { onClick: function () { doAction(function () { return rpc('archive-task', { taskId: task.id }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.text2, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('archive', 10), '归档') : null),
1469
+ // 取消(仅草稿/待办/阻塞):confirm 预警「取消后不再派发,可在归档恢复」;与删除并列但语义=作废留档(可恢复)
1470
+ canCancel(task) ? React.createElement('button', { onClick: function () { cancelTask(task.id, task.title, setActionMsg) }, title: '取消任务(取消后不再派发,可在归档恢复待办)', style: { fontSize: 10, padding: '3px 8px', border: '1px solid ' + C.warn, borderRadius: 3, background: 'transparent', color: C.warn, cursor: 'pointer', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('ban', 10), '取消') : null,
1438
1471
  // 删除(仅草稿/待办/阻塞):confirm 防误删;被 host 门禁拒绝时把原因回显到详情页 actionMsg
1439
1472
  // 容器 div 的收尾括号已挂在「归档」行;删除按钮作为**额外一行元素**排在它后面
1440
1473
  // (多一个实参给同一个 createElement,属于合法调用),因此本行自身必须配平。
@@ -2055,13 +2088,14 @@ function apply(ctx) {
2055
2088
  // 近 14 天柱形通用画法(① 通过率 / ③ 超时率共用):cells = [{ k, rate(null=当日无样本), tip }]
2056
2089
  // 今日柱 brand 高亮 + 率值顶标;无样本日画 3px 灰基线(title 写明无样本,不假装 0%)。
2057
2090
  function qtDayBars(cells, color, todayKey) {
2058
- return React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 3, height: 46 } },
2091
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 3, height: 46, minWidth: 0, overflow: 'hidden' } },
2059
2092
  cells.map(function (c) {
2060
2093
  var isToday = c.k === todayKey
2061
2094
  var h = c.rate === null ? 3 : Math.max(4, Math.round(c.rate * 34))
2095
+ h = Math.min(22, h) // clamp 柱高:100% 柱封顶 22px——46px 绘图容器扣掉上下率值/日期标签与 gap/描边后不越出,防单日 100% 黑柱向上盖住右列
2062
2096
  var kp = c.k.split('-')
2063
2097
  var dLabel = Number(kp[1]) + '/' + Number(kp[2])
2064
- return React.createElement('div', { key: c.k, style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }, title: c.tip },
2098
+ return React.createElement('div', { key: c.k, style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }, title: c.tip },
2065
2099
  c.rate !== null ? React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2 } }, mpPct(c.rate)) : null,
2066
2100
  React.createElement('div', { style: { width: '100%', maxWidth: 22, height: h + 'px', borderRadius: 2, background: c.rate === null ? C.nested : (isToday ? C.brand : color), border: '1px solid ' + (isToday ? C.brand : (c.rate === null ? C.border : color)) } }),
2067
2101
  React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2 } }, dLabel))
@@ -2106,7 +2140,7 @@ function apply(ctx) {
2106
2140
  })
2107
2141
  var fpHas = false; fpCells.forEach(function (c) { if (c.rate !== null) fpHas = true })
2108
2142
  var fpToday = fpCells[fpCells.length - 1]
2109
- var block1 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230 } },
2143
+ var block1 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230, overflow: 'hidden' } },
2110
2144
  blockHead('一次通过率(近 14 天)', React.createElement('span', null, '今日 ', React.createElement('span', { style: { color: fpToday.rate !== null ? C.brand : C.text2, fontWeight: 600 } }, fpToday.rate !== null ? mpPct(fpToday.rate) : '—')), '一次通过率 = 当天落定「已完成」任务里零驳回(rejectCount=0)的占比;驳回 = Verifier 验收判 reject'),
2111
2145
  fpHas ? qtDayBars(fpCells, C.ok, todayKey)
2112
2146
  : React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '近 14 天窗口内暂无 resolved 任务落定'))
@@ -2134,7 +2168,7 @@ function apply(ctx) {
2134
2168
  })
2135
2169
  var toHas = false; toCells.forEach(function (c) { if (c.rate !== null) toHas = true })
2136
2170
  var toToday = toCells[toCells.length - 1]
2137
- var block3 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230 } },
2171
+ var block3 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230, overflow: 'hidden' } },
2138
2172
  blockHead('超时率走势(近 14 天)', React.createElement('span', null, '今日 ', React.createElement('span', { style: { color: toToday.rate !== null ? C.warn : C.text2, fontWeight: 600 } }, toToday.rate !== null ? mpPct(toToday.rate) : '—')), '超时率 = 超时落定(timeout/error)的 run 占当天 run 的比例'),
2139
2173
  toHas ? qtDayBars(toCells, C.warn, todayKey)
2140
2174
  : React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '近 14 天窗口内暂无 run 落定'))
package/lib/core.mjs CHANGED
@@ -260,6 +260,27 @@ export function archiveApply(d, t, actor, note) {
260
260
  if (p) r.parentUpdated = true
261
261
  return r
262
262
  }
263
+ // 取消落定(task_cancel 工具与 cancel-task RPC 共享,单一行为口径;门禁 cancelGate 留在
264
+ // rpc.mjs——它只需判 status + claimedBy,无需 IO,与 archiveErr/deleteGate 同居「门禁在接线层、
265
+ // 状态流转在 core」的分工)。置 cancelled + 记历史(actor + 理由 note 可选)。
266
+ // spawn-pending 占位回退:claimedBy='spawn-pending' 是派发瞬时占位(无真实 run,spawn 尚在途),
267
+ // 取消时与 spawn 失败回退同口径清认领位(claimedBy/claimedAt → null),不留死占位。
268
+ // 取消不级联子卡(删除有「未归档子卡」门禁防悬空引用;取消只标记本卡,父卡不删,子卡 parentId
269
+ // 仍指向有效卡);但取消子卡同样触发父卡自动收口(isChildSettled 已计 cancelled)。
270
+ export function cancelApply(d, t, actor, note) {
271
+ var ps = t.status
272
+ if (t.claimedBy) { t.claimedBy = null; t.claimedAt = null } // spawn-pending / 残留认领位回退
273
+ t.status = 'cancelled'
274
+ t.cancelledAt = new Date().toISOString()
275
+ t.resolvedAt = null
276
+ t.resolution = null
277
+ delete t.stuckSince
278
+ ah(t, ps, 'cancelled', actor, note || 'cancelled')
279
+ var r = { ok: true, task: t }
280
+ var p = maybeAutoCloseParent(d, t) // 取消路径同样触发父卡自动收口(isChildSettled 已计 cancelled)
281
+ if (p) r.parentUpdated = true
282
+ return r
283
+ }
263
284
 
264
285
  // ===== 史诗 hooks=agent run(宿主生命周期接线)=====
265
286
  // 定位:hook 点 = 一次**真实 agent 运行**(不是声明式命令、不走 shell),挂在 epic 卡上、
package/lib/dispatch.mjs CHANGED
@@ -12,7 +12,7 @@ const { ah, cfg, claimApply, resolveApply, verifyApply, lateVerdictApply, parseS
12
12
  export function createDispatch(ctx, state, deps) {
13
13
  // 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
14
14
  // 清单只给「路径:L行号 — 一句用途」,文件内容由 Worker 自己用 read 工具按行号范围自取。
15
- var rt = deps.rt, wt = deps.wt, mutateLocked = deps.mutateLocked, kickCycle = deps.kickCycle
15
+ var rt = deps.rt, mutateLocked = deps.mutateLocked, kickCycle = deps.kickCycle
16
16
  var rootForSession = deps.rootForSession, withTimeout = deps.withTimeout, runsFor = deps.runsFor, feedbackOn = deps.feedbackOn
17
17
  // 会话日志根(可选注入):usage 增量结算(卡3)必须「真读真日志」才测得出重复计账——
18
18
  // 生产不注入(usage.mjs 回退 os.homedir()),单测注入临时目录(否则只能开子进程改 HOME,
@@ -1114,7 +1114,7 @@ export function createDispatch(ctx, state, deps) {
1114
1114
  try { await reconcileRoot(sid) } catch (e) { console.error('[task-board] reconcile 失败:', String(e)) }
1115
1115
  // 幽灵占位回收(池冻结根修):必须排在活跃度计数与 pickDispatch 之前——残留表项正是把 capW 拉到 0、
1116
1116
  // 并把卡挡在 Verifier 派发之外的元凶(见 reapGhostRecs 头注释)。收尾会写盘(结局/usage),
1117
- // 故有回收时重读一次快照——否则下面空闲快进的 wt(snap) 会拿旧快照把收尾结果覆盖回去。
1117
+ // 故有回收时重读一次快照——否则下面空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(写盘已归一进锁)。
1118
1118
  var reapedN = 0
1119
1119
  try { reapedN = await reapGhostRecs(sid, snap) } catch (e) { console.error('[task-board] 幽灵占位回收失败(本轮跳过,派发照常):', String(e)) }
1120
1120
  if (reapedN > 0) {
@@ -1137,8 +1137,20 @@ export function createDispatch(ctx, state, deps) {
1137
1137
  var stalePool = snap.poolStatus && (((snap.poolStatus.workers || []).length + (snap.poolStatus.verifiers || []).length) > 0)
1138
1138
  // dispatchInfo 是瞬时通知(90s TTL):空闲周期也要负责过期清理,否则永久残留
1139
1139
  var staleInfo = snap.dispatchInfo && (!snap.dispatchInfoAt || Date.now() - new Date(snap.dispatchInfoAt).getTime() > 90000)
1140
- if (staleInfo) { delete snap.dispatchInfo; delete snap.dispatchInfoAt }
1141
- if (stalePool || staleInfo) { if (stalePool) snap.poolStatus = emptyPool; try { await wt(sid, snap) } catch (_) {} }
1140
+ if (stalePool || staleInfo) {
1141
+ // 锁归一(P1 根修 task-mv1e17x9):空闲清理写盘必须走 mutateLocked,且过期判定在锁内重读重判——
1142
+ // 锁外读到的 stalePool/staleInfo 此刻可能已被并发写清掉,拿旧快照直写会把它覆盖回去(丢更新)。
1143
+ try {
1144
+ await mutateLocked(sid, function (d) {
1145
+ var sp2 = d.poolStatus && (((d.poolStatus.workers || []).length + (d.poolStatus.verifiers || []).length) > 0)
1146
+ var si2 = d.dispatchInfo && (!d.dispatchInfoAt || Date.now() - new Date(d.dispatchInfoAt).getTime() > 90000)
1147
+ if (!sp2 && !si2) return null // 锁内重判已无过期项 → 不写(避免覆盖并发写)
1148
+ if (sp2) d.poolStatus = emptyPool
1149
+ if (si2) { delete d.dispatchInfo; delete d.dispatchInfoAt }
1150
+ return true // 返回非空触发写盘
1151
+ }, true) // skipKick:本轮已是 poolCycle,无需再踢
1152
+ } catch (_) {}
1153
+ }
1142
1154
  return snap
1143
1155
  }
1144
1156
  var c = cfg(snap)
@@ -1374,5 +1386,5 @@ export function createDispatch(ctx, state, deps) {
1374
1386
  // 在并行 spawn 下存在认领错包的潜伏 bug,一并消灭。
1375
1387
  }
1376
1388
 
1377
- return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack }
1389
+ return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory }
1378
1390
  }