dsh-agent-board 1.7.6 → 1.7.7
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 +40 -2
- package/index.mjs +3 -2
- package/lib/client/board-list.js +23 -8
- package/lib/client/dashboard.js +10 -5
- package/lib/client/kernel.js +9 -4
- package/lib/client/task-detail.js +79 -1
- package/lib/client.js +121 -18
- package/lib/core.mjs +103 -1
- package/lib/dispatch.mjs +26 -2
- package/lib/health.mjs +22 -0
- package/lib/notify.mjs +23 -1
- package/lib/rpc.mjs +96 -12
- package/lib/store.mjs +89 -11
- package/package.json +1 -1
- package/test/archive-detail.test.mjs +43 -0
- package/test/core.test.mjs +62 -5
- package/test/headless-smoke.test.mjs +25 -0
- package/test/helpers/mock-ctx.mjs +17 -3
- package/test/review-hints.test.mjs +170 -0
- package/test/store-data-safety.test.mjs +112 -0
- package/test/verify-timing.test.mjs +266 -0
package/lib/rpc.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLes
|
|
|
13
13
|
import { makeMsg } from './notify.mjs'
|
|
14
14
|
import { computeHealthHints, computeRuntimeHealthHints } from './health.mjs'
|
|
15
15
|
import { isFullSessionId } from './session.mjs' // 幻影板防线口径(纯函数,与 policy.mjs 直引同例)
|
|
16
|
-
const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, archiveApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint } = core
|
|
16
|
+
const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, lateVerdictApply, archiveApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint } = core
|
|
17
17
|
|
|
18
18
|
function defineTool(options) {
|
|
19
19
|
var userExecute = options.execute
|
|
@@ -43,6 +43,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
43
43
|
// 主窗口消耗(task-muwsol23):测试可注入临时日志根;生产 undefined → findRunLog 回退 ~/.dsh/sessions
|
|
44
44
|
var sessionsRoot = (typeof deps.sessionsRoot === 'string' && deps.sessionsRoot) ? deps.sessionsRoot : undefined
|
|
45
45
|
var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
|
|
46
|
+
var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
|
|
46
47
|
var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
|
|
47
48
|
// 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
|
|
48
49
|
// Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
|
|
@@ -59,7 +60,36 @@ export function createRpc(ctx, state, deps) {
|
|
|
59
60
|
// (runsFor(sid)[taskId] 存在且 !settled 即活跃)。未注入时按「有活跃 run」兜底——
|
|
60
61
|
// 保守保持旧门禁行为(in-progress 一律拒归档),绝不因缺依赖误放走在跑的任务。
|
|
61
62
|
var hasActiveRun = typeof deps.hasActiveRun === 'function' ? deps.hasActiveRun : function () { return true }
|
|
63
|
+
// ===== 活跃 run 判定(验收时序三洞 ①②,用户 n-mv0ebretgv1o)=====
|
|
64
|
+
// 口径:内存活跃表(runsFor)是「仍活着」的权威信号(未 settled);卡面 runs 留档(outcome 仍
|
|
65
|
+
// running 且无 endedAt、且 id 匹配当前认领位 claimedBy/verifierRun)是持久化兜底(内存表丢失/重启后)。
|
|
66
|
+
// 只匹配「当前认领位」:手动终止/重派会把认领位换新,旧 run 的 running 留档不再被误判为活跃。
|
|
67
|
+
// 返回 { role, id } 或 null。role 归一 verifier/worker(hook run 走 verifierRun 位,但归档/验收
|
|
68
|
+
// 竞态只关心 worker/verifier 两族;未知角色按 worker 兜底文案)。
|
|
69
|
+
function activeRunOf(sid, t) {
|
|
70
|
+
var rec = runsFor(sid)[t.id]
|
|
71
|
+
if (rec && !rec.settled) return { role: rec.role === 'verifier' ? 'verifier' : 'worker', id: String(rec.id) }
|
|
72
|
+
var trs = Array.isArray(t.runs) ? t.runs : []
|
|
73
|
+
function liveEntry(runId, role) {
|
|
74
|
+
if (!runId || runId === 'spawn-pending') return null
|
|
75
|
+
for (var i = trs.length - 1; i >= 0; i--) {
|
|
76
|
+
var r = trs[i]
|
|
77
|
+
if (r && r.role === role && String(r.id) === String(runId) && (!r.outcome || r.outcome === 'running') && !r.endedAt) return r
|
|
78
|
+
}
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
if (liveEntry(t.verifierRun, 'verifier')) return { role: 'verifier', id: String(t.verifierRun) }
|
|
82
|
+
if (liveEntry(t.claimedBy, 'worker')) return { role: 'worker', id: String(t.claimedBy) }
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
// 迟到驳回 healthHint 落点(内存心跳,sticky——host 重启才清):board_verdict 迟到驳回分支写
|
|
86
|
+
function markLateRejectHealth(sid, taskId) {
|
|
87
|
+
if (!state.poolHealth) state.poolHealth = {}
|
|
88
|
+
var ph = state.poolHealth[sid] || (state.poolHealth[sid] = { bornAt: Date.now() })
|
|
89
|
+
ph.lateRejectNote = { taskId: taskId, at: Date.now() }
|
|
90
|
+
}
|
|
62
91
|
// archive 门禁(archive-task RPC 与 task_archive 工具同一口径,返回错误串或 null 放行):
|
|
92
|
+
// 任一 run(含 verifier)仍活跃(outcome running 且无 endedAt / 内存表未 settled)→ 拒绝(先查,宁拦勿漏)
|
|
63
93
|
// resolved / cancelled → 放行(既有行为)
|
|
64
94
|
// in-progress 且无活跃 run → 放行(parentKick 僵尸态出清:direct epic 被
|
|
65
95
|
// parentKickOnDispatch 推进到 in-progress 后子任务已归档、无 run、claimedBy=null,
|
|
@@ -67,6 +97,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
67
97
|
// 只能 resetToPending→delete 三段舞)
|
|
68
98
|
// in-progress 有活跃 run / 其余状态 → 拒绝
|
|
69
99
|
function archiveErr(sid, t) {
|
|
100
|
+
var act = activeRunOf(sid, t)
|
|
101
|
+
if (act) return '有活跃 run(' + (act.role === 'verifier' ? 'verifier ' : 'worker ') + act.id + ' 仍在跑),等它落定或先 terminate'
|
|
70
102
|
if (t.status === 'resolved' || t.status === 'cancelled') return null
|
|
71
103
|
if (t.status === 'in-progress' && !hasActiveRun(sid, t.id)) return null
|
|
72
104
|
return 'cannot archive'
|
|
@@ -149,7 +181,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
149
181
|
// 门禁,旧守卫 `t.claimedBy !== actor` 对 null 零容忍 = 谁都解不了,手动兜底被堵死;
|
|
150
182
|
// claimedBy 非空且非本人时照旧拒(既有守卫语义不变)。
|
|
151
183
|
ctx.tools.register(defineTool({ name: 'task_resolve', description: '提交验证(verifying)或阻塞(blocked)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, status: { type: 'string', enum: ['verifying', 'blocked'] }, resolution: { type: 'string' } }, required: ['taskId', 'status'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; if (t.claimedBy && t.claimedBy !== actor) return { ok: false, error: 'not claimed by you' }; if (args.status === 'verifying' && !args.resolution) return { ok: false, error: 'resolution required' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) } }))
|
|
152
|
-
ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; var __vr = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); __vr.hint = REJECT_REDISPATCH_HINT } return __vr }) } }))
|
|
184
|
+
ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。Verifier 仍在跑(无 endedAt/run outcome 仍 running)时默认拦截并返回提示;确认提前裁决请加 force:true。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' }, force: { type: 'boolean' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; var __act = activeRunOf(sid, t); if (__act && __act.role === 'verifier' && args.force !== true) return { ok: false, error: 'Verifier 还在跑(run ' + __act.id + '),提前裁决将丢弃它的独立结论。确认提前裁决请加 force:true', verifierActive: __act.id }; var __vr = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); __vr.hint = REJECT_REDISPATCH_HINT } return __vr }) } }))
|
|
153
185
|
ctx.tools.register(defineTool({ name: 'task_archive', description: '归档已解决/已取消任务(无活跃 run 的 in-progress 僵尸卡也可归档,用于 parentKick 僵尸态出清)。', parameters: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __ae = archiveErr(sid, t); if (__ae) return { ok: false, error: __ae }; return archiveApply(d, t, actor, 'archived') }) } }))
|
|
154
186
|
ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时以文件清单(路径:L行号 — 一句用途)随首条 prompt 注入,内容由子代理按需 read 自取' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, unfreeze: { type: 'boolean', description: '解除冻结(frozen)并立即触发派发,用于 hold 裁决补完上下文后重新入池' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; delete t.retryCount; delete t.stuckSince; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
|
|
155
187
|
ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时以「文件清单」(每行「路径:L起-L止 — 一句用途」)随首条 prompt 一次性注入,只给路径与行号、不含内容本体(子代理用 read 工具按行号范围按需自取:执行时盘面更新鲜,也免了大文件正文撑爆 prompt 与随快照每轮重发),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,全文随首条 prompt 一次性注入(≤8000 字符)。【开发类任务(涉及代码改动/修复/特性)务必带文件调研】:把调研中读过的关键文件路径放进 contextFiles、结论思路放进 contextNotes——实测可让 Worker 省去 10~15 分钟从零 grep 定位的时间,且方向感天壤之别(无调研的 Worker 只能靠标题猜需求、极易跑偏)。调研门禁:pipeline=full/work 且 touches 非空而未带调研上下文时,返回将附 warning 提示。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务持有文件锁(in-progress/verifying 持有,状态流转到 resolved/cancelled/archived 即释放——锁只护正在写的阶段,验收后-提交前的窗口期由主窗口提交纪律 + 史诗 post-hook 承接)。' + TASK_SIZE_CONTRACT, parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径;可写「路径:L1-L2 — 一句用途」),派发时以文件清单随首条 prompt 注入(只给路径行号,内容由子代理按需 read 自取)' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),全文随首条 prompt 一次性注入' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (args.hooks !== undefined) { var __hn = normalizeHooks(args.hooks); if (__hn.error) return { ok: false, error: __hn.error }; if (__hn.hooks && Object.keys(__hn.hooks).length) t.hooks = __hn.hooks } if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 warning 族(与 create-task RPC 同口径,core 纯函数):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建 */ var __out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var __warns = createTaskWarnings(t); attachContextSuggestions(__out, __warns, t.touches, existsInSession(sid)); if (__warns.length) __out.warning = __warns.join(';'); return __out }) } }))
|
|
@@ -157,10 +189,30 @@ export function createRpc(ctx, state, deps) {
|
|
|
157
189
|
// ===== RPC =====
|
|
158
190
|
// get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
|
|
159
191
|
// 客户端 3s 轮询不再触发池计算/写盘(之前每轮询一次就 poolCycle+写盘一次,切会话时多会话轮询挤在文件锁上)
|
|
160
|
-
// isRoot
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
192
|
+
// isRoot 持久语义(banner 入口瞬态/持久消失修复):根会话=无父(持久 parentSession 缺省),不再读活跃 roots() 集。
|
|
193
|
+
// 旧实现 roots() 只含活体会话——休眠根会话(有板有卡)恒 false,且生成开始/结束 agents 树重建有瞬态窗口
|
|
194
|
+
// 返回不含本 sid(假 false),入口分钟级消失。改读持久 parentSession(见 isRootPersistent):
|
|
195
|
+
// 主源 sessionPersistence.stat(sid)(持久表,休眠会话也读得到)→ header.parentSession 缺省 = 根;
|
|
196
|
+
// 老宿主/测试桩无 sessionPersistence → 回退活跃 agent 的 session.header.parentSession,再回退旧 roots() 成员判定。
|
|
197
|
+
// 防抖仍在客户端:kernel.js applyIsRoot——曾确认 true 的会话需连续 3 次 false(3s 心跳≈9s)才收抽屉;
|
|
198
|
+
// 从未 true 的子代理会话仍即时收起(反馈 n-muuerxv9ijxs / task-muupr8ld)。
|
|
199
|
+
async function isRootPersistent(sid) {
|
|
200
|
+
var sp = ctx.get && ctx.get('sessionPersistence')
|
|
201
|
+
if (sp && typeof sp.stat === 'function') {
|
|
202
|
+
try { var snap = await sp.stat(sid); if (snap && snap.header) return !snap.header.parentSession } catch (_) {}
|
|
203
|
+
}
|
|
204
|
+
var ag = ctx.agents
|
|
205
|
+
if (ag) {
|
|
206
|
+
var live = typeof ag.get === 'function' ? ag.get(sid) : undefined
|
|
207
|
+
if (live && live.session && live.session.header && live.session.header.parentSession) return false
|
|
208
|
+
var roots = ag.roots()
|
|
209
|
+
var rids = []
|
|
210
|
+
for (var i = 0; i < roots.length; i++) rids.push(String(roots[i].id))
|
|
211
|
+
return rids.indexOf(sid) >= 0
|
|
212
|
+
}
|
|
213
|
+
return true
|
|
214
|
+
}
|
|
215
|
+
handle('get-tasks', async function (args) { var sid = rpcSessionId(args); var d = await rt(sid); d.sessionId = sid; d.isRoot = await isRootPersistent(sid)
|
|
164
216
|
// poolStatus 防幽灵:只保留指向当前活跃任务的条目(重启后内存 runs 清空,文件快照可能残留)
|
|
165
217
|
if (d.poolStatus) { var __act = {}; (d.tasks || []).forEach(function (t) { if (t.status === 'in-progress' || t.status === 'verifying') __act[t.id] = true }); d.poolStatus.workers = (d.poolStatus.workers || []).filter(function (w) { return __act[w.taskId] }); d.poolStatus.verifiers = (d.poolStatus.verifiers || []).filter(function (v) { return __act[v.taskId] }) }
|
|
166
218
|
// 学习飞轮 v1:把 feedbackEnabled 显式放进返回体(normalizeBoard 已按老看板补默认 true),
|
|
@@ -224,6 +276,15 @@ export function createRpc(ctx, state, deps) {
|
|
|
224
276
|
// 父卡列位置/进度展示的数据源;total 含已归档子任务,settled=resolved|cancelled|archived(resolved 为
|
|
225
277
|
// 兼容别名同值),因此归档子卡不会让进度分母缩水(task-muupgfot);只有无任何子任务的父卡才不出键
|
|
226
278
|
d.childStats = aggregateChildStats(d.tasks)
|
|
279
|
+
// ===== 风险队列「待你过目」:reviewHint 现算不落库(异常驱动审视①,task-muzikj1y)=====
|
|
280
|
+
// 四硬信号 + 两软信号(软信号仅质量异动告警激活期间计入,读 __qch 非空)现算后挂到每张卡,
|
|
281
|
+
// get-tasks 透出;唯一新落库字段=reviewedAt(mark-reviewed RPC 落)。挂在 tasksHash 之前——
|
|
282
|
+
// reviewHint + reviewedAt 都驱动列表渲染,须进 hash 序列化口径(否则已阅/软信号联动不重渲染)。
|
|
283
|
+
var __rhints = core.computeReviewHints(d.tasks, { qualityAlertActive: __qch.length > 0 })
|
|
284
|
+
for (var __ri = 0; __ri < (d.tasks || []).length; __ri++) {
|
|
285
|
+
var __rkt = d.tasks[__ri]
|
|
286
|
+
if (__rkt && __rhints[__rkt.id]) __rkt.reviewHint = __rhints[__rkt.id]
|
|
287
|
+
}
|
|
227
288
|
// 渲染变更检测(反馈 n-mut9rzs2mkhg):tasks 关键字段的稳定 hash(纯函数,口径见 core.tasksHash),
|
|
228
289
|
// 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify——传输仍全量,省的是渲染
|
|
229
290
|
d.tasksHash = tasksHash(d.tasks)
|
|
@@ -308,8 +369,13 @@ export function createRpc(ctx, state, deps) {
|
|
|
308
369
|
})
|
|
309
370
|
handle('claim-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var err = claimCheck(d, t, actor); if (err) return { ok: false, error: err }; claimApply(d, t, actor, 'manual claim via board'); return { ok: true, task: t } }) })
|
|
310
371
|
handle('resolve-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) })
|
|
311
|
-
handle('verify-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; delete t.escalation; delete t.verifyRetries; var r = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); pushRejectLesson(d, t, args.comment); r.hint = REJECT_REDISPATCH_HINT } return r }) })
|
|
372
|
+
handle('verify-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; var __act = activeRunOf(sid, t); if (__act && __act.role === 'verifier' && args.force !== true) return { ok: false, error: 'Verifier 还在跑(run ' + __act.id + '),提前裁决将丢弃它的独立结论。确认提前裁决请加 force:true', verifierActive: __act.id }; delete t.escalation; delete t.verifyRetries; var r = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); pushRejectLesson(d, t, args.comment); r.hint = REJECT_REDISPATCH_HINT } return r }) })
|
|
312
373
|
handle('archive-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __ae = archiveErr(sid, t); if (__ae) return { ok: false, error: __ae }; return archiveApply(d, t, actor, 'manual archive') }) })
|
|
374
|
+
// ===== 已阅落账(异常驱动审视①③):打开详情页即幂等落 reviewedAt(唯一新落库字段)=====
|
|
375
|
+
// 幂等只写一次:已有 reviewedAt 就原样返回、绝不重写(时间戳不漂移、history 不刷屏);
|
|
376
|
+
// 后续重复打开详情页零副作用。reviewedAt 进 tasksHash 序列化口径 → 落账后下一轮 get-tasks hash 变化,
|
|
377
|
+
// 客户端列表徽章/chip 即时消失(配合 kernel.fetchTasks 的 hash 短路渲染节约)。
|
|
378
|
+
handle('mark-reviewed', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (!t.reviewedAt) t.reviewedAt = new Date().toISOString(); return { ok: true, taskId: t.id, reviewedAt: t.reviewedAt } }) })
|
|
313
379
|
// ===== 任务删除通道(真删,无 undo)=====
|
|
314
380
|
// 背景:archive-task 只收 resolved/cancelled,草稿/误建卡片此前没有任何下线通道(只能永远挂着)。
|
|
315
381
|
// 状态门禁(delete-task 与 batch-op delete 共用本函数,保证两条入口语义完全一致):
|
|
@@ -346,7 +412,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
346
412
|
return { ok: true, deleted: t.id }
|
|
347
413
|
})
|
|
348
414
|
})
|
|
349
|
-
handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; delete t.retryCount; delete t.stuckSince; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
|
|
415
|
+
handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.tags !== undefined) t.tags = Array.isArray(args.tags) ? args.tags.map(function (s) { return String(s).trim() }).filter(function (s) { return s.length > 0 }) : []; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; delete t.retryCount; delete t.stuckSince; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
|
|
350
416
|
handle('set-board-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.boardMode = args.mode === 'manual' ? 'manual' : 'auto'; if (d.boardMode === 'manual' && d.teamMode) { d.teamMode = false; teamModeCache[sid] = false }; return { ok: true, boardMode: d.boardMode, teamMode: !!d.teamMode, workMode: deriveWorkMode(d) } }) })
|
|
351
417
|
handle('set-team-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.teamMode = !!args.enabled; if (d.teamMode) d.boardMode = 'auto'; teamModeCache[sid] = d.teamMode; return { ok: true, teamMode: d.teamMode, boardMode: d.boardMode, workMode: deriveWorkMode(d) } }) })
|
|
352
418
|
// 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
|
|
@@ -512,7 +578,11 @@ export function createRpc(ctx, state, deps) {
|
|
|
512
578
|
if (!t) return null
|
|
513
579
|
delete t.stuckSince
|
|
514
580
|
if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', actor, '手动终止,任务重新排队') }
|
|
515
|
-
|
|
581
|
+
// 验收时序三洞② 回归修复:终止 verifier 时同步清 verifierRun 认领位(与 in-progress 清 claimedBy
|
|
582
|
+
// 同口径)。否则卡面 verifierRun 仍指向已终止 run 的 running 留档(terminate 刻意不 closeRunHistory),
|
|
583
|
+
// activeRunOf 的卡面兜底(liveEntry 按 verifierRun 匹配)会把已死的 run 误判为活跃 → archive 门禁
|
|
584
|
+
// 永久拒绝,而报错文案推荐的补救「先 terminate」再跑一次也是空转(内存表已摘、认领位不清)= 死结。
|
|
585
|
+
else if (t.status === 'verifying') { t.verifierRun = null; delete t.verifierRunAt; ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
|
|
516
586
|
return t
|
|
517
587
|
})
|
|
518
588
|
return { ok: true, terminated: label }
|
|
@@ -620,7 +690,14 @@ export function createRpc(ctx, state, deps) {
|
|
|
620
690
|
var result = await mutateLocked(sid, function (d) {
|
|
621
691
|
var t = d.tasks.find(function (x) { return x.id === args.taskId })
|
|
622
692
|
if (!t) return { ok: false, error: 'not found' }
|
|
623
|
-
if (t.status !== 'verifying')
|
|
693
|
+
if (t.status !== 'verifying') {
|
|
694
|
+
// 迟到落账(验收时序三洞 ③):卡已落定(resolved/archived/cancelled)→ verifier 结论不再静默丢弃
|
|
695
|
+
if (t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled') {
|
|
696
|
+
var lr = lateVerdictApply(t, args.verdict, args.summary, args.checks, args.userTest, new Date().toISOString(), actor, cfg(d).verifyUserGuide)
|
|
697
|
+
return { ok: true, late: true, lateRejected: lr.lateRejected && lr.wasApproved, task: t }
|
|
698
|
+
}
|
|
699
|
+
return { ok: false, error: 'not verifying (状态: ' + t.status + ')' }
|
|
700
|
+
}
|
|
624
701
|
delete t.escalation; delete t.verifyRetries // verifier 恢复产出:清人工验收标记
|
|
625
702
|
t.verification = { verdict: args.verdict, summary: args.summary || '', checks: args.checks || '', at: new Date().toISOString(), by: actor }
|
|
626
703
|
// 自测指南落账(verifyUserGuide 开关门禁,task-muxyyvg0):normalizeUserTest 归一(tier 三档
|
|
@@ -636,16 +713,21 @@ export function createRpc(ctx, state, deps) {
|
|
|
636
713
|
if (!approved) pushRejection(t, args.summary, args.checks, t.verification.at, actor)
|
|
637
714
|
return { ok: true, task: t }
|
|
638
715
|
})
|
|
639
|
-
if (result && result.ok && result.task) {
|
|
716
|
+
if (result && result.ok && result.task && !result.late) {
|
|
640
717
|
if (result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
|
|
641
718
|
if (result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
|
|
642
719
|
}
|
|
643
|
-
if (result && result.ok && !approved && result.task) {
|
|
720
|
+
if (result && result.ok && !approved && result.task && !result.late) {
|
|
644
721
|
// v74 一次性模型:原 Worker 已销毁,驳回任务回 pending 重派新 Worker(完整驳回包已落 t.messages,
|
|
645
722
|
// 经 buildMessages 全量随 prompt 注入;history 只是审计轨)
|
|
646
723
|
var bt = result.task
|
|
647
724
|
if ((bt.rejectCount || 0) < 3) { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === bt.id }); if (t2 && t2.status === 'in-progress') { t2.status = 'pending'; t2.claimedBy = null; t2.claimedAt = null }; return t2 }, true) }
|
|
648
725
|
}
|
|
726
|
+
// 迟到驳回(验收时序三洞 ③):verdict=rejected 且卡已 approved → err 级通知(回执直投 owner,不走开关)+ healthHint
|
|
727
|
+
if (result && result.ok && result.late && result.lateRejected) {
|
|
728
|
+
if (notifyLateReject) notifyLateReject(sid, result.task)
|
|
729
|
+
markLateRejectHealth(sid, result.task.id)
|
|
730
|
+
}
|
|
649
731
|
return result
|
|
650
732
|
} }))
|
|
651
733
|
// 枚举当前网关可用模型(供 Verifier 模型下拉选择):llm.listProviders + listModels
|
|
@@ -715,6 +797,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
715
797
|
console.error('[task-board] batch delete: ' + id + ' «' + String(t.title || '').slice(0, 60) + '» (status=' + t.status + ') by ' + actor)
|
|
716
798
|
done++
|
|
717
799
|
} else if (args.op === 'archive') {
|
|
800
|
+
var __act = activeRunOf(sid, t)
|
|
801
|
+
if (__act) { skip(id, '有活跃 run(' + (__act.role === 'verifier' ? 'verifier ' : 'worker ') + __act.id + ' 仍在跑),等它落定或先 terminate'); return }
|
|
718
802
|
if (t.status !== 'resolved' && t.status !== 'cancelled') { skip(id, 'cannot archive'); return }
|
|
719
803
|
var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'batch archive'); done++
|
|
720
804
|
archived.push(t)
|
package/lib/store.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// dsh-agent-board — 看板持久化层(lib/store.mjs)
|
|
2
|
-
// createStore(ctx, state, deps):家目录绝对路径直读写 / 原子写盘(tmp+rename
|
|
3
|
-
// 跨重启继承(唯一孤儿板接管)/
|
|
2
|
+
// createStore(ctx, state, deps):家目录绝对路径直读写 / 原子写盘(tmp+fsync+rename+瞬时占用退避+unlink 兜底)/
|
|
3
|
+
// 跨重启继承(唯一孤儿板接管)/ 腐坏隔离可见化 + 自动 salvage(截断抢救)/
|
|
4
|
+
// 每会话文件锁串行化读-改-写 / mutateLocked + kickCycle 派发触发。
|
|
4
5
|
// deps.poolCycle 为晚绑定(index.mjs 在 dispatch 创建后收口):kickCycle 经 50ms 去抖才调用,
|
|
5
6
|
// 而 kickCycle 只可能被工具/RPC/心跳写盘触发(全部发生在 apply 接线完成之后),晚绑定无窗口风险。
|
|
6
7
|
import * as core from './core.mjs'
|
|
@@ -8,6 +9,24 @@ import path from 'node:path'
|
|
|
8
9
|
import fsNode from 'node:fs'
|
|
9
10
|
const { seed, normalizeBoard, vt, boardHome } = core
|
|
10
11
|
|
|
12
|
+
// 腐坏看板自动抢救(P0 根修 task-mv0bl9vg ③):从撕裂/夹残片的 JSON 原文里截取**第一个完整 JSON 值**。
|
|
13
|
+
// 手法 = JSON.parse 报错 position 截断法(本次事故人工恢复就是这招):整串 parse 失败时,
|
|
14
|
+
// V8 报错信息含 "position N"(实测 node v24:'{"a":1}{"b":2}' 报 position 18,即第二个值起点),
|
|
15
|
+
// 截到 N 再 parse 即得第一个完整值;无 position(如单对象中部截断 "Unexpected end of JSON input")或
|
|
16
|
+
// 截断后仍 parse 失败 → 返回 { ok:false },交由调用方隔离空板。
|
|
17
|
+
function trySalvage(raw) {
|
|
18
|
+
if (typeof raw !== 'string' || !raw) return { ok: false }
|
|
19
|
+
try { var d = JSON.parse(raw); return d && typeof d === 'object' ? { ok: true, board: d } : { ok: false } }
|
|
20
|
+
catch (e) {
|
|
21
|
+
var m = /position (\d+)/.exec(String(e && e.message))
|
|
22
|
+
if (!m) return { ok: false }
|
|
23
|
+
var pos = parseInt(m[1], 10)
|
|
24
|
+
if (!(pos > 0)) return { ok: false }
|
|
25
|
+
try { var v = JSON.parse(raw.slice(0, pos)); return v && typeof v === 'object' ? { ok: true, board: v } : { ok: false } }
|
|
26
|
+
catch (_) { return { ok: false } }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
export function createStore(ctx, state, deps) {
|
|
12
31
|
var sessionCwd = deps.sessionCwd
|
|
13
32
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
@@ -16,6 +35,7 @@ export function createStore(ctx, state, deps) {
|
|
|
16
35
|
var epicSplitCache = state.epicSplitCache
|
|
17
36
|
var fileLocks = state.fileLocks
|
|
18
37
|
var cyclePending = state.cyclePending
|
|
38
|
+
var poolHealth = state.poolHealth || (state.poolHealth = {}) // 腐坏隔离/抢救 hint 落这里(与 runtime health 同表)
|
|
19
39
|
|
|
20
40
|
// 看板文件用「家目录绝对路径 + node:fs」直读写——不再走 fs 服务的相对路径解析
|
|
21
41
|
// (fs 服务的相对路径解析根 = 进程启动 cwd,cwd 一变所有看板静默读成空板;
|
|
@@ -146,27 +166,81 @@ export function createStore(ctx, state, deps) {
|
|
|
146
166
|
}
|
|
147
167
|
return seed(sid, sessionCwd(sid))
|
|
148
168
|
} catch (_) {
|
|
149
|
-
// JSON
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
169
|
+
// JSON 截断/损坏(如强杀打断写盘、中部夹杂另一版本残片)→ 抢救 + 隔离 + 可见化(P0 根修)
|
|
170
|
+
return recoverCorrupt(sid, r)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// ===== 腐坏看板:自动 salvage + 隔离可见化(task-mv0bl9vg ②③)=====
|
|
174
|
+
// 三件套:① 隔离前先 trySalvage 截断抢救(成功则以抢救出的完整 JSON 为底继续);
|
|
175
|
+
// ② 原始坏文件一律 .corrupt-<ts> 留档(抢救成功=复制留档+写回覆盖,抢救失败=改名隔离;撕裂尾部都不丢);
|
|
176
|
+
// ③ 追加 healthHints err 级 hint(写 poolHealth[sid].corruptNote)+ notify 通知 owner——不再静默。
|
|
177
|
+
async function recoverCorrupt(sid, raw) {
|
|
178
|
+
var p = boardPath(sid)
|
|
179
|
+
var ts = Date.now()
|
|
180
|
+
var corruptPath = p + '.corrupt-' + ts
|
|
181
|
+
var salv = trySalvage(raw)
|
|
182
|
+
var salvaged = null, kept = 0
|
|
183
|
+
if (salv.ok && vt(salv.board)) {
|
|
184
|
+
salvaged = normalizeBoard(salv.board)
|
|
185
|
+
salvaged.ownerSession = sid // 抢救出的板归属本 sid(与 adoptBoard 同口径)
|
|
186
|
+
var cw = sessionCwd(sid)
|
|
187
|
+
if (!salvaged.ownerCwd && cw) salvaged.ownerCwd = cw
|
|
188
|
+
kept = (salvaged.tasks || []).length
|
|
189
|
+
}
|
|
190
|
+
if (salvaged) {
|
|
191
|
+
// 抢救成功:原始坏内容**复制**留档 .corrupt-<ts>(不动主板,撕裂尾部不丢),
|
|
192
|
+
// 再以截断后的完整 JSON 为底原子写回主板继续用;写回失败则下次读盘再抢救(幂等,不留空板空档)。
|
|
193
|
+
try { await fsNode.promises.copyFile(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板复制留档失败:', String(e)) }
|
|
194
|
+
try {
|
|
195
|
+
await wt(sid, salvaged)
|
|
196
|
+
teamModeCache[sid] = !!salvaged.teamMode
|
|
197
|
+
feedbackCache[sid] = salvaged.feedbackEnabled !== false
|
|
198
|
+
epicSplitCache[sid] = salvaged.epicSplit !== false
|
|
199
|
+
} catch (e) { console.error('[task-board] 抢救写回失败(原始文件已留档 ' + corruptPath + '):', String(e)) }
|
|
200
|
+
recordCorruptNote(sid, { at: ts, salvage: true, kept: kept, file: path.basename(corruptPath) })
|
|
201
|
+
console.error('[task-board] board file corrupt, salvaged (kept ' + kept + ' tasks), original quarantined: ' + corruptPath)
|
|
202
|
+
return salvaged
|
|
203
|
+
}
|
|
204
|
+
// 抢救失败:坏文件改名 .corrupt-<ts> 隔离(防反复 poison)+ 空板重启(禁止回写)+ 可见 hint + notify
|
|
205
|
+
try { await fsNode.promises.rename(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板隔离改名失败(下次读盘重试):', String(e)) }
|
|
206
|
+
recordCorruptNote(sid, { at: ts, salvage: false, kept: 0, file: path.basename(corruptPath) })
|
|
207
|
+
console.error('[task-board] board file corrupt, quarantined: ' + corruptPath)
|
|
208
|
+
return seedNoPersist(sid)
|
|
209
|
+
}
|
|
210
|
+
// 腐坏/抢救 hint 落 poolHealth(get-tasks 现算透出 err 级 hint)+ notify 通知 owner
|
|
211
|
+
function recordCorruptNote(sid, note) {
|
|
212
|
+
var rec = poolHealth[sid] || (poolHealth[sid] = {})
|
|
213
|
+
rec.corruptNote = note
|
|
214
|
+
var text = note.salvage
|
|
215
|
+
? '看板数据文件腐坏,已自动抢救保留 ' + note.kept + ' 张卡(丢弃撕裂残片),原始文件留档 ' + note.file + ',可联系恢复'
|
|
216
|
+
: '看板数据文件腐坏已隔离,历史在 ' + note.file + ',可联系恢复'
|
|
217
|
+
// 晚绑定直读 deps.notifyBoardCorrupt(index.mjs 在 notify 创建后才收口,创建时取值为 undefined)
|
|
218
|
+
var nb = deps.notifyBoardCorrupt
|
|
219
|
+
if (typeof nb === 'function') {
|
|
220
|
+
try { nb(sid, text) } catch (e) { console.error('[task-board] 腐坏通知失败:', String(e)) }
|
|
153
221
|
}
|
|
154
222
|
}
|
|
155
223
|
// 瞬时读失败/坏文件隔离后的空板必须禁止回写:否则一个「读不到」的瞬间就会把 106 张卡覆成空板。
|
|
156
224
|
// __noPersist 用 non-enumerable 挂载——即使将来有路径漏判把它写出去,JSON.stringify 也会跳过该字段。
|
|
157
225
|
function seedNoPersist(sid) { var d = seed(sid, sessionCwd(sid)); try { Object.defineProperty(d, '__noPersist', { value: true, enumerable: false }) } catch (_) {} return d }
|
|
158
|
-
//
|
|
226
|
+
// 原子写盘:写 tmp → fsync → rename 覆盖目标——强杀若发生在写盘中途,磁盘上最多留个 .tmp 残件,
|
|
159
227
|
// 看板本体永远不会是截断的半个 JSON(此前非原子直写,kill 中写 = 看板被 seed 清空)
|
|
228
|
+
// fsync 保证 tmp 内容真正落盘后再 rename(rename 在同分区是原子的)。
|
|
160
229
|
// Windows 特有问题:rename 目标被并发读句柄/Defender/索引器短暂占用时抛 EPERM/EBUSY
|
|
161
230
|
// (E2E 实测:GUI 3s 轮询 + 脚本 10s 轮询下偶发,publish 写入整个丢失)。
|
|
162
231
|
// 对这类瞬时占用做有限退避重试;其他错误(只读/不存在目录等)直接抛。
|
|
232
|
+
// 重试耗尽仍失败 → 降级「先 unlink 目标再 rename」并记录(Windows rename-over-existing 实测
|
|
233
|
+
// node v24 win32 直接覆盖 OK,仅被占时才需 unlink 兜底——绝不丢整板)。
|
|
234
|
+
async function writeTmpFsync(tmp, content) {
|
|
235
|
+
var fh = await fsNode.promises.open(tmp, 'w')
|
|
236
|
+
try { await fh.writeFile(content, 'utf8'); await fh.sync() }
|
|
237
|
+
finally { await fh.close().catch(function () {}) }
|
|
238
|
+
}
|
|
163
239
|
async function wt(sid, d) {
|
|
164
240
|
var c = JSON.stringify(d); var p = boardPath(sid); var tmp = p + '.tmp'
|
|
165
|
-
var lastErr = null
|
|
166
241
|
for (var attempt = 0; attempt < 6; attempt++) {
|
|
167
|
-
try { await
|
|
242
|
+
try { await writeTmpFsync(tmp, c); await fsNode.promises.rename(tmp, p); return }
|
|
168
243
|
catch (e) {
|
|
169
|
-
lastErr = e
|
|
170
244
|
var code = e && e.code
|
|
171
245
|
if (code === 'EPERM' || code === 'EBUSY' || code === 'ENOTEMPTY' || code === 'EACCES') {
|
|
172
246
|
await new Promise(function (r) { setTimeout(r, 60 * (attempt + 1)) })
|
|
@@ -175,7 +249,11 @@ export function createStore(ctx, state, deps) {
|
|
|
175
249
|
console.error('[task-board] write:', String(e)); throw e
|
|
176
250
|
}
|
|
177
251
|
}
|
|
178
|
-
|
|
252
|
+
// 退避重试耗尽(顽固瞬时占用)→ 降级「先 unlink 目标再 rename」并记录;最后一步失败则抛(不吞)
|
|
253
|
+
console.error('[task-board] write: rename 直覆失败(重试耗尽),降级 unlink+rename: ' + p)
|
|
254
|
+
try { await writeTmpFsync(tmp, c) } catch (e) { console.error('[task-board] write:', String(e)); throw e }
|
|
255
|
+
await unlinkRetry(p, 6)
|
|
256
|
+
await fsNode.promises.rename(tmp, p)
|
|
179
257
|
}
|
|
180
258
|
// 每会话一条 promise 链,串行化所有 读-改-写,消除并发写竞争
|
|
181
259
|
function withLock(sid, fn) { var prev = fileLocks[sid] || Promise.resolve(); var p = prev.then(function () { return fn() }); fileLocks[sid] = p.catch(function () {}); return p }
|
package/package.json
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// dsh-agent-board — 归档卡点开详情(用户 2026-10-09 指令)client 侧单测(test/archive-detail.test.mjs)
|
|
2
|
+
// client 无渲染 harness(react 不在包依赖,渲染由 dsh web 客户端运行时注入)——与历卡同一纪律:
|
|
3
|
+
// 全部走源码级断言(board-list.js / kernel.js / task-detail.js 模块源 + client.js 重组装产物)。
|
|
4
|
+
// 断言:
|
|
5
|
+
// ① 归档行可点击进详情:ArchiveView 行挂 onClick → state.detailId = t.id(复用 detailId 机制,与看板卡同路径);
|
|
6
|
+
// 恢复/历史会话按钮 stopPropagation——点「恢复待办」不误开详情
|
|
7
|
+
// ② 归档 tab 分支尊重 detailId:kernel.js view==='archive' 分支 detailId 优先渲染 DetailView
|
|
8
|
+
// ③ 已阅链路对归档卡生效:mark-reviewed 触发条件无 status 门禁(读 detailId,archived 同样落账)
|
|
9
|
+
// ④ 产物重组装:关键接线标记出现在 lib/client.js
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
const bl = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
|
|
15
|
+
const kernel = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
|
|
16
|
+
const td = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
|
|
17
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
18
|
+
|
|
19
|
+
// ===== ① 归档行可点击进详情 + 行内按钮不冒泡 =====
|
|
20
|
+
test('① 归档行可点击进详情(onClick 接线 state.detailId)+ 恢复/历史会话按钮不冒泡', () => {
|
|
21
|
+
assert.match(bl, /onClick: function \(\) \{ state\.detailId = t\.id; notify\(\) \}/)
|
|
22
|
+
assert.match(bl, /onClick: function \(e\) \{ e\.stopPropagation\(\); restore\(t\.id\) \}/)
|
|
23
|
+
assert.match(bl, /onClick: function \(e\) \{ e\.stopPropagation\(\); if \(uiWorkspaceSvc\) uiWorkspaceSvc\.openSession\(r\.id\) \}/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// ===== ② 归档 tab 分支尊重 detailId:复用 DetailView =====
|
|
27
|
+
test('② 归档 tab 分支尊重 detailId:复用 DetailView(与看板卡同路径)', () => {
|
|
28
|
+
assert.match(kernel, /view === 'archive'\) \{ content = detailId \? React\.createElement\(DetailView\) : React\.createElement\(ArchiveView\) \}/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// ===== ③ 已阅链路对归档卡生效:mark-reviewed 无 status 门禁 =====
|
|
32
|
+
test('③ 已阅链路对归档卡生效:mark-reviewed 触发无 status 门禁(读 detailId)', () => {
|
|
33
|
+
assert.match(td, /var t = getTask\(state\.detailId\); if \(t && t\.reviewHint && t\.reviewHint\.score > 0 && !t\.reviewedAt\) \{ rpc\('mark-reviewed'/)
|
|
34
|
+
// reviewPending 单一事实源含 archived(徽章随 fetchTasks 消失的口径)
|
|
35
|
+
assert.match(kernel, /t\.status === 'resolved' \|\| t\.status === 'archived'/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// ===== ④ 产物重组装 =====
|
|
39
|
+
test('④ 产物重组装:三处接线标记出现在 lib/client.js', () => {
|
|
40
|
+
for (const m of ['state.detailId = t.id; notify()', 'detailId ? React.createElement(DetailView) : React.createElement(ArchiveView)', 'e.stopPropagation(); restore(t.id)']) {
|
|
41
|
+
assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
|
|
42
|
+
}
|
|
43
|
+
})
|
package/test/core.test.mjs
CHANGED
|
@@ -1018,6 +1018,15 @@ test('README 双份同步记录 touches 锁随工作态口径(状态流转到
|
|
|
1018
1018
|
assert.ok(!pkg.includes('锁持到归档'), 'README 不应再记录「锁持到归档」旧口径')
|
|
1019
1019
|
})
|
|
1020
1020
|
|
|
1021
|
+
test('README 定位段:agent 团队持久台账与治理层关键词存在(台账/治理层/审计台/流水线归机器)+ 双份逐字一致', () => {
|
|
1022
|
+
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
1023
|
+
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
1024
|
+
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
|
|
1025
|
+
for (const s of ['agent 团队的持久台账与治理层', '台账', '治理层', '审计台', '流水线归机器,审计台归人', '验收独立复跑', '适合谁']) {
|
|
1026
|
+
assert.ok(root.includes(s), 'README 定位段应含关键词:' + s)
|
|
1027
|
+
}
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1021
1030
|
// ===== 派发决策(touches 拦截)=====
|
|
1022
1031
|
test('pickDispatch: touches 与活动任务冲突 → 不进 pendings,记入 blockedTouches', () => {
|
|
1023
1032
|
const tasks = [
|
|
@@ -4345,13 +4354,15 @@ test('isRoot 蝶变防抖(源码级):计数器 + 连续 3 次阈值 + ever
|
|
|
4345
4354
|
assert.equal((cli.match(/applyIsRoot\(/g) || []).length, 2) // 定义 1 + 轮询调用 1
|
|
4346
4355
|
})
|
|
4347
4356
|
|
|
4348
|
-
test('isRoot
|
|
4357
|
+
test('isRoot 持久语义:host 侧改读 parentSession(根=无父),防抖仍指回客户端', () => {
|
|
4349
4358
|
const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
4350
|
-
//
|
|
4351
|
-
assert.match(rpcSrc, /
|
|
4359
|
+
// isRoot 持久判定入口:根会话=无父,不再读活跃 roots() 集
|
|
4360
|
+
assert.match(rpcSrc, /async function isRootPersistent\(sid\)/)
|
|
4361
|
+
assert.match(rpcSrc, /snap\.header\) return !snap\.header\.parentSession/)
|
|
4362
|
+
assert.match(rpcSrc, /d\.isRoot = await isRootPersistent\(sid\)/)
|
|
4363
|
+
// 指针注释:防抖仍在客户端 kernel.js applyIsRoot,曾确认 true 需连续 3 次 false 才收
|
|
4364
|
+
assert.match(rpcSrc, /防抖仍在客户端:kernel\.js applyIsRoot/)
|
|
4352
4365
|
assert.match(rpcSrc, /曾确认 true 的会话需连续 3 次/)
|
|
4353
|
-
// 行为未改:isRoot 仍按 agents.roots() 现算(不缓存、不防抖、不落盘)
|
|
4354
|
-
assert.match(rpcSrc, /var __roots = __ag\.roots\(\)[\s\S]{0,200}d\.isRoot = __rids\.indexOf\(sid\) >= 0/)
|
|
4355
4366
|
})
|
|
4356
4367
|
|
|
4357
4368
|
// ===== 史诗 hooks UI(task-muuw56yf):卡片相位徽章 + 详情页 hooks 编辑区 =====
|
|
@@ -5582,3 +5593,49 @@ test('巡检③:batch-op archive 跳过附 reasons(id → 原因)且 clien
|
|
|
5582
5593
|
assert.match(built, /it\.title \+ ':' \+ it\.reason/)
|
|
5583
5594
|
assert.match(built, /'跳过原因'/)
|
|
5584
5595
|
})
|
|
5596
|
+
|
|
5597
|
+
// ===== 巡检三连②(详情编辑 / 表单标签 / 恢复预警):优先级+标签可编辑 / 标签进 create 载荷 / 恢复 confirm 门禁 =====
|
|
5598
|
+
test('巡检三连②-①:详情页优先级四档下拉 + 标签编辑走 update-task(含 tags 通道落卡 trim/去空)', async () => {
|
|
5599
|
+
const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
|
|
5600
|
+
// 优先级静态 chip → 四档下拉(value 读 task.priority,onChange 落 update-task priority 通道)
|
|
5601
|
+
assert.match(src, /value: task\.priority \|\| 'medium', onChange: function \(e\) \{ rpc\('update-task', \{ taskId: task\.id, priority: e\.target\.value \}\)\.then\(fetchTasks\)/)
|
|
5602
|
+
assert.match(src, /\['critical', 'high', 'medium', 'low'\]\.map/)
|
|
5603
|
+
// 标签编辑:TagsEditor 逗号分隔 input + update-task tags 通道
|
|
5604
|
+
assert.match(src, /function TagsEditor\(props\)/)
|
|
5605
|
+
assert.match(src, /rpc\('update-task', \{ taskId: task\.id, tags: tags \}\)/)
|
|
5606
|
+
// 行为级:update-task RPC tags 通道落卡 + trim/去空(详情页标签编辑的落点)
|
|
5607
|
+
const board = mkBoard([mkTask({ id: 't1', tags: [] })])
|
|
5608
|
+
const h = mkRpcHandlers(board)
|
|
5609
|
+
const r = await h['update-task']({ taskId: 't1', tags: ['bug', ' epic ', '', '待验收'] })
|
|
5610
|
+
assert.equal(r.ok, true)
|
|
5611
|
+
assert.deepEqual(board.tasks[0].tags, ['bug', 'epic', '待验收'])
|
|
5612
|
+
// 组装产物同步
|
|
5613
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
5614
|
+
assert.match(built, /function TagsEditor\(props\)/)
|
|
5615
|
+
assert.match(built, /value: task\.priority \|\| 'medium', onChange: function \(e\) \{ rpc\('update-task', \{ taskId: task\.id, priority: e\.target\.value \}\)\.then\(fetchTasks\)/)
|
|
5616
|
+
})
|
|
5617
|
+
|
|
5618
|
+
test('巡检三连②-②:新建表单「标签」字段进 create-task 载荷(逗号分隔 trim+去空)', () => {
|
|
5619
|
+
const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
|
|
5620
|
+
// 标签输入字段 + 逗号分隔解析(trim + 去空)
|
|
5621
|
+
assert.match(src, /var tags = tagsRaw\.split\(/)
|
|
5622
|
+
assert.match(src, /'标签(逗号分隔,可空)'/)
|
|
5623
|
+
// create-task 载荷带 tags 字段(与 touches/dependsOn 同级)
|
|
5624
|
+
assert.match(src, /tags: tags, dependsOn: deps/)
|
|
5625
|
+
// 组装产物同步
|
|
5626
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
5627
|
+
assert.match(built, /tags: tags, dependsOn: deps/)
|
|
5628
|
+
})
|
|
5629
|
+
|
|
5630
|
+
test('巡检三连②-③:归档「恢复待办」confirm 门禁(list 不弹 / auto+team 弹)+ title 预警', () => {
|
|
5631
|
+
const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
|
|
5632
|
+
// 工作模式派生(与 fetchTasks 同口径);list 不弹,auto/team 走 confirm 门禁
|
|
5633
|
+
assert.match(src, /var wm = state\.workMode \|\| \(state\.teamMode \? 'team' : \(state\.boardMode === 'auto' \? 'auto' : 'list'\)\)/)
|
|
5634
|
+
assert.match(src, /if \(wm !== 'list'\)/)
|
|
5635
|
+
assert.match(src, /window\.confirm\('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token。继续/)
|
|
5636
|
+
// 按钮 title 预警(非 list 模式),list 模式仅「恢复为待办」
|
|
5637
|
+
assert.match(src, /title: wm === 'list' \? '恢复为待办' : '恢复为待办(将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token)'/)
|
|
5638
|
+
// 组装产物同步
|
|
5639
|
+
const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
5640
|
+
assert.match(built, /window\.confirm\('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token。继续/)
|
|
5641
|
+
})
|
|
@@ -207,3 +207,28 @@ test('冒烟链路:apply(mockCtx) → 建卡 → 时钟推进 → continuable
|
|
|
207
207
|
assert.equal(t2.runs[1].resume, true, '第二条标 resume:true')
|
|
208
208
|
} finally { await env.cleanup() }
|
|
209
209
|
})
|
|
210
|
+
|
|
211
|
+
// ===== isRoot 持久语义(banner 入口瞬态/持久消失修复,task-muzys43o)=====
|
|
212
|
+
// 根因:host get-tasks 的 isRoot 旧实现读活跃 roots() 集——roots() 只含活体会话,
|
|
213
|
+
// 休眠根会话(有板有卡但无活 agent)恒 false → banner「智能看板」按钮分钟级消失;
|
|
214
|
+
// 且生成开始/结束 agents 树重建有瞬态窗口假 false。修法:isRoot 改读持久 parentSession
|
|
215
|
+
// (根会话=无父),休眠根也算 root;子代理会话(parentSession 指向父)仍 false。
|
|
216
|
+
test('isRoot 持久语义:休眠根会话仍 root(parentSession 缺省),子代理会话仍 false', async () => {
|
|
217
|
+
var env = createMockCtx()
|
|
218
|
+
try {
|
|
219
|
+
plugin.apply(env.ctx)
|
|
220
|
+
// 造一块「有板有卡」的根会话板(证明休眠前确有板卡)
|
|
221
|
+
var created = await env.rpc('create-task', { sessionId: env.sid, id: 't-isroot-1', title: '休眠根会话卡', pipeline: 'work' })
|
|
222
|
+
assert.equal(created.ok, true)
|
|
223
|
+
// 模拟休眠:活跃 roots 清空(无活 agent),但持久表仍记 parentSession 缺省(根)
|
|
224
|
+
env.setRoots([])
|
|
225
|
+
var r = await env.rpc('get-tasks', { sessionId: env.sid })
|
|
226
|
+
assert.equal(r.isRoot, true, '① 休眠根会话 isRoot=true(持久 parentSession 缺省,不依赖活跃 roots 集)')
|
|
227
|
+
assert.equal(r.tasks.length, 1, '休眠不丢板卡(rt 读盘仍在)')
|
|
228
|
+
// ② 子代理会话:持久表记 parentSession → false(保护语义不变)
|
|
229
|
+
var childSid = 'session-child-aaaa-bbbb'
|
|
230
|
+
env.persistedHeaders[childSid] = { id: childSid, parentSession: env.sid }
|
|
231
|
+
var rc = await env.rpc('get-tasks', { sessionId: childSid })
|
|
232
|
+
assert.equal(rc.isRoot, false, '② 子代理会话 isRoot=false(parentSession 指向父)')
|
|
233
|
+
} finally { await env.cleanup() }
|
|
234
|
+
})
|