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 +2 -2
- package/index.mjs +1 -0
- package/lib/client/board-list.js +12 -8
- package/lib/client/dashboard.js +5 -4
- package/lib/client/kernel.js +32 -5
- package/lib/client/task-detail.js +2 -0
- package/lib/client.js +51 -17
- package/lib/core.mjs +21 -0
- package/lib/dispatch.mjs +17 -5
- package/lib/rpc.mjs +53 -9
- package/lib/store.mjs +86 -62
- package/package.json +1 -1
- package/test/core.test.mjs +142 -12
- package/test/scoreboard-trends-client.test.mjs +21 -0
- package/test/store-data-safety.test.mjs +105 -0
- package/test/verify-timing.test.mjs +26 -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, lateVerdictApply, 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, cancelApply, 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
|
|
@@ -45,6 +45,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
45
45
|
var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
|
|
46
46
|
var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
|
|
47
47
|
var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
|
|
48
|
+
// 手动终止的 run 关账(outcome:'terminated',task-mv1pqy27 死结窗口根修):与状态无关,
|
|
49
|
+
// 凡 terminate 命中该卡的 run 就关。未注入(老宿主/测试桩)→ 静默跳过,行为与改造前逐字一致。
|
|
50
|
+
var closeRunHistory = (typeof deps.closeRunHistory === 'function') ? deps.closeRunHistory : function () {}
|
|
48
51
|
// 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
|
|
49
52
|
// Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
|
|
50
53
|
// 行为与本次改造前逐字一致(那时本来就没人做这件事)。
|
|
@@ -183,7 +186,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
183
186
|
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) }) } }))
|
|
184
187
|
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 }) } }))
|
|
185
188
|
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') }) } }))
|
|
186
|
-
ctx.tools.register(defineTool({ name: '
|
|
189
|
+
ctx.tools.register(defineTool({ name: 'task_cancel', description: '取消任务:draft/pending/blocked 态可取消(取消后不再派发,可在归档 tab 恢复待办)。in-progress/verifying 需先 terminate-agent,resolved 请用归档。reason 可选,落流转轨迹。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, reason: { type: 'string', description: '取消理由(可选,随流转轨迹留档)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doCancel(toolSessionId(), getActorId(), args.taskId, args.reason) } }))
|
|
190
|
+
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; delete t.cancelledAt; 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 } }))
|
|
187
191
|
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 }) } }))
|
|
188
192
|
|
|
189
193
|
// ===== RPC =====
|
|
@@ -412,7 +416,39 @@ export function createRpc(ctx, state, deps) {
|
|
|
412
416
|
return { ok: true, deleted: t.id }
|
|
413
417
|
})
|
|
414
418
|
})
|
|
415
|
-
|
|
419
|
+
// ===== 任务取消通道(task_cancel 工具与 cancel-task RPC 共享,单一行为口径)=====
|
|
420
|
+
// 背景:主窗口作废一张 pending/draft/blocked 卡无正规通道——archive 拒收、update 无 status、
|
|
421
|
+
// delete 是真删(丢留档)。取消=显式放弃:置 cancelled + 留档可检索、可在归档 tab 恢复待办。
|
|
422
|
+
// 状态门禁(与 deleteGate 同居「门禁在接线层」分工):
|
|
423
|
+
// draft / pending / blocked → 允许(未产生执行痕迹)
|
|
424
|
+
// in-progress 且 claimedBy=spawn-pending → 允许(派发瞬时占位,无真实 run,cancelApply 内回退占位)
|
|
425
|
+
// in-progress(真实 run)/ verifying → 拒绝,提示先 terminate-agent 终止
|
|
426
|
+
// resolved → 拒绝,引导用 archive-task(已落定任务留档可检索)
|
|
427
|
+
// cancelled / archived → 幂等 ok(已在取消/归档态)
|
|
428
|
+
// 返回 null(放行)或 { err }(拒绝)或 { mode: 'already' }(幂等)。
|
|
429
|
+
function cancelGate(t) {
|
|
430
|
+
if (t.status === 'in-progress' && t.claimedBy === 'spawn-pending') return null
|
|
431
|
+
if (t.status === 'in-progress' || t.status === 'verifying') return { err: '任务正在执行中,请先用 terminate-agent 终止(in-progress 回待办、verifying 换 verifier 接手)再取消' }
|
|
432
|
+
if (t.status === 'resolved') return { err: '已落定任务请用归档(archive-task),不要取消' }
|
|
433
|
+
if (t.status === 'cancelled' || t.status === 'archived') return { mode: 'already' }
|
|
434
|
+
if (t.status !== 'draft' && t.status !== 'pending' && t.status !== 'blocked') return { err: '任务状态 ' + t.status + ' 不在可取消范围(仅草稿/待办/阻塞可取消)' }
|
|
435
|
+
return null
|
|
436
|
+
}
|
|
437
|
+
async function doCancel(sid, actor, taskId, reason) {
|
|
438
|
+
return mutateLocked(sid, function (d) {
|
|
439
|
+
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
440
|
+
if (!t) return { ok: false, error: 'not found' }
|
|
441
|
+
var g = cancelGate(t)
|
|
442
|
+
if (g) {
|
|
443
|
+
if (g.mode === 'already') return { ok: true, task: t, alreadyCancelled: true }
|
|
444
|
+
return { ok: false, error: g.err }
|
|
445
|
+
}
|
|
446
|
+
var note = (reason && String(reason).trim()) ? ('取消: ' + String(reason).trim().slice(0, 200)) : '取消'
|
|
447
|
+
return cancelApply(d, t, actor, note)
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
handle('cancel-task', async function (args) { return doCancel(rpcSessionId(args), getActorId(), args.taskId, args.reason) })
|
|
451
|
+
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; delete t.cancelledAt; 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 })
|
|
416
452
|
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) } }) })
|
|
417
453
|
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) } }) })
|
|
418
454
|
// 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
|
|
@@ -559,8 +595,6 @@ export function createRpc(ctx, state, deps) {
|
|
|
559
595
|
// 清理:一次性 run 走 dispose(既有语义逐字不变);continuable 的 rec 没有 run(持久子会话),
|
|
560
596
|
// 卡2 起改走 **interrupt 留存**(task-muw5h2ps):只打断当前 turn、不销毁子会话——否则「手动终止」
|
|
561
597
|
// 之后旧 Worker 仍在改同一棵树,与新派发的 Worker 并行写盘(终止按钮反而制造双写)。
|
|
562
|
-
// 手动终止**刻意不留续跑资格**:run 结局停在 running(不 closeRunHistory)→ 重派走 fresh spawn,
|
|
563
|
-
// 不把用户刚终止的子会话又唤醒(终止语义 = 重新排队,不是续跑)。
|
|
564
598
|
// interrupt 失败(会话已死 / 无 root 可授权)静默跳过:任务照常回 pending 重排,不卡死。
|
|
565
599
|
try { if (rec.run) await rec.run.dispose() } catch (_) {}
|
|
566
600
|
if (rec.continuable) {
|
|
@@ -572,16 +606,26 @@ export function createRpc(ctx, state, deps) {
|
|
|
572
606
|
// 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
|
|
573
607
|
// 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
|
|
574
608
|
await accumulateRunUsage(sid, rec)
|
|
609
|
+
// 关 run 结局 outcome:'terminated'(与状态无关,task-mv1pqy27 死结窗口根修):
|
|
610
|
+
// terminate 命中该卡的 run 就关账,否则卡面 runs 留档停在 running 且无 endedAt,
|
|
611
|
+
// activeRunOf 的卡面兜底会把它误判为活跃 → archive 永久拒(~50ms 结算窗口)。
|
|
612
|
+
// terminated 不满足 resumeTarget 的续跑判据(只认 timeout/error|incomplete)→ 仍不留续跑资格。
|
|
613
|
+
try { await closeRunHistory(sid, taskId, String(rec.id), 'terminated') } catch (_) {}
|
|
575
614
|
}
|
|
576
615
|
await mutateLocked(sid, function (d) {
|
|
577
616
|
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
578
617
|
if (!t) return null
|
|
579
618
|
delete t.stuckSince
|
|
619
|
+
// 认领位清理与状态无关(task-mv1pqy27 死结窗口根修):凡 terminate 命中该卡的 run(rec 在内存表),
|
|
620
|
+
// 按 rec.role 清对应认领位——覆盖 resolved/archived/cancelled 态(卡已落定但 run 尚在结算窗口)的
|
|
621
|
+
// 死结:否则 verifierRun/claimedBy 仍指向已终止 run 的 running 留档 → activeRunOf 卡面兜底误判活跃 → archive 永久拒。
|
|
622
|
+
if (rec) {
|
|
623
|
+
if (rec.role === 'verifier') { t.verifierRun = null; delete t.verifierRunAt }
|
|
624
|
+
else { t.claimedBy = null; t.claimedAt = null }
|
|
625
|
+
}
|
|
580
626
|
if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', actor, '手动终止,任务重新排队') }
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
// activeRunOf 的卡面兜底(liveEntry 按 verifierRun 匹配)会把已死的 run 误判为活跃 → archive 门禁
|
|
584
|
-
// 永久拒绝,而报错文案推荐的补救「先 terminate」再跑一次也是空转(内存表已摘、认领位不清)= 死结。
|
|
627
|
+
// 无活跃 rec 的 stale 卡(重启/已摘除)也要按状态兜底清认领位:in-progress 清 claimedBy 同口径,
|
|
628
|
+
// 否则卡面 verifierRun 仍指向已死 run 的 running 留档 → archive 永久拒(rec 不存在时 rec.role 无从判定)。
|
|
585
629
|
else if (t.status === 'verifying') { t.verifierRun = null; delete t.verifierRunAt; ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
|
|
586
630
|
return t
|
|
587
631
|
})
|
package/lib/store.mjs
CHANGED
|
@@ -9,6 +9,11 @@ import path from 'node:path'
|
|
|
9
9
|
import fsNode from 'node:fs'
|
|
10
10
|
const { seed, normalizeBoard, vt, boardHome } = core
|
|
11
11
|
|
|
12
|
+
// 进程内 tmp 序号(配合 pid + 时间戳拼出唯一 tmp 名):并发 wt() 各自写各自的 tmp,
|
|
13
|
+
// 消灭「两个 chunked 写在同一 p+'.tmp' 上交错 → rename 把拼接怪装上正位」的撕裂根因。
|
|
14
|
+
// 模块级(而非 per-store)是为了同一进程内多个 store 实例(测试/热重载)也不会撞名。
|
|
15
|
+
var tmpSeq = 0
|
|
16
|
+
|
|
12
17
|
// 腐坏看板自动抢救(P0 根修 task-mv0bl9vg ③):从撕裂/夹残片的 JSON 原文里截取**第一个完整 JSON 值**。
|
|
13
18
|
// 手法 = JSON.parse 报错 position 截断法(本次事故人工恢复就是这招):整串 parse 失败时,
|
|
14
19
|
// V8 报错信息含 "position N"(实测 node v24:'{"a":1}{"b":2}' 报 position 18,即第二个值起点),
|
|
@@ -108,6 +113,11 @@ export function createStore(ctx, state, deps) {
|
|
|
108
113
|
}
|
|
109
114
|
return false
|
|
110
115
|
}
|
|
116
|
+
// 锁感知写回(P1 根修 task-mv1e17x9):locked=true(调用方已在 mutateLocked 锁内)→ 直接执行;
|
|
117
|
+
// locked=false(rt 被锁外直读触发,如 poolCycle/rpc/notify)→ 经 withLock 串行。
|
|
118
|
+
// 为什么不能一律 withLock:rt 在 mutateLocked 锁内被调用时,再套 withLock 会链到当前**未落定**的锁上
|
|
119
|
+
// (withLock 是简单 promise 链,不可重入)→ 死锁。故用 locked 标志位区分,锁内直跑、锁外补锁。
|
|
120
|
+
function lockAware(sid, locked, fn) { return locked ? fn() : withLock(sid, fn) }
|
|
111
121
|
// 接管落盘:两条路径,都保证「接管后看板目录里只剩新 sid 那一个文件」(不残留 .adopt-/.tmp 之类残件——
|
|
112
122
|
// 残留一份含全部历史卡的旧板 = 用户磁盘上多一份完整看板,下次继承还会把它算成候选)。
|
|
113
123
|
// ① 主路径 rename(p → np):原子、复用 wt() 同款 EPERM/EBUSY 退避重试。注意 rename 只搬内容,
|
|
@@ -116,32 +126,37 @@ export function createStore(ctx, state, deps) {
|
|
|
116
126
|
// 读到 ownerSession 不匹配会退回空板,用户视角仍是「看板被清空」)。
|
|
117
127
|
// ② 兜底 rename 走不通(跨设备/顽固占用)→ wt() 写新板 + unlinkRetry 删旧板。先写后删:
|
|
118
128
|
// 任何时刻磁盘上至少有一份完整看板。
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
// 整段经 lockAware 串行(继承写回归一进锁):locked=false 时并发继承/写盘不会互相交错。
|
|
130
|
+
async function adoptBoard(sid, cand, cwd, locked) {
|
|
131
|
+
return lockAware(sid, locked, async function () {
|
|
132
|
+
var p = boardPath(cand.sid), np = boardPath(sid)
|
|
133
|
+
// 目标名已被占用 → 绝不接管(防覆盖:本会话看板若只是「这一刻读不到」,覆盖等于把本板换成孤儿板)
|
|
134
|
+
if (cand.sid === sid || fsNode.existsSync(np)) return false
|
|
135
|
+
var c = null
|
|
136
|
+
try { c = JSON.parse(await fsNode.promises.readFile(p, 'utf8')) } catch (e) { console.error('[task-board] 继承失败(读旧板):', String(e)); return false }
|
|
137
|
+
if (!vt(c)) return false
|
|
138
|
+
c.ownerSession = sid
|
|
139
|
+
if (typeof cwd === 'string' && cwd) c.ownerCwd = cwd
|
|
140
|
+
if (await renameRetry(p, np)) { // ① 主路径:先改名,再把 ownerSession 改写写回
|
|
141
|
+
try {
|
|
142
|
+
await wt(sid, c)
|
|
143
|
+
console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots)')
|
|
144
|
+
return true
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.error('[task-board] 继承失败(改写 ownerSession):', String(e))
|
|
147
|
+
await renameRetry(np, p) // 回滚到旧名:下次 rt 还能再试,且不留半成品
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
137
150
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
try { await wt(sid, c) } catch (e) { console.error('[task-board] 继承失败(写新板):', String(e)); return false }
|
|
152
|
+
await unlinkRetry(p)
|
|
153
|
+
console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots;rename 不通,走写新+删旧)')
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
143
156
|
}
|
|
144
|
-
|
|
157
|
+
// locked(内部参数):true=调用方已在 mutateLocked 锁内(继承/抢救写回不得再套 withLock,防死锁);
|
|
158
|
+
// 缺省 false=锁外直读(写回经 lockAware 补锁串行)。对外只暴露 rt(sid),锁内调用由 mutateLocked 传 true。
|
|
159
|
+
async function rt(sid, locked) {
|
|
145
160
|
var r = null
|
|
146
161
|
try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (e) {
|
|
147
162
|
// 只有「文件确实不存在(ENOENT)」才触发继承:EACCES/EPERM/被占 等说明本会话看板很可能存在、
|
|
@@ -151,7 +166,7 @@ export function createStore(ctx, state, deps) {
|
|
|
151
166
|
try {
|
|
152
167
|
var cwd0 = sessionCwd(sid)
|
|
153
168
|
var cand = findAdoptableBoardFile(sid, cwd0)
|
|
154
|
-
if (cand && await adoptBoard(sid, cand, cwd0)) r = await fsNode.promises.readFile(boardPath(sid), 'utf8')
|
|
169
|
+
if (cand && await adoptBoard(sid, cand, cwd0, locked)) r = await fsNode.promises.readFile(boardPath(sid), 'utf8')
|
|
155
170
|
} catch (e2) { console.error('[task-board] 继承流程异常(降级为空板):', String(e2)) }
|
|
156
171
|
if (r === null) return seed(sid, sessionCwd(sid))
|
|
157
172
|
}
|
|
@@ -167,45 +182,49 @@ export function createStore(ctx, state, deps) {
|
|
|
167
182
|
return seed(sid, sessionCwd(sid))
|
|
168
183
|
} catch (_) {
|
|
169
184
|
// JSON 截断/损坏(如强杀打断写盘、中部夹杂另一版本残片)→ 抢救 + 隔离 + 可见化(P0 根修)
|
|
170
|
-
return recoverCorrupt(sid, r)
|
|
185
|
+
return recoverCorrupt(sid, r, locked)
|
|
171
186
|
}
|
|
172
187
|
}
|
|
173
188
|
// ===== 腐坏看板:自动 salvage + 隔离可见化(task-mv0bl9vg ②③)=====
|
|
174
189
|
// 三件套:① 隔离前先 trySalvage 截断抢救(成功则以抢救出的完整 JSON 为底继续);
|
|
175
190
|
// ② 原始坏文件一律 .corrupt-<ts> 留档(抢救成功=复制留档+写回覆盖,抢救失败=改名隔离;撕裂尾部都不丢);
|
|
176
191
|
// ③ 追加 healthHints err 级 hint(写 poolHealth[sid].corruptNote)+ notify 通知 owner——不再静默。
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
salvaged
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
192
|
+
// locked 语义同 adoptBoard:锁外直读触发 → 经 withLock 串行(双生 .corrupt 对已实证读路径并发触发写真实存在);
|
|
193
|
+
// mutateLocked 锁内触发 → 直跑。整段(含 copyFile 留档 + wt 写回 / rename 隔离)归一进锁,写回不再 bypass withLock。
|
|
194
|
+
async function recoverCorrupt(sid, raw, locked) {
|
|
195
|
+
return lockAware(sid, locked, async function () {
|
|
196
|
+
var p = boardPath(sid)
|
|
197
|
+
var ts = Date.now()
|
|
198
|
+
var corruptPath = p + '.corrupt-' + ts
|
|
199
|
+
var salv = trySalvage(raw)
|
|
200
|
+
var salvaged = null, kept = 0
|
|
201
|
+
if (salv.ok && vt(salv.board)) {
|
|
202
|
+
salvaged = normalizeBoard(salv.board)
|
|
203
|
+
salvaged.ownerSession = sid // 抢救出的板归属本 sid(与 adoptBoard 同口径)
|
|
204
|
+
var cw = sessionCwd(sid)
|
|
205
|
+
if (!salvaged.ownerCwd && cw) salvaged.ownerCwd = cw
|
|
206
|
+
kept = (salvaged.tasks || []).length
|
|
207
|
+
}
|
|
208
|
+
if (salvaged) {
|
|
209
|
+
// 抢救成功:原始坏内容**复制**留档 .corrupt-<ts>(不动主板,撕裂尾部不丢),
|
|
210
|
+
// 再以截断后的完整 JSON 为底原子写回主板继续用;写回失败则下次读盘再抢救(幂等,不留空板空档)。
|
|
211
|
+
try { await fsNode.promises.copyFile(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板复制留档失败:', String(e)) }
|
|
212
|
+
try {
|
|
213
|
+
await wt(sid, salvaged)
|
|
214
|
+
teamModeCache[sid] = !!salvaged.teamMode
|
|
215
|
+
feedbackCache[sid] = salvaged.feedbackEnabled !== false
|
|
216
|
+
epicSplitCache[sid] = salvaged.epicSplit !== false
|
|
217
|
+
} catch (e) { console.error('[task-board] 抢救写回失败(原始文件已留档 ' + corruptPath + '):', String(e)) }
|
|
218
|
+
recordCorruptNote(sid, { at: ts, salvage: true, kept: kept, file: path.basename(corruptPath) })
|
|
219
|
+
console.error('[task-board] board file corrupt, salvaged (kept ' + kept + ' tasks), original quarantined: ' + corruptPath)
|
|
220
|
+
return salvaged
|
|
221
|
+
}
|
|
222
|
+
// 抢救失败:坏文件改名 .corrupt-<ts> 隔离(防反复 poison)+ 空板重启(禁止回写)+ 可见 hint + notify
|
|
223
|
+
try { await fsNode.promises.rename(p, corruptPath) } catch (e) { console.error('[task-board] 腐坏看板隔离改名失败(下次读盘重试):', String(e)) }
|
|
224
|
+
recordCorruptNote(sid, { at: ts, salvage: false, kept: 0, file: path.basename(corruptPath) })
|
|
225
|
+
console.error('[task-board] board file corrupt, quarantined: ' + corruptPath)
|
|
226
|
+
return seedNoPersist(sid)
|
|
227
|
+
})
|
|
209
228
|
}
|
|
210
229
|
// 腐坏/抢救 hint 落 poolHealth(get-tasks 现算透出 err 级 hint)+ notify 通知 owner
|
|
211
230
|
function recordCorruptNote(sid, note) {
|
|
@@ -237,7 +256,11 @@ export function createStore(ctx, state, deps) {
|
|
|
237
256
|
finally { await fh.close().catch(function () {}) }
|
|
238
257
|
}
|
|
239
258
|
async function wt(sid, d) {
|
|
240
|
-
var c = JSON.stringify(d); var p = boardPath(sid)
|
|
259
|
+
var c = JSON.stringify(d); var p = boardPath(sid)
|
|
260
|
+
// tmp 唯一名(P1 根修 task-mv1e17x9):pid + 时间戳 + 进程内自增序号——并发 wt() 各自写各自的 tmp,
|
|
261
|
+
// rename 装上的永远是**某一份**完整 JSON(旧实现共享 p+'.tmp':两个并发 chunked 写在同一 tmp 交错,
|
|
262
|
+
// rename 把拼接怪原子装上正位——原子 rename 防写一半,防不住 tmp 本身是拼的)。
|
|
263
|
+
var tmp = p + '.' + process.pid + '.' + Date.now() + '-' + (++tmpSeq) + '.tmp'
|
|
241
264
|
for (var attempt = 0; attempt < 6; attempt++) {
|
|
242
265
|
try { await writeTmpFsync(tmp, c); await fsNode.promises.rename(tmp, p); return }
|
|
243
266
|
catch (e) {
|
|
@@ -246,14 +269,15 @@ export function createStore(ctx, state, deps) {
|
|
|
246
269
|
await new Promise(function (r) { setTimeout(r, 60 * (attempt + 1)) })
|
|
247
270
|
continue
|
|
248
271
|
}
|
|
272
|
+
await unlinkRetry(tmp, 2) // 非瞬时错误:清掉自己的 tmp 残件再抛(不堆积)
|
|
249
273
|
console.error('[task-board] write:', String(e)); throw e
|
|
250
274
|
}
|
|
251
275
|
}
|
|
252
276
|
// 退避重试耗尽(顽固瞬时占用)→ 降级「先 unlink 目标再 rename」并记录;最后一步失败则抛(不吞)
|
|
253
277
|
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 }
|
|
278
|
+
try { await writeTmpFsync(tmp, c) } catch (e) { await unlinkRetry(tmp, 2); console.error('[task-board] write:', String(e)); throw e }
|
|
255
279
|
await unlinkRetry(p, 6)
|
|
256
|
-
await fsNode.promises.rename(tmp, p)
|
|
280
|
+
try { await fsNode.promises.rename(tmp, p) } catch (e) { await unlinkRetry(tmp, 2); console.error('[task-board] write:', String(e)); throw e }
|
|
257
281
|
}
|
|
258
282
|
// 每会话一条 promise 链,串行化所有 读-改-写,消除并发写竞争
|
|
259
283
|
function withLock(sid, fn) { var prev = fileLocks[sid] || Promise.resolve(); var p = prev.then(function () { return fn() }); fileLocks[sid] = p.catch(function () {}); return p }
|
|
@@ -285,7 +309,7 @@ export function createStore(ctx, state, deps) {
|
|
|
285
309
|
}
|
|
286
310
|
if (tm) tm.timeout(50).then(go, go); else Promise.resolve().then(go)
|
|
287
311
|
}
|
|
288
|
-
function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); if (d && d.__noPersist) { console.error('[task-board] 看板暂时不可读,拒绝在空板上覆写(防瞬时读失败清板): ' + sid); return { ok: false, error: '看板暂时不可读,请重试' } } var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
|
|
312
|
+
function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid, true); if (d && d.__noPersist) { console.error('[task-board] 看板暂时不可读,拒绝在空板上覆写(防瞬时读失败清板): ' + sid); return { ok: false, error: '看板暂时不可读,请重试' } } var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
|
|
289
313
|
|
|
290
314
|
return { rt: rt, wt: wt, kickCycle: kickCycle, mutateLocked: mutateLocked }
|
|
291
315
|
}
|
package/package.json
CHANGED