dsh-agent-board 1.7.1 → 1.7.3
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 -15
- package/index.mjs +15 -7
- package/lib/client/board-list.js +37 -5
- package/lib/client/dashboard.js +164 -25
- package/lib/client/kernel.js +84 -13
- package/lib/client/task-detail.js +142 -11
- package/lib/client.js +427 -54
- package/lib/core.mjs +355 -35
- package/lib/dispatch.mjs +1032 -196
- package/lib/health.mjs +43 -11
- package/lib/notify.mjs +42 -12
- package/lib/policy.mjs +17 -1
- package/lib/rpc.mjs +159 -25
- package/lib/session.mjs +7 -2
- package/lib/store.mjs +28 -2
- package/lib/usage.mjs +238 -17
- package/package.json +1 -1
- package/test/core.test.mjs +3469 -88
- package/test/health.test.mjs +86 -4
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 } 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, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash } = core
|
|
16
|
+
const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection } = core
|
|
17
17
|
|
|
18
18
|
function defineTool(options) {
|
|
19
19
|
var userExecute = options.execute
|
|
@@ -42,6 +42,16 @@ export function createRpc(ctx, state, deps) {
|
|
|
42
42
|
var rt = deps.rt, mutateLocked = deps.mutateLocked
|
|
43
43
|
var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
|
|
44
44
|
var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
|
|
45
|
+
// 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
|
|
46
|
+
// Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
|
|
47
|
+
// 行为与本次改造前逐字一致(那时本来就没人做这件事)。
|
|
48
|
+
var reportRunSettled = (typeof deps.reportRunSettled === 'function') ? deps.reportRunSettled : null
|
|
49
|
+
// 上报即收尾的统一调用点:失败只记日志、绝不影响工具返回值(收尾是附加动作,工具通道的结果已落定)。
|
|
50
|
+
function settleReportedRun(taskId) {
|
|
51
|
+
if (!reportRunSettled) return Promise.resolve(false)
|
|
52
|
+
return Promise.resolve(reportRunSettled(toolSessionId(), taskId, { outcome: 'completed' }))
|
|
53
|
+
.catch(function (e) { console.error('[task-board] 上报通道 run 收尾失败 (task ' + taskId + '):', String(e)); return false })
|
|
54
|
+
}
|
|
45
55
|
var pushSysNote = deps.pushSysNote, sessionCwd = deps.sessionCwd // 调研门禁③:epic 发布预检的投递通道与路径解析根
|
|
46
56
|
// 僵尸 epic 出清(反馈 n-mutma3mmwceq):archive 门禁的活性判定由 index.mjs 接线注入
|
|
47
57
|
// (runsFor(sid)[taskId] 存在且 !settled 即活跃)。未注入时按「有活跃 run」兜底——
|
|
@@ -62,13 +72,15 @@ export function createRpc(ctx, state, deps) {
|
|
|
62
72
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
63
73
|
var teamModeCache = state.teamModeCache
|
|
64
74
|
var feedbackCache = state.feedbackCache
|
|
75
|
+
var epicSplitCache = state.epicSplitCache
|
|
65
76
|
// RPC handlers 表必须在最前面初始化:后面的 handle(...) 调用依赖它(var 只提升声明不提升赋值)
|
|
66
77
|
var handlers = state.handlers
|
|
67
78
|
function handle(method, fn) { handlers[method] = fn }
|
|
68
79
|
|
|
69
80
|
// ===== 调研门禁③配套:epic 发布预检 =====
|
|
70
|
-
//
|
|
81
|
+
// 路径存在性判定:相对路径按「该会话工作区」解析(不靠进程 cwd);
|
|
71
82
|
// existsSync 同步轻量,只在 publish 时跑一遍(每子任务 ≤20 条路径,不读全文)。
|
|
83
|
+
// 注意:这是**唯一**还按会话工作区解析路径的地方(派发侧预研清单已不读盘,见 dispatch.readContextPack)。
|
|
72
84
|
function existsInSession(sid) {
|
|
73
85
|
var cwd = typeof sessionCwd === 'function' ? sessionCwd(sid) : ''
|
|
74
86
|
return function (p) { try { return fsNode.existsSync(path.isAbsolute(p) ? p : (cwd ? path.join(cwd, p) : p)) } catch (_) { return false } }
|
|
@@ -80,54 +92,90 @@ export function createRpc(ctx, state, deps) {
|
|
|
80
92
|
if (typeof pushSysNote === 'function') pushSysNote(sid, epicPrecheckNote(pre), taskId)
|
|
81
93
|
}
|
|
82
94
|
|
|
95
|
+
// ===== hooks(史诗 hooks=agent run)只许主窗口设置 =====
|
|
96
|
+
// 工具任务链早已有「看板管理工具仅主窗口可用」门禁(resolveRoot(actor) === actor),这里单独再拦一道:
|
|
97
|
+
// hooks 是高权限入口(会 spawn 真的 agent run 去动代码/跑收口),RPC 通道同样只放行主窗口。
|
|
98
|
+
var HOOKS_MAIN_ONLY = 'hooks 仅主窗口可设(子代理无 hooks 权限)'
|
|
99
|
+
function hooksDenied(actor) { return resolveRoot(actor) !== actor }
|
|
100
|
+
// hooks 浅校验 + 与既有值浅合并;返回 { hooks } 或 { error }(口径唯一在 core.normalizeHooks)。
|
|
101
|
+
// 传 hooks=null 表示整体清除;传 { pre: null } 表示只撤 pre 点位;未提交的点位保留原值。
|
|
102
|
+
function applyHooks(target, incoming) {
|
|
103
|
+
var n = normalizeHooks(incoming)
|
|
104
|
+
if (n.error) return n
|
|
105
|
+
var merged = mergeHooks(target.hooks, n.hooks)
|
|
106
|
+
if (Object.keys(merged).length) target.hooks = merged
|
|
107
|
+
else delete target.hooks
|
|
108
|
+
return { ok: true }
|
|
109
|
+
}
|
|
110
|
+
|
|
83
111
|
// ===== Tools =====
|
|
84
112
|
ctx.tools.register(defineTool({ name: 'task_list', description: '列出当前会话任务。', parameters: { type: 'object', properties: { status: { type: 'string', enum: ['pending', 'in-progress', 'verifying', 'resolved', 'blocked', 'cancelled'] }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tag: { type: 'string' }, parentId: { type: 'string' }, includeArchived: { type: 'boolean' }, limit: { type: 'number' } }, required: [] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var d = await rt(sid); var a = d.tasks; var ts = a; if (!args.includeArchived) ts = ts.filter(function (x) { return x.status !== 'archived' }); if (args.status) ts = ts.filter(function (x) { return x.status === args.status }); if (args.priority) ts = ts.filter(function (x) { return x.priority === args.priority }); if (args.tag) ts = ts.filter(function (x) { return (x.tags || []).indexOf(args.tag) >= 0 }); if (args.parentId === 'null') ts = ts.filter(function (x) { return !isb(x) }); else if (args.parentId) ts = ts.filter(function (x) { return x.parentId === args.parentId }); var po = PRIO_RANK; ts.sort(function (a, b) { var dd = (po[b.priority] || 0) - (po[a.priority] || 0); return dd !== 0 ? dd : (a.createdAt || '').localeCompare(b.createdAt || '') }); var lim = Math.min(args.limit || 20, 100); var res = ts.slice(0, lim).map(function (x) { var e = Object.assign({}, x); if (isb(x)) { var p = gpt(x, a); if (p) e.parentSummary = { id: p.id, title: p.title, status: p.status } }; var ch = gsb(x.id, a); if (ch.length) { e.subtaskCount = ch.length; e.subtaskResolved = ch.filter(function (y) { return y.status === 'resolved' }).length }; return e }); var out = { tasks: res, total: ts.length, session: sid, actor: getActorId(), boardMode: d.boardMode || 'auto', teamMode: !!d.teamMode, poolStatus: d.poolStatus }; if (d.teamMode) out.teamHint = 'Team 模式已开启:实质性改动请优先 task_create 提交看板由池执行;调研/读取/讨论可直接进行;Worker 歧义会上报等你裁决。'; return out } }))
|
|
85
113
|
ctx.tools.register(defineTool({ name: 'task_context', description: '获取任务完整上下文。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, expandFiles: { type: 'boolean' }, includeParent: { type: 'boolean' }, includeSubtasks: { type: 'boolean' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var d = await rt(sid); var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found: ' + args.taskId }; return { ok: true, context: { task: t, inheritedContext: t.context || {} } } } }))
|
|
86
|
-
ctx.tools.register(defineTool({ name: 'task_preview_context', description: '派发前上下文预览:预演 task_create 的 contextFiles/contextNotes
|
|
114
|
+
ctx.tools.register(defineTool({ name: 'task_preview_context', description: '派发前上下文预览:预演 task_create 的 contextFiles/contextNotes 将注入给子代理的瘦身清单形态(调研笔记全文 + 文件清单行「路径:L行号 — 一句用途」;文件内容本体不进 prompt,由子代理按需 read 自取),用于确认材料是否足够。不创建任务。返回 ok=false/empty=true 说明无可注入内容。', parameters: { type: 'object', properties: { contextFiles: { type: 'array', items: { type: 'string' }, description: '预演的文件路径列表;支持「路径:L1-L2」锚点与可选「 — 一句用途」写法' }, contextNotes: { type: 'string', description: '预演的调研笔记' } }, required: [] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; toolSessionId(); /* 副作用:注册已知会话(心跳集合);清单组装不依赖会话 id */ var files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []; var notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''; if (!files.length && !notes.trim()) return { ok: false, error: 'contextFiles/contextNotes 至少提供一个' }; try { var pack = readContextPack({ context: { files: files, notes: notes } }); return { ok: true, empty: !pack, pack: pack } } catch (e) { return { ok: false, error: String(e).slice(0, 200) } } } }))
|
|
87
115
|
ctx.tools.register(defineTool({ name: 'task_claim', description: '领取待办任务→in-progress。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, reason: { 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 err = claimCheck(d, t, actor); if (err) return { ok: false, error: err }; claimApply(d, t, actor, args.reason || 'claimed'); return { ok: true, task: t, context: { task: t, inheritedContext: t.context || {} } } }) } }))
|
|
88
116
|
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 !== 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) }) } }))
|
|
89
|
-
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') __vr.hint = REJECT_REDISPATCH_HINT
|
|
117
|
+
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 }) } }))
|
|
90
118
|
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 a = d.tasks; var t = a.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 }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'archived'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) } }))
|
|
91
|
-
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: '
|
|
92
|
-
ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles
|
|
119
|
+
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 } }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
|
|
120
|
+
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); /* 调研门禁 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 }) } }))
|
|
93
121
|
|
|
94
122
|
// ===== RPC =====
|
|
95
123
|
// get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
|
|
96
124
|
// 客户端 3s 轮询不再触发池计算/写盘(之前每轮询一次就 poolCycle+写盘一次,切会话时多会话轮询挤在文件锁上)
|
|
125
|
+
// isRoot 现算不落盘:生成开始/结束瞬间 agents 树重建,roots() 存在瞬态窗口返回不含本 sid(假 false)。
|
|
126
|
+
// host 不改行为(保持"现算真相"),防抖在客户端:kernel.js applyIsRoot——曾确认 true 的会话需连续 3 次
|
|
127
|
+
// false(3s 心跳≈9s)才收抽屉;从未 true 的子代理会话仍即时收起(反馈 n-muuerxv9ijxs / task-muupr8ld)。
|
|
97
128
|
handle('get-tasks', async function (args) { var sid = rpcSessionId(args); var d = await rt(sid); d.sessionId = sid; var __ag = ctx.agents; d.isRoot = true; if (__ag) { var __roots = __ag.roots(); var __rids = []; for (var __i = 0; __i < __roots.length; __i++) __rids.push(String(__roots[__i].id)); d.isRoot = __rids.indexOf(sid) >= 0 }
|
|
98
129
|
// poolStatus 防幽灵:只保留指向当前活跃任务的条目(重启后内存 runs 清空,文件快照可能残留)
|
|
99
130
|
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] }) }
|
|
100
131
|
// 学习飞轮 v1:把 feedbackEnabled 显式放进返回体(normalizeBoard 已按老看板补默认 true),
|
|
101
132
|
// 客户端据此做能力检测——开关关闭时渲染层不生成候选、不显示「沉淀」按钮。
|
|
102
133
|
d.feedbackEnabled = cfg(d).feedbackEnabled
|
|
134
|
+
// 回执开关(设置区「通知」小节)同法显式透出:老 host 不返回时客户端按「缺字段=开」兜底,
|
|
135
|
+
// 这里给出确定布尔值,UI 勾选态不依赖客户端各自的兜底写法。
|
|
136
|
+
d.notifyDispatch = cfg(d).notifyDispatch
|
|
137
|
+
d.notifyDone = cfg(d).notifyDone
|
|
138
|
+
// Worker 可续跑开关(workerContinuable,缺省 true):与上面两键同款——读路径用 cfg 归一后的值,
|
|
139
|
+
// 老看板缺字段也返回 true(新行为即默认),客户端不需要自己猜缺省。消费点只在派发引擎。
|
|
140
|
+
d.workerContinuable = cfg(d).workerContinuable
|
|
141
|
+
// 史诗拆分总开关(设置区「功能」小节)同法显式透出确定布尔值:老 host 不返回时客户端按「缺字段=开」兜底,
|
|
142
|
+
// UI 勾选态不依赖各自兜底写法。
|
|
143
|
+
d.epicSplit = cfg(d).epicSplit
|
|
103
144
|
// 工作模式派生字段:UI 只读这一个字段决定三档选中态(不落盘,写入仍走 boardMode/teamMode)
|
|
104
145
|
d.workMode = deriveWorkMode(d)
|
|
105
146
|
// ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
|
|
106
|
-
|
|
147
|
+
// 统计范围(args.range 可选 { from, to } 本地日):仪表盘的「统计范围」筛选对 Token 区的
|
|
148
|
+
// 模型分布 / Top8 / 累计生效——run 级数据只在 host,所以过滤必须在 host 做(客户端只拿聚合,没法自己裁)。
|
|
149
|
+
// 缺省/空范围 = 现状逐字不变(aggregateUsageSummary 内部判定,空串不算范围)。
|
|
150
|
+
d.usageSummary = aggregateUsageSummary(d.tasks, args && args.range)
|
|
107
151
|
// 架构自省 L1:healthHints 现算(纯函数零存储零 IO,近 50 卡窗口),客户端「架构健康」区超阈值才显示
|
|
108
152
|
d.healthHints = computeHealthHints(d.tasks)
|
|
109
|
-
// 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, resolved, active, activeTitle } },
|
|
110
|
-
//
|
|
153
|
+
// 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, settled, resolved, active, activeTitle } },
|
|
154
|
+
// 父卡列位置/进度展示的数据源;total 含已归档子任务,settled=resolved|cancelled|archived(resolved 为
|
|
155
|
+
// 兼容别名同值),因此归档子卡不会让进度分母缩水(task-muupgfot);只有无任何子任务的父卡才不出键
|
|
111
156
|
d.childStats = aggregateChildStats(d.tasks)
|
|
112
157
|
// 渲染变更检测(反馈 n-mut9rzs2mkhg):tasks 关键字段的稳定 hash(纯函数,口径见 core.tasksHash),
|
|
113
158
|
// 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify——传输仍全量,省的是渲染
|
|
114
159
|
d.tasksHash = tasksHash(d.tasks)
|
|
115
160
|
return d })
|
|
116
|
-
// 派发前上下文预览:主 agent 用它确认"
|
|
161
|
+
// 派发前上下文预览:主 agent 用它确认"我将注入给子代理的瘦身清单"是否足够(不发任务、不落盘)
|
|
117
162
|
handle('preview-context', async function (args) {
|
|
163
|
+
rpcSessionId(args) // 副作用保留:注册已知会话(15s 心跳轮询集合);清单组装本身已不依赖会话工作区
|
|
118
164
|
var files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []
|
|
119
165
|
var notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''
|
|
120
166
|
if (!files.length && !notes.trim()) return { ok: false, error: 'contextFiles/contextNotes 至少提供一个' }
|
|
121
167
|
var pack = ''
|
|
122
|
-
try { pack =
|
|
168
|
+
try { pack = readContextPack({ context: { files: files, notes: notes } }) } catch (e) { return { ok: false, error: '清单组装失败: ' + String(e).slice(0, 120) } }
|
|
123
169
|
return { ok: true, filesCount: files.length, notesLen: notes.length, pack: pack, empty: !pack }
|
|
124
170
|
})
|
|
125
171
|
// 活动心跳:读子代理会话日志的最后一帧,提取最近的动作摘要(卡片/详情页展示"现在跑到哪了")
|
|
126
172
|
handle('agent-activity', async function (args) {
|
|
127
173
|
var sid = rpcSessionId(args)
|
|
128
174
|
var rec = runsFor(sid)[args.taskId]
|
|
129
|
-
if (!rec
|
|
130
|
-
|
|
175
|
+
if (!rec) return { ok: true, activity: null, reason: 'no active run' }
|
|
176
|
+
// 会话 id 统一取 rec.id:continuable Worker 的 rec 没有 run(rec.run=null,只有 childId),
|
|
177
|
+
// 一次性 run 的 rec.id === rec.run.id——两条路径在这里同构。
|
|
178
|
+
var child = String(rec.id)
|
|
131
179
|
try {
|
|
132
180
|
// 子会话日志定位:直接扫描 ~/.dsh/sessions/*/<child>/session.vN.jsonl.zstd
|
|
133
181
|
// (不再从看板路径反推 workspace——那依赖进程 cwd 凑巧等于工作区,曾是隐性 bug)
|
|
@@ -190,7 +238,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
190
238
|
})
|
|
191
239
|
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 } }) })
|
|
192
240
|
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) }) })
|
|
193
|
-
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') { pushRejectLesson(d, t, args.comment); r.hint = REJECT_REDISPATCH_HINT } return r }) })
|
|
241
|
+
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 }) })
|
|
194
242
|
handle('archive-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var a = d.tasks; var t = a.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 }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'manual archive'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) })
|
|
195
243
|
// ===== 任务删除通道(真删,无 undo)=====
|
|
196
244
|
// 背景:archive-task 只收 resolved/cancelled,草稿/误建卡片此前没有任何下线通道(只能永远挂着)。
|
|
@@ -228,7 +276,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
228
276
|
return { ok: true, deleted: t.id }
|
|
229
277
|
})
|
|
230
278
|
})
|
|
231
|
-
handle('update-task', async function (args) { var sid = rpcSessionId(args); 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') }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
|
|
279
|
+
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 } }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
|
|
232
280
|
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) } }) })
|
|
233
281
|
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) } }) })
|
|
234
282
|
// 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
|
|
@@ -292,12 +340,68 @@ export function createRpc(ctx, state, deps) {
|
|
|
292
340
|
// 通道选择(v1.2.4):steer 优先——下一个 step 边界即消费;followup 要等整个 turn 结束,
|
|
293
341
|
// 长 turn 下干预形同失联(实测:Worker 单 turn 跑 10+ 分钟,「口径重写」类干预到位时活已按旧口径干完)。
|
|
294
342
|
// steer 不可用(老宿主无此方法)或抛错时回退 followup。
|
|
343
|
+
//
|
|
344
|
+
// ===== continuable 通道补齐(task-muwox2ii,发版门禁 steer 套件 4/8 红的根因)=====
|
|
345
|
+
// 病根:投递条件写的是 rec.run.localAgent——那是**一次性 run 的句柄**;continuable rec 没有 run
|
|
346
|
+
//(持久子会话,卡1 起改由宿主续跑管理器持有,见 spawnOneShot 的 rec 形态),于是整段投递被跳过:
|
|
347
|
+
// 干预没进任何会话、delivered 恒 false、channel 恒 '',Worker 全程收不到指令(steer e2e 的
|
|
348
|
+
// 「上报内容含干预标记」必红),而详情页仍记着「已干预」——人以为送达了。
|
|
349
|
+
// 修法:continuable rec 改走与卡2 续跑同一族通道,三态降级(能实时送达就绝不留成「事后注入」):
|
|
350
|
+
// ① 宿主 host-protocol 投递:Symbol.for('dsh.subagent.deliverPrompt') 是宿主为「适配器保留自有
|
|
351
|
+
// source 描述」留的符号键入口(dsh-subagent 里 Symbol.for 注册、实例上挂同名方法),
|
|
352
|
+
// delivery='steer' → 就近 step 边界消费;走它,子会话日志里记的仍是
|
|
353
|
+
// source.kind='plugin:dsh-agent-board' 的看板消息——与一次性路径 agent.steer(插件消息) 的存档
|
|
354
|
+
// 形态一致(e2e 的「干预被消费」断言口径因此不用改);
|
|
355
|
+
// ② 该符号不可用/抛错(老宿主、跨进程代理面)→ 回退公开 sendMessage(卡2 续跑同通道;宿主内部
|
|
356
|
+
// 也是按 delivery='steer' 投递,只是 source 会被记成 agent-message);
|
|
357
|
+
// ③ 仍然送不到(无 childId / root 不在线 / 会话已死)→ 回退既有记录注入(现状行为:干预文本进
|
|
358
|
+
// t.messages,下次派发随 prompt 注入),并在 history 里写明「未实时送达」,不让它冒充已送达。
|
|
359
|
+
// 一次性 rec 走的仍是原分支:agent.steer → agent.followup 逐字不变。
|
|
360
|
+
var DELIVER_PROMPT = (function () { try { return Symbol.for('dsh.subagent.deliverPrompt') } catch (_) { return null } })()
|
|
361
|
+
// 投递用的取消信号(与 dispatch.mjs 的 makeSignal 同形):只覆盖「入队前」的取消,构造不出来时退化为哑信号。
|
|
362
|
+
function makeSignal() { try { return new AbortController().signal } catch (_) { return { aborted: false, addEventListener: function () {}, removeEventListener: function () {} } } }
|
|
363
|
+
async function deliverContinuableIntervention(sid, taskId, rec, msg) {
|
|
364
|
+
var childId = String((rec && rec.childId) || '')
|
|
365
|
+
var subagents = ctx.subagents
|
|
366
|
+
var parent = rootForSession(sid)
|
|
367
|
+
// 判活口径(主窗口 2026-10-06 干预锐化):continuable rec 的「能不能安全投递」=
|
|
368
|
+
// childId 在 + **未被标记 settled**(立旗 = 结算通道正在认领 / 会话正在收尾,此刻投递要么打在
|
|
369
|
+
// 正在关门的会话上、要么静默丢失);任一不满足即不投,直接落「未实时送达」记录注入。
|
|
370
|
+
// 刻意不照抄 dispatch.mjs 的 recIsLive:那是「幽灵回收」的**卡面证据**口径(注释明写不认
|
|
371
|
+
// rec.settled——抢先摘表项会丢交付物),回答的是「这条表项还算不算在跑」;这里只回答「能不能投」。
|
|
372
|
+
if (!childId || rec.settled === true || !subagents || !parent) return { delivered: false }
|
|
373
|
+
// 干预消息形态与一次性路径**同一个构造**(makeMsg):文案/source 逐字一致,两条通道的存档可对齐比对。
|
|
374
|
+
var m = makeMsg('[高优先级干预] 来自主窗口/用户的指令:\n\n' + msg + '\n\n请优先响应此指令,然后继续当前任务。', 'notice', '高优干预: ' + taskId)
|
|
375
|
+
// 符号键读取本身也包 try:跨进程代理面对未知符号的取属性可能直接抛(取不到就按「老宿主」降级)
|
|
376
|
+
var host = null
|
|
377
|
+
try { host = DELIVER_PROMPT ? subagents[DELIVER_PROMPT] : null } catch (_) { host = null }
|
|
378
|
+
if (typeof host === 'function') {
|
|
379
|
+
try {
|
|
380
|
+
await host.call(subagents, parent, childId, m.content, m.source, makeSignal(), 'steer')
|
|
381
|
+
return { delivered: true, channel: 'steer' }
|
|
382
|
+
} catch (e) { console.error('[task-board] continuable 干预 host 投递失败 (task ' + taskId + '):', String(e)) }
|
|
383
|
+
}
|
|
384
|
+
if (typeof subagents.sendMessage === 'function') {
|
|
385
|
+
try {
|
|
386
|
+
await subagents.sendMessage(parent, childId, m.content, { signal: makeSignal() })
|
|
387
|
+
return { delivered: true, channel: 'steer' }
|
|
388
|
+
} catch (e) { console.error('[task-board] continuable 干预 sendMessage 回退失败 (task ' + taskId + '):', String(e)) }
|
|
389
|
+
}
|
|
390
|
+
return { delivered: false }
|
|
391
|
+
}
|
|
295
392
|
async function doIntervene(sid, actor, taskId, msg) {
|
|
296
393
|
if (!(msg || '').trim()) return { ok: false, error: 'message required' }
|
|
297
394
|
var rec = runsFor(sid)[taskId]
|
|
298
395
|
var delivered = false
|
|
299
396
|
var channel = ''
|
|
300
|
-
|
|
397
|
+
var missedNote = ''
|
|
398
|
+
if (rec && rec.continuable === true) {
|
|
399
|
+
// continuable:没有本地 Agent 句柄,只能经宿主投递(三态降级见上)
|
|
400
|
+
var ivr = await deliverContinuableIntervention(sid, taskId, rec, msg)
|
|
401
|
+
delivered = ivr.delivered === true
|
|
402
|
+
channel = ivr.channel || ''
|
|
403
|
+
if (!delivered) missedNote = '(干预未能实时送达(会话不可用),已转为重派注入)'
|
|
404
|
+
} else if (rec && rec.run && rec.run.localAgent) {
|
|
301
405
|
var agent = rec.run.localAgent
|
|
302
406
|
var m = makeMsg('[高优先级干预] 来自主窗口/用户的指令:\n\n' + msg + '\n\n请优先响应此指令,然后继续当前任务。', 'notice', '高优干预: ' + taskId)
|
|
303
407
|
if (typeof agent.steer === 'function') {
|
|
@@ -307,16 +411,28 @@ export function createRpc(ctx, state, deps) {
|
|
|
307
411
|
try { agent.followup(m); delivered = true; channel = 'followup' } catch (_) {}
|
|
308
412
|
}
|
|
309
413
|
}
|
|
310
|
-
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === taskId }); if (t) { if (!Array.isArray(t.messages)) t.messages = []; t.messages.push({ kind: 'intervention', text: msg, at: new Date().toISOString(), by: actor }); ah(t, t.status, t.status, actor, '高优干预: ' + msg.slice(0, 200) + (delivered ? '' : '(无活跃 run,随下次派发注入)')) }; return t })
|
|
414
|
+
await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === taskId }); if (t) { if (!Array.isArray(t.messages)) t.messages = []; t.messages.push({ kind: 'intervention', text: msg, at: new Date().toISOString(), by: actor }); ah(t, t.status, t.status, actor, '高优干预: ' + msg.slice(0, 200) + (delivered ? '' : (missedNote || '(无活跃 run,随下次派发注入)'))) }; return t })
|
|
311
415
|
return { ok: true, delivered: delivered, channel: channel }
|
|
312
416
|
}
|
|
313
|
-
// 终止执行某任务的 run
|
|
417
|
+
// 终止执行某任务的 run:一次性 dispose / continuable interrupt 后回 pending(verifying 则保持待审,由新 verifier 接手)
|
|
314
418
|
async function doTerminate(sid, actor, taskId) {
|
|
315
419
|
var rec = runsFor(sid)[taskId]
|
|
316
420
|
var label = 'no-active-run'
|
|
317
421
|
if (rec) {
|
|
318
422
|
delete runsFor(sid)[taskId]; label = rec.role + ':' + taskId
|
|
319
|
-
|
|
423
|
+
// 清理:一次性 run 走 dispose(既有语义逐字不变);continuable 的 rec 没有 run(持久子会话),
|
|
424
|
+
// 卡2 起改走 **interrupt 留存**(task-muw5h2ps):只打断当前 turn、不销毁子会话——否则「手动终止」
|
|
425
|
+
// 之后旧 Worker 仍在改同一棵树,与新派发的 Worker 并行写盘(终止按钮反而制造双写)。
|
|
426
|
+
// 手动终止**刻意不留续跑资格**:run 结局停在 running(不 closeRunHistory)→ 重派走 fresh spawn,
|
|
427
|
+
// 不把用户刚终止的子会话又唤醒(终止语义 = 重新排队,不是续跑)。
|
|
428
|
+
// interrupt 失败(会话已死 / 无 root 可授权)静默跳过:任务照常回 pending 重排,不卡死。
|
|
429
|
+
try { if (rec.run) await rec.run.dispose() } catch (_) {}
|
|
430
|
+
if (rec.continuable) {
|
|
431
|
+
try {
|
|
432
|
+
var subs = ctx.subagents, rroot = rootForSession(sid)
|
|
433
|
+
if (subs && typeof subs.interrupt === 'function' && rroot) subs.interrupt(rec.childId, { kind: 'ancestor', agent: rroot })
|
|
434
|
+
} catch (_) {}
|
|
435
|
+
}
|
|
320
436
|
// 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
|
|
321
437
|
// 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
|
|
322
438
|
await accumulateRunUsage(sid, rec)
|
|
@@ -369,9 +485,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
369
485
|
if (!t) return { ok: false, error: 'task disappeared' }
|
|
370
486
|
var rec = await spawnOneShot(sid, t, role)
|
|
371
487
|
if (rec) {
|
|
372
|
-
if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.claimedBy === 'spawn-pending') t2.claimedBy = String(rec.
|
|
373
|
-
if (role === 'verifier') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2) t2.verifierRun = String(rec.
|
|
374
|
-
return { ok: true, runId: String(rec.
|
|
488
|
+
if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.claimedBy === 'spawn-pending') t2.claimedBy = String(rec.id); return t2 }, true) }
|
|
489
|
+
if (role === 'verifier') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2) t2.verifierRun = String(rec.id); return t2 }, true) }
|
|
490
|
+
return { ok: true, runId: String(rec.id) }
|
|
375
491
|
}
|
|
376
492
|
// spawn 失败 → 回退
|
|
377
493
|
if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.status === 'in-progress' && t2.claimedBy === 'spawn-pending') { t2.status = 'pending'; t2.claimedBy = null; t2.claimedAt = null; ah(t2, 'in-progress', 'pending', 'system', 'spawn 失败') }; return t2 }, true) }
|
|
@@ -418,6 +534,14 @@ export function createRpc(ctx, state, deps) {
|
|
|
418
534
|
})
|
|
419
535
|
if (result && result.ok && result.escalated) maybeNotify(sid, result.task)
|
|
420
536
|
if (result && result.ok && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved') // 工具直报路径也要回执(work 档 board_report 直接落 resolved)
|
|
537
|
+
// ===== 上报通道收尾(task-muwkhqf8)=====
|
|
538
|
+
// 工具通道把任务推进到落定态(verifying/resolved)即代表这一轮 Worker 交付完成——此刻就把该 run
|
|
539
|
+
// 的记录收掉(关结局 + usage 落账 + 摘超时臂)。为什么不能只等 agent/status 的 idle:continuable
|
|
540
|
+
// Worker 的 idle 可能压根不到(伪 idle 被 rec.ran 二次守卫挡下 / 冷窗口 / 重启 / 事件丢失),
|
|
541
|
+
// 那时 run 记录永久停在 running → 软超时误报 + usage 漏计 + 硬超时对闲置会话补枪(本卡三症状)。
|
|
542
|
+
// 必须 await:上报返回前收尾落地,「上报成功但记录仍开着」的窗口归零。
|
|
543
|
+
// 失败/无活跃 rec 静默(收尾是附加动作,绝不因它让上报失败);progress/escalate 不在此列。
|
|
544
|
+
if (result && result.ok && result.task && result.task.status !== 'in-progress') await settleReportedRun(result.task.id)
|
|
421
545
|
return result
|
|
422
546
|
} }))
|
|
423
547
|
ctx.tools.register(defineTool({ name: 'board_verdict', description: '[任务看板 Verifier 专用] 提交验收结论。verdict=approved/rejected;summary=测试概要;checks=逐条核对证据。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, summary: { type: 'string' }, checks: { type: 'string' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) {
|
|
@@ -433,6 +557,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
433
557
|
if (!approved) { t.rejectCount = (t.rejectCount || 0) + 1; if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') } }
|
|
434
558
|
// 学习飞轮 v1:工具通道驳回同样生成候选教训(与文本通道同一条判重口径,不会重复落)
|
|
435
559
|
if (!approved) pushRejectLesson(d, t, (args.summary || '') + (args.checks ? '\n' + args.checks : ''), t.verification.at)
|
|
560
|
+
// 驳回包全量带回(task-muvg15p5):checks(逐条核对证据)此前只进 history 且被截到 200 字——
|
|
561
|
+
// 现落一条 kind='rejection' 全量包,随 buildMessages 注入重派 Worker prompt(见 core.pushRejection)
|
|
562
|
+
if (!approved) pushRejection(t, args.summary, args.checks, t.verification.at, actor)
|
|
436
563
|
return { ok: true, task: t }
|
|
437
564
|
})
|
|
438
565
|
if (result && result.ok && result.task) {
|
|
@@ -440,7 +567,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
440
567
|
if (result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
|
|
441
568
|
}
|
|
442
569
|
if (result && result.ok && !approved && result.task) {
|
|
443
|
-
// v74 一次性模型:原 Worker 已销毁,驳回任务回 pending 重派新 Worker
|
|
570
|
+
// v74 一次性模型:原 Worker 已销毁,驳回任务回 pending 重派新 Worker(完整驳回包已落 t.messages,
|
|
571
|
+
// 经 buildMessages 全量随 prompt 注入;history 只是审计轨)
|
|
444
572
|
var bt = result.task
|
|
445
573
|
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) }
|
|
446
574
|
}
|
|
@@ -479,8 +607,14 @@ export function createRpc(ctx, state, deps) {
|
|
|
479
607
|
} catch (e) { console.error('[task-board] push-lesson followup failed:', String(e)) }
|
|
480
608
|
return { ok: true, delivered: delivered }
|
|
481
609
|
})
|
|
482
|
-
|
|
483
|
-
|
|
610
|
+
// 板级配置写入(设置区单入口):数值键钳制范围、字符串键 trim、布尔键原样存。
|
|
611
|
+
// 回执开关 notifyDispatch/notifyDone(UI「通知」小节):布尔原样落盘,缺省 true 由 normalizeBoard 补;
|
|
612
|
+
// 生效点在派发侧读 cfg(snap)(不回填缓存),下一次派发/结算周期即生效。
|
|
613
|
+
// 史诗拆分总开关 epicSplit(UI「功能」小节):布尔原样落盘 + **当场回填 epicSplitCache**——Team 提示词
|
|
614
|
+
// 组装是同步函数只能读缓存,不回填就得等下一次 rt() 读盘才生效(关掉后仍按旧的劝拆一轮,体感是开关没生效)。
|
|
615
|
+
// 与 feedbackEnabled 同款双写;机制面(parentId 建子卡/自动收口/hooks)不看这个键。
|
|
616
|
+
handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } else if (args.key === 'notifyDispatch') d.notifyDispatch = !!args.value; else if (args.key === 'notifyDone') d.notifyDone = !!args.value; else if (args.key === 'workerContinuable') d.workerContinuable = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } }) })
|
|
617
|
+
handle('create-task', async function (args) { var sid = rpcSessionId(args); /* 短 id 防幻影板(task-muuf0o7a):截断/不完整 sessionId 建出的板永远匹配不到活 root(每 15s 刷屏元凶),显式报错优于静默建幻影板;import- 前缀板由 isFullSessionId 放行 */ if (!isFullSessionId(sid)) return { ok: false, error: 'sessionId 不完整(疑似截断短 id),拒绝创建任务: ' + sid }; var actor = getActorId(); /* hooks 只许主窗口设置(角色门禁) */ if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; return mutateLocked(sid, function (d) { var nHooks = null; if (args.hooks !== undefined) { var hn = normalizeHooks(args.hooks); if (hn.error) return { ok: false, error: hn.error }; nHooks = hn.hooks } if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate 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 || 'Untitled', 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 (nHooks && Object.keys(nHooks).length) t.hooks = nHooks; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); /* 调研门禁 warning 族(与并行 UI 卡约定字段名 warning,core 纯函数统一口径):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建;字段可选,老 client 无感 */ 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 }) })
|
|
484
618
|
handle('list-children', async function (args) { var sid = rpcSessionId(args); var subs = ctx.subagents; if (!subs) return { ok: true, children: [] }; try { var list = await subs.listChildren(sid); var children = (list || []).map(function (c) { return { id: String(c.sessionId || c.id || ''), label: String(c.label || c.title || c.mode || '') } }).filter(function (c) { return c.id.length > 0 }); return { ok: true, children: children } } catch (e) { return { ok: true, children: [], error: String(e) } } })
|
|
485
619
|
// ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
|
|
486
620
|
// delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
|
package/lib/session.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-agent-board — 会话层(lib/session.mjs)
|
|
2
2
|
// createSession(ctx, state):agent 归属解析(resolveRoot 10s 缓存)/ 工具与 RPC 的会话 id 归一 /
|
|
3
|
-
// 已知会话心跳集合 / 工作模式三档派生 / feedback 开关读取 / 按会话找 root agent / 会话工作区 /
|
|
3
|
+
// 已知会话心跳集合 / 工作模式三档派生 / feedback 与 epicSplit 开关读取 / 按会话找 root agent / 会话工作区 /
|
|
4
4
|
// 通用超时包装 / 活跃 run 表与派发履历访问。共享状态全部经 state 显式注入(别名为本地 var)。
|
|
5
5
|
// 另出口纯函数 isFullSessionId(幻影板防线口径,rpc.mjs 直接 import 复用,与 policy.mjs 纯函数同例)。
|
|
6
6
|
|
|
@@ -21,6 +21,7 @@ export function createSession(ctx, state) {
|
|
|
21
21
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
22
22
|
var knownSessions = state.knownSessions
|
|
23
23
|
var feedbackCache = state.feedbackCache
|
|
24
|
+
var epicSplitCache = state.epicSplitCache
|
|
24
25
|
var activeRuns = state.activeRuns
|
|
25
26
|
var dispatchedEver = state.dispatchedEver
|
|
26
27
|
|
|
@@ -53,6 +54,10 @@ export function createSession(ctx, state) {
|
|
|
53
54
|
// feedbackEnabled 缓存(学习飞轮 v1):同样由 rt() 同步——systemPrompt 的组装是同步函数,
|
|
54
55
|
// 不能在里面读文件。与 teamModeCache 同源同生命周期(引导段本来就要 teamMode 命中才渲染)。
|
|
55
56
|
function feedbackOn(sid) { return feedbackCache[sid] !== false }
|
|
57
|
+
// 史诗拆分总开关缓存(epicSplit,缺省 true):与 feedbackCache 同源同生命周期——Team 提示词组装是同步
|
|
58
|
+
// 函数,只能读缓存;由 rt() 每次读盘同步、set-board-config 写入时当场更新。缺键=开(老看板字段由
|
|
59
|
+
// normalizeBoard 补 true,未落盘前也不会误判为关)。
|
|
60
|
+
function epicSplitOn(sid) { return epicSplitCache[sid] !== false }
|
|
56
61
|
// 工作模式三档收敛(UI 一维化):内部仍存 boardMode+teamMode 两个 flag(老数据/老 RPC 无损),
|
|
57
62
|
// workMode 是纯派生字段——由两 flag 算出,不落盘(normalizeBoard 无需改动)。
|
|
58
63
|
// 'team' → boardMode=auto + teamMode=true(主窗口当调度员:默认草稿 + 裁决歧义)
|
|
@@ -72,5 +77,5 @@ export function createSession(ctx, state) {
|
|
|
72
77
|
function runsFor(sid) { if (!activeRuns[sid]) activeRuns[sid] = {}; return activeRuns[sid] }
|
|
73
78
|
function isDispatched(sid, id) { return !!(id && dispatchedEver[sid] && dispatchedEver[sid][id]) }
|
|
74
79
|
|
|
75
|
-
return { getActorId: getActorId, resolveRoot: resolveRoot, toolSessionId: toolSessionId, rpcSessionId: rpcSessionId, touchSession: touchSession, rootForSession: rootForSession, sessionCwd: sessionCwd, deriveWorkMode: deriveWorkMode, feedbackOn: feedbackOn, withTimeout: withTimeout, runsFor: runsFor, isDispatched: isDispatched, isFullSessionId: isFullSessionId }
|
|
80
|
+
return { getActorId: getActorId, resolveRoot: resolveRoot, toolSessionId: toolSessionId, rpcSessionId: rpcSessionId, touchSession: touchSession, rootForSession: rootForSession, sessionCwd: sessionCwd, deriveWorkMode: deriveWorkMode, feedbackOn: feedbackOn, epicSplitOn: epicSplitOn, withTimeout: withTimeout, runsFor: runsFor, isDispatched: isDispatched, isFullSessionId: isFullSessionId }
|
|
76
81
|
}
|
package/lib/store.mjs
CHANGED
|
@@ -13,6 +13,7 @@ export function createStore(ctx, state, deps) {
|
|
|
13
13
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
14
14
|
var teamModeCache = state.teamModeCache
|
|
15
15
|
var feedbackCache = state.feedbackCache
|
|
16
|
+
var epicSplitCache = state.epicSplitCache
|
|
16
17
|
var fileLocks = state.fileLocks
|
|
17
18
|
var cyclePending = state.cyclePending
|
|
18
19
|
|
|
@@ -137,7 +138,7 @@ export function createStore(ctx, state, deps) {
|
|
|
137
138
|
try {
|
|
138
139
|
var d = JSON.parse(r)
|
|
139
140
|
if (vt(d) && d.ownerSession === sid) {
|
|
140
|
-
teamModeCache[sid] = !!d.teamMode; var nd = normalizeBoard(d); feedbackCache[sid] = nd.feedbackEnabled !== false
|
|
141
|
+
teamModeCache[sid] = !!d.teamMode; var nd = normalizeBoard(d); feedbackCache[sid] = nd.feedbackEnabled !== false; epicSplitCache[sid] = nd.epicSplit !== false
|
|
141
142
|
// ownerCwd 回填(只改内存,随下一次写盘落盘):老看板文件没有该字段,而继承判定靠它——
|
|
142
143
|
// 不写盘就永远不能匿名继承。这里不额外做 IO,避免把只读路径变成写路径。
|
|
143
144
|
if (!nd.ownerCwd) { var cw = sessionCwd(sid); if (cw) nd.ownerCwd = cw }
|
|
@@ -180,7 +181,32 @@ export function createStore(ctx, state, deps) {
|
|
|
180
181
|
function withLock(sid, fn) { var prev = fileLocks[sid] || Promise.resolve(); var p = prev.then(function () { return fn() }); fileLocks[sid] = p.catch(function () {}); return p }
|
|
181
182
|
// 便捷:串行的 读→mutate→写。mutate(d) 返回值作为结果;mutate 返回 null/undefined 则不写
|
|
182
183
|
// 写成功后异步触发一次 poolCycle(派发/回收反应快),按会话去抖避免连环触发
|
|
183
|
-
|
|
184
|
+
// ===== 去抖 latch 的复位兜底(池冻结防御,task-muwlepg6)=====
|
|
185
|
+
// cyclePending 是「写盘后触发一次 cycle」的去抖闸门:置真 → 50ms 后复位 → 调 poolCycle。
|
|
186
|
+
// 旧实现 `tm.timeout(50).then(go)` 有两个恒真风险,任何一个都让**此后所有写盘都不再触发派发**
|
|
187
|
+
// (症状与「池被冻住」完全一致,且完全静默):
|
|
188
|
+
// ① 定时器 promise reject(插件热重载/宿主 timer 被回收)→ then 的 onFulfilled 永不执行,
|
|
189
|
+
// go 不跑 → cyclePending[sid] 永远为 true;
|
|
190
|
+
// ② 定时器 promise 永不落定(同源的宿主态异常)→ 同上。
|
|
191
|
+
// 修法:① 用 .then(go, go) 让 reject 也走复位;② latch 存**时间戳**而不是布尔,超过
|
|
192
|
+
// STALE_LATCH_MS(10s,正常去抖窗口的 200 倍——正常路径永远碰不到)即视为坏 latch,放行本次触发。
|
|
193
|
+
// ③ poolCycle 调用本身再包一层 try/catch:deps.poolCycle 还没收口(null)时旧写法会在
|
|
194
|
+
// then 回调里同步抛 TypeError → 变成无处理者的 promise 拒绝(reject 后 latch 也复位不了)。
|
|
195
|
+
var STALE_LATCH_MS = 10000
|
|
196
|
+
function kickCycle(sid) {
|
|
197
|
+
var latchedAt = cyclePending[sid]
|
|
198
|
+
if (latchedAt && Date.now() - latchedAt < STALE_LATCH_MS) return // 正常去抖:50ms 窗口内只排一次
|
|
199
|
+
cyclePending[sid] = Date.now()
|
|
200
|
+
var tm = ctx.timer
|
|
201
|
+
var go = function () {
|
|
202
|
+
cyclePending[sid] = 0
|
|
203
|
+
try {
|
|
204
|
+
var p = deps.poolCycle(sid)
|
|
205
|
+
if (p && typeof p.catch === 'function') p.catch(function () {})
|
|
206
|
+
} catch (e) { console.error('[task-board] kickCycle → poolCycle 调用失败:', String(e)) }
|
|
207
|
+
}
|
|
208
|
+
if (tm) tm.timeout(50).then(go, go); else Promise.resolve().then(go)
|
|
209
|
+
}
|
|
184
210
|
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 }) }
|
|
185
211
|
|
|
186
212
|
return { rt: rt, wt: wt, kickCycle: kickCycle, mutateLocked: mutateLocked }
|