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/core.mjs
CHANGED
|
@@ -20,8 +20,9 @@ export function vt(d) { return d && typeof d === 'object' && Array.isArray(d.tas
|
|
|
20
20
|
|
|
21
21
|
// ===== touches 文件级排他(glob 最小匹配器,零依赖)=====
|
|
22
22
|
// 背景:并行 Worker 改同一批文件会互踩(diff 冲突/一方覆盖另一方)。任务可声明
|
|
23
|
-
// touches: string[](glob
|
|
23
|
+
// touches: string[](glob),派发器发现与「持锁任务的 touches」冲突
|
|
24
24
|
// 则本轮跳过该候选(记入 pickDispatch 返回的 blockedTouches),等锁释放再派。
|
|
25
|
+
// 持锁口径见 holdsFiles(in-progress + claimedBy / verifying 持锁;resolved/cancelled/archived 即放)。
|
|
25
26
|
// 语义(宁可偏严不可漏拦):
|
|
26
27
|
// 1. 先归一化:\ → /、去 './' 前缀、去尾部 '/';空串或非字符串忽略。
|
|
27
28
|
// 2. a === b 视为冲突。
|
|
@@ -113,10 +114,27 @@ export function classifyPipeline(t) {
|
|
|
113
114
|
// dispose 释放并发位——人在线时由人决策,人不在时系统兜底。
|
|
114
115
|
// feedbackEnabled(学习飞轮 v1 总开关,默认开):关掉后不生成候选教训、prompt 不提软召回、
|
|
115
116
|
// 详情页沉淀按钮不渲染。老看板文件没有该字段 → 默认 true(normalizeBoard 补齐)。
|
|
117
|
+
// 回执开关(设置区「通知」小节,双布尔,缺省 true = 现状不变):
|
|
118
|
+
// notifyDispatch=false → 派发回执(任务被 Worker/Verifier 领走时播报)不入聚合队列;
|
|
119
|
+
// notifyDone=false → 完成回执(任务完成/阻塞时聚合播报)不入聚合队列。
|
|
120
|
+
// 只闸「回执」两条入口;歧义裁决通知(notifyMainWindow)是裁决通道不是回执,不接入开关。
|
|
121
|
+
// 史诗拆分总开关 epicSplit(入池配置弹层「功能」小节,缺省 true = 现状不变):**只关引导,不禁机制**——
|
|
122
|
+
// false → ① Team 提示词第 6 条 TEAM_SPLIT_RULE 整条不注入(policy.splitRuleOf);② create-task RPC 与
|
|
123
|
+
// task_create 工具的返回体不再附 suggestSplit 软提示(policy.withSplitHint 第三参门禁)。
|
|
124
|
+
// 机制照常:显式传 parentId 建子卡、史诗自动收口(checkParentAuto)、hooks 状态机都不看这个开关——
|
|
125
|
+
// 用户/主窗口明确要拆的时候不受阻(关的是"主动劝你拆",不是"不许你拆")。
|
|
126
|
+
// 边界:task_create 工具描述里的 TASK_SIZE_CONTRACT 是**静态工具契约**(工具定义会快照进 request header,
|
|
127
|
+
// dsh-session 校验 description 必须是 string),没有按板动态能力,因此不随本开关走——这也是「关引导」
|
|
128
|
+
// 只覆盖两处**动态引导**(Team 提示词条款 + suggestSplit 软提示)的原因。
|
|
129
|
+
// Worker 可续跑开关 workerContinuable(task-muw5gnhv,缺省 true = 新行为即默认):门禁**只在
|
|
130
|
+
// dispatch.spawnOneShot 的 Worker 分支**生效——true 走 subagents.startContinuable(rec 持 childId,
|
|
131
|
+
// turn 结算靠 host 事件 agent/status running→idle),false 逐字回退旧的 subagents.start() 一次性路径
|
|
132
|
+
// (run.result 结算)。Verifier/hook run 一律照旧 one-shot,不看这个键(它们无续跑语义)。
|
|
133
|
+
// 消费点只有派发引擎(spawn 时读一次 cfg 快照),所以不需要 feedbackEnabled/epicSplit 那样的热路径缓存。
|
|
116
134
|
export function cfg(d) {
|
|
117
135
|
var soft = Math.max(1, Math.min(480, d.softTimeoutMin || 30))
|
|
118
136
|
var hard = Math.max(soft, Math.min(1440, d.hardTimeoutMin || 120))
|
|
119
|
-
return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, feedbackEnabled: d.feedbackEnabled !== false }
|
|
137
|
+
return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, feedbackEnabled: d.feedbackEnabled !== false, notifyDispatch: d.notifyDispatch !== false, notifyDone: d.notifyDone !== false, epicSplit: d.epicSplit !== false, workerContinuable: d.workerContinuable !== false }
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
// ===== 看板数据目录(跨重启继承用)=====
|
|
@@ -133,7 +151,7 @@ export function boardHome() { return path.join(boardDirName(), '.dsh') }
|
|
|
133
151
|
// ownerCwd(跨重启继承):创建该看板的会话工作区路径,继承判定全靠它——取不到就省略字段
|
|
134
152
|
// (绝不落空串,否则「路径读不到的多个会话」会被误判成同一工作区)。
|
|
135
153
|
export function seed(sid, ownerCwd) {
|
|
136
|
-
var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
|
|
154
|
+
var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, workerContinuable: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
|
|
137
155
|
if (typeof ownerCwd === 'string' && ownerCwd) d.ownerCwd = ownerCwd
|
|
138
156
|
return d
|
|
139
157
|
}
|
|
@@ -141,10 +159,18 @@ export function seed(sid, ownerCwd) {
|
|
|
141
159
|
// touches 兼容:老任务没有该字段照常(这里只把「存在但非数组」的脏值收敛成数组,
|
|
142
160
|
// 避免 holdsFiles/touchesConflict 里 Array.isArray 判定之外还有第三种形态)
|
|
143
161
|
// feedbackEnabled 兼容:老看板没有该字段(或落了脏值)一律补 true——默认开,行为与 v1 之前一致。
|
|
162
|
+
// notifyDispatch/notifyDone(回执开关)同法:老看板文件没有该字段 → 补 true,缺省开 = 现状不变。
|
|
163
|
+
// epicSplit(史诗拆分总开关)同法:老看板没有该字段(或脏值)→ 补 true,缺省开 = 引导照旧。
|
|
164
|
+
// workerContinuable(Worker 可续跑开关)同法:老看板没有该字段(或脏值)→ 补 true,
|
|
165
|
+
// 即老看板读进来就按新行为(可续跑 Worker)派发;显式落 false 才是逐字回退旧一次性路径。
|
|
144
166
|
export function normalizeBoard(d) {
|
|
145
167
|
if (d && typeof d === 'object') {
|
|
146
168
|
if (!d.poolStatus || typeof d.poolStatus !== 'object' || !Array.isArray(d.poolStatus.workers) || !Array.isArray(d.poolStatus.verifiers)) d.poolStatus = { workers: [], verifiers: [] }
|
|
147
169
|
if (typeof d.feedbackEnabled !== 'boolean') d.feedbackEnabled = true
|
|
170
|
+
if (typeof d.notifyDispatch !== 'boolean') d.notifyDispatch = true
|
|
171
|
+
if (typeof d.notifyDone !== 'boolean') d.notifyDone = true
|
|
172
|
+
if (typeof d.epicSplit !== 'boolean') d.epicSplit = true
|
|
173
|
+
if (typeof d.workerContinuable !== 'boolean') d.workerContinuable = true
|
|
148
174
|
if (Array.isArray(d.tasks)) {
|
|
149
175
|
for (var i = 0; i < d.tasks.length; i++) {
|
|
150
176
|
var t = d.tasks[i]
|
|
@@ -158,10 +184,183 @@ export function normalizeBoard(d) {
|
|
|
158
184
|
// ===== 状态流转 =====
|
|
159
185
|
export function claimCheck(d, t, sid) { if (CLAIMABLE.indexOf(t.status) < 0) return 'cannot claim in ' + t.status; if (t.claimedBy && t.claimedBy !== sid && t.status === 'in-progress') return 'claimed by ' + t.claimedBy; if (d.boardMode === 'manual' || t.assignMode === 'manual') { if (t.assignee && t.assignee !== sid) return 'assigned to ' + t.assignee }; if (isb(t)) { var p = gpt(t, d.tasks); if (!p) return 'parent not found'; if (p.status !== 'in-progress' && p.status !== 'verifying') return 'parent not in-progress' }; var mc = d.tasks.filter(function (x) { return x.claimedBy === sid && (x.status === 'in-progress' || x.status === 'verifying') && !isb(x) }); if (!isb(t) && mc.length >= MAX_CLAIMED) return 'max ' + MAX_CLAIMED + ' active'; return null }
|
|
160
186
|
export function claimApply(d, t, sid, note) { var ps = t.status; t.status = 'in-progress'; t.claimedBy = sid; t.claimedAt = new Date().toISOString(); ah(t, ps, 'in-progress', sid, note) }
|
|
161
|
-
|
|
162
|
-
|
|
187
|
+
// 子任务「已了结」终态口径:resolved(已完成/已验收)+ cancelled(人主动放弃该子任务范围)。
|
|
188
|
+
// 为什么 cancelled 也算:cancelled 是人的显式决定,该子任务范围已关闭;若不算,一张被取消的
|
|
189
|
+
// 子任务会把 epic 永久钉在 in-progress(手动取消的卡反而制造死卡)。验收时人仍可在 epic 上驳回。
|
|
190
|
+
// 注:archived 不算——归档是「收尾/清理」动作,不应反向推动父卡流转(父卡归档时会级联归档子任务)。
|
|
191
|
+
export function isChildSettled(t) { return !!t && (t.status === 'resolved' || t.status === 'cancelled') }
|
|
192
|
+
// 史诗父卡自动收口(共享 helper,唯一判定口径):父卡存在、父卡 in-progress、且全部子任务 ∈ 终态完成集
|
|
193
|
+
// → 父卡转 verifying(交人验收)。幂等:父卡已 verifying/resolved/archived 一律返回 null,重复调用无副作用。
|
|
194
|
+
export function maybeAutoCloseParent(d, childTask) {
|
|
195
|
+
if (!d || !childTask || !isb(childTask)) return null
|
|
196
|
+
var p = gpt(childTask, d.tasks)
|
|
197
|
+
if (!p || p.status !== 'in-progress') return null
|
|
198
|
+
var s = gsb(p.id, d.tasks)
|
|
199
|
+
if (!s.length || !s.every(function (x) { return isChildSettled(x) })) return null
|
|
200
|
+
// ===== post 延迟 verifying(hooks=agent run 接线③)=====
|
|
201
|
+
// epic 声明了 post hook 且尚未收口(state !== 'done')→ 不直接转 verifying,先挂 post 闸门:
|
|
202
|
+
// state='idle' → 本轮不转 verifying,只置 running 等 poolCycle 补 spawn hook-post run
|
|
203
|
+
// (core 保持纯函数:只落状态 + 返回父卡,spawn 由 dispatch 侧做)
|
|
204
|
+
// state='running' → 已在收口途中,poolCycle 会补 spawn(idle 与 running 同分支,二者共用
|
|
205
|
+
// hook-post 幂等占用标记 hooks.post.pending)
|
|
206
|
+
// state='failed' → 收口失败已 blocked 等人裁决,绝不自动收口(回归安静态,避免每轮重复触发)
|
|
207
|
+
// 未声明/未启用 post → 直接转 verifying(既有行为逐字不变),并清掉可能残留的待跑标记。
|
|
208
|
+
if (hookOn(p, 'post')) {
|
|
209
|
+
var pst = hookState(p, 'post')
|
|
210
|
+
if (pst === 'failed') return null
|
|
211
|
+
if (pst !== 'done') {
|
|
212
|
+
p.hooks.post.pending = true // hook-post 幂等占用标记:poolCycle 见它才 spawn(spawn 后清除,失败再置回)
|
|
213
|
+
p.hooks.post.state = 'running'
|
|
214
|
+
ah(p, p.status, p.status, 'system', 'auto: all subtasks settled,post hook 收口未完成,延迟 verifying')
|
|
215
|
+
return p
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (p.hooks && p.hooks.post && p.hooks.post.pending) delete p.hooks.post.pending
|
|
219
|
+
p.status = 'verifying'
|
|
220
|
+
p.resolvedAt = new Date().toISOString()
|
|
221
|
+
p.resolution = 'all subtasks resolved'
|
|
222
|
+
ah(p, 'in-progress', 'verifying', 'system', 'auto: all subtasks resolved')
|
|
223
|
+
return p
|
|
224
|
+
}
|
|
225
|
+
// 兼容名:既有调用点(verifyApply)与 TEAM_SPLIT_RULE / README 的对外语义名保持不变,内部委托共享 helper。
|
|
226
|
+
export function checkParentAuto(d, t) { return maybeAutoCloseParent(d, t) }
|
|
227
|
+
export function resolveApply(d, t, sid, status, resolution, note) { var ps = t.status; if (status === 'verifying' && t.pipeline && t.pipeline !== 'full') { status = 'resolved' } t.status = status; t.resolution = resolution || null; t.resolvedAt = new Date().toISOString(); ah(t, ps, status, sid, note); var r = { ok: true, task: t }; var p = maybeAutoCloseParent(d, t); if (p) { r.parentUpdated = true }; return r }
|
|
163
228
|
export function verifyApply(d, t, sid, verdict, comment) { var ps = t.status; if (verdict === 'approved') { t.status = 'resolved'; t.verifiedAt = new Date().toISOString(); t.verifiedBy = sid; delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, ps, 'resolved', sid, 'approved' + (comment ? ': ' + comment : '')) } else { t.status = 'in-progress'; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'in-progress', sid, 'rejected' + (comment ? ': ' + comment : '')) }; var r = { ok: true, task: t }; if (verdict === 'approved' && isb(t)) { var p = checkParentAuto(d, t); if (p) { r.parentUpdated = true } }; return r }
|
|
164
229
|
|
|
230
|
+
// ===== 史诗 hooks=agent run(宿主生命周期接线)=====
|
|
231
|
+
// 定位:hook 点 = 一次**真实 agent 运行**(不是声明式命令、不走 shell),挂在 epic 卡上、
|
|
232
|
+
// 由派发周期 spawn 成一次性子代理 run(role 'hook-pre' / 'hook-post'),run 结算把 state 推进。
|
|
233
|
+
// 数据形态(写在卡上,重启可恢复——state 机不靠内存):
|
|
234
|
+
// epic.hooks = { pre: { enabled, prompt, state: 'idle'|'running'|'done'|'failed', runId }, post: 同构 }
|
|
235
|
+
// 三条红线:
|
|
236
|
+
// ① 点位可选——epic 未声明 hooks 时全链路零变化(pickDispatch / maybeAutoCloseParent / poolCycle
|
|
237
|
+
// 都只在 hooks 存在且 enabled 时才进入分支,老 epic 行为逐字不变);
|
|
238
|
+
// ② 薄框架——prompt 只给契约与上下文,做什么由 hook agent 自行决策,吃不准就歧义上报;
|
|
239
|
+
// ③ commit/push 不进任何默认形态——默认文案明确写「不要默认提交/推送」,收口动作全由 agent 自己判断。
|
|
240
|
+
export var HOOK_PHASES = ['pre', 'post']
|
|
241
|
+
export var HOOK_STATES = ['idle', 'running', 'done', 'failed']
|
|
242
|
+
// 建卡/更新卡的 hooks 浅校验(工具 task_create/task_update 与 RPC create-task/update-task 共用同一口径)。
|
|
243
|
+
// 口径:只认 { pre?, post? } 两键;每项 { enabled?:bool, prompt?:string, state?:enum, runId?:string };
|
|
244
|
+
// prompt 非空字符串(≤4000,超长截断);state/runId 允许传(重启后人工恢复现场);未知键忽略、不报错。
|
|
245
|
+
// 返回 { hooks } 或 { error }——调用方把 error 原样回给主窗口(宁早报错,别静默存下一坨跑不起来的配置)。
|
|
246
|
+
export function normalizeHooks(input) {
|
|
247
|
+
if (input === null) return { hooks: null }
|
|
248
|
+
if (typeof input !== 'object' || Array.isArray(input)) return { error: 'hooks 必须是对象({ pre?, post? })' }
|
|
249
|
+
var out = {}
|
|
250
|
+
var dels = []
|
|
251
|
+
for (var i = 0; i < HOOK_PHASES.length; i++) {
|
|
252
|
+
var ph = HOOK_PHASES[i]
|
|
253
|
+
var raw = input[ph]
|
|
254
|
+
if (raw === undefined) continue
|
|
255
|
+
// 显式 null = 撤掉该点位(un-declare)。它必须原样穿到 mergeHooks——否则「只撤 pre」的意图会在
|
|
256
|
+
// 归一化阶段被吃掉,task_update hooks:{pre:null} 变成空操作(实测踩过)。
|
|
257
|
+
if (raw === null) { dels.push(ph); out[ph] = null; continue }
|
|
258
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return { error: 'hooks.' + ph + ' 必须是对象' }
|
|
259
|
+
var h = { enabled: raw.enabled === undefined ? true : !!raw.enabled, prompt: '', state: 'idle', runId: null }
|
|
260
|
+
if (raw.prompt !== undefined) {
|
|
261
|
+
if (typeof raw.prompt !== 'string') return { error: 'hooks.' + ph + '.prompt 必须是字符串' }
|
|
262
|
+
h.prompt = raw.prompt.slice(0, 4000)
|
|
263
|
+
}
|
|
264
|
+
if (!String(h.prompt).trim()) return { error: 'hooks.' + ph + '.prompt 不能为空(薄框架只给契约,契约本体由主窗口写)' }
|
|
265
|
+
if (raw.state !== undefined) {
|
|
266
|
+
if (HOOK_STATES.indexOf(raw.state) < 0) return { error: 'hooks.' + ph + '.state 必须是 ' + HOOK_STATES.join('/') }
|
|
267
|
+
h.state = raw.state
|
|
268
|
+
}
|
|
269
|
+
if (raw.runId !== undefined && raw.runId !== null) h.runId = String(raw.runId)
|
|
270
|
+
// 内部机器标记(pending)原样穿过去:主窗口若为恢复现场整条重传 pre,不该把待跑标记洗掉
|
|
271
|
+
if (raw.pending) h.pending = true
|
|
272
|
+
out[ph] = h
|
|
273
|
+
}
|
|
274
|
+
// 只有「纯删除」时才返回仅含 null 的对象;有真实点位时把 null 一并带上(mergeHooks 逐点位处理)
|
|
275
|
+
if (!dels.length) { var clean = {}; for (var j = 0; j < HOOK_PHASES.length; j++) { if (out[HOOK_PHASES[j]] && out[HOOK_PHASES[j]] !== null) clean[HOOK_PHASES[j]] = out[HOOK_PHASES[j]] } return { hooks: clean } }
|
|
276
|
+
return { hooks: out }
|
|
277
|
+
}
|
|
278
|
+
// 已有 hooks 与本次提交的钩子做**浅合并**(task_update 只想改 prompt 时不必重复整条 pre/post):
|
|
279
|
+
// 未提交的键保留原值;提交 null 表示删除该点位(un-declare)。
|
|
280
|
+
export function mergeHooks(prev, next) {
|
|
281
|
+
var out = {}
|
|
282
|
+
var base = (prev && typeof prev === 'object') ? prev : {}
|
|
283
|
+
for (var i = 0; i < HOOK_PHASES.length; i++) {
|
|
284
|
+
var ph = HOOK_PHASES[i]
|
|
285
|
+
if (base[ph]) out[ph] = base[ph]
|
|
286
|
+
}
|
|
287
|
+
if (!next) return out
|
|
288
|
+
for (var j = 0; j < HOOK_PHASES.length; j++) {
|
|
289
|
+
var p2 = HOOK_PHASES[j]
|
|
290
|
+
if (next[p2] === undefined) continue
|
|
291
|
+
if (next[p2] === null) { delete out[p2]; continue }
|
|
292
|
+
out[p2] = next[p2]
|
|
293
|
+
}
|
|
294
|
+
return out
|
|
295
|
+
}
|
|
296
|
+
// 该点位是否「已声明且启用」——pickDispatch 闸门与 poolCycle 触发共用的唯一判定口径
|
|
297
|
+
export function hookOn(owner, phase) { var h = owner && owner.hooks && owner.hooks[phase]; return !!(h && h.enabled) }
|
|
298
|
+
export function hookState(owner, phase) { var h = owner && owner.hooks && owner.hooks[phase]; return (h && h.state) || 'idle' }
|
|
299
|
+
// 状态机写入(唯一入口,保证 ah 留痕):state 写在 epic 卡上(重启可恢复)。
|
|
300
|
+
export function hookSetState(p, phase, state, actor, note) {
|
|
301
|
+
var h = p.hooks && p.hooks[phase]
|
|
302
|
+
var from = (h && h.state) || 'idle'
|
|
303
|
+
h.state = state
|
|
304
|
+
ah(p, p.status, p.status, actor || 'system', 'hooks.' + phase + ': ' + from + ' → ' + state + (note ? '(' + note + ')' : ''))
|
|
305
|
+
return h
|
|
306
|
+
}
|
|
307
|
+
// hook run 结算(纯函数,dispatch.settleHook 持锁段调用;抽出来是为了能单测状态机):
|
|
308
|
+
// ok=true pre → hooks.pre='done'(串行闸门打开,下轮起子任务正常派发)
|
|
309
|
+
// ok=true post → hooks.post='done' + epic 转 verifying(收口完成,交人验收)
|
|
310
|
+
// ok=false pre/post → 该点位 state='failed' + epic 转 blocked + escalation 挂卡(歧义上报:
|
|
311
|
+
// 重试/跳过/放弃,由主窗口裁决),绝不自动重试——故障 hook 反复重跑只会烧钱。
|
|
312
|
+
// 幂等:点位已 done、或 epic 已有未裁决 escalation 时返回 { already: true },不覆盖不改状态。
|
|
313
|
+
export function applyHookSettle(d, epicId, phase, ok, output, runId, errText) {
|
|
314
|
+
var p = d.tasks.find(function (x) { return x.id === epicId })
|
|
315
|
+
if (!p) return null
|
|
316
|
+
var h = (p.hooks && p.hooks[phase]) || null
|
|
317
|
+
if (!h) return { task: p, already: true }
|
|
318
|
+
if (h.state === 'done') return { task: p, already: true }
|
|
319
|
+
if (p.escalation) { h.runId = null; delete h.pending; return { task: p, already: true } }
|
|
320
|
+
h.runId = null
|
|
321
|
+
delete h.pending
|
|
322
|
+
if (!ok) {
|
|
323
|
+
var why = String(errText || '').slice(0, 150) || '未给出有效结论'
|
|
324
|
+
hookSetState(p, phase, 'failed', String(runId), 'hook run 失败')
|
|
325
|
+
p.lastError = ('hooks.' + phase + ' run 失败: ' + why).slice(0, 300)
|
|
326
|
+
p.escalation = {
|
|
327
|
+
question: 'hooks.' + phase + '(' + (phase === 'pre' ? '前置准备' : '收口') + ')失败:' + why + '\n' +
|
|
328
|
+
(output ? 'hook agent 输出(截断):\n' + String(output).slice(0, 1200) + '\n' : '') +
|
|
329
|
+
'请裁决:重试(把 hooks.' + phase + '.state 置回 idle 即可重跑)/ 跳过(置为 done 放行)/ 放弃(终止该史诗)。',
|
|
330
|
+
at: new Date().toISOString(), by: String(runId),
|
|
331
|
+
}
|
|
332
|
+
if (!Array.isArray(p.messages)) p.messages = []
|
|
333
|
+
p.messages.push({ kind: 'escalation', text: p.escalation.question, at: p.escalation.at, by: String(runId) })
|
|
334
|
+
ah(p, p.status, 'blocked', String(runId), 'hooks.' + phase + ' 失败,待主窗口裁决(重试/跳过/放弃)')
|
|
335
|
+
p.status = 'blocked'
|
|
336
|
+
return { task: p, blocked: true }
|
|
337
|
+
}
|
|
338
|
+
hookSetState(p, phase, 'done', String(runId), 'hook run 完成')
|
|
339
|
+
if (phase === 'post') {
|
|
340
|
+
var from = p.status
|
|
341
|
+
p.status = 'verifying'
|
|
342
|
+
p.resolvedAt = new Date().toISOString()
|
|
343
|
+
p.resolution = 'hooks.post settled'
|
|
344
|
+
ah(p, from, 'verifying', 'system', 'auto: post hook 收口完成')
|
|
345
|
+
return { task: p, closed: true }
|
|
346
|
+
}
|
|
347
|
+
return { task: p, preDone: true }
|
|
348
|
+
}
|
|
349
|
+
// ===== pre 串行闸门(纯函数,pickDispatch 过滤用)=====
|
|
350
|
+
// 语义:子任务候选命中派发前,先看它所在 epic 的前置 hook 是否已完成——
|
|
351
|
+
// hooks.pre 未声明/未启用 → 放行(老 epic 零变化)
|
|
352
|
+
// state='done' → 放行(前置准备完成,下轮起子任务正常派发)
|
|
353
|
+
// state='idle' → **拦下**:本轮不派子任务,由 poolCycle 改 spawn hook-pre run(串行闸门)
|
|
354
|
+
// state='running' → 拦下:hook run 还在跑,跳过本轮(不重复 spawn)
|
|
355
|
+
// state='failed' → 拦下:已 blocked 等人裁决(重试/跳过/放弃),绝不自动放行
|
|
356
|
+
// 串行闸门语义 = 「一个 epic 的子任务在 pre hook 完成之前一张都不派」,天然并发安全。
|
|
357
|
+
export function preHookGate(epic) {
|
|
358
|
+
if (!epic || !hookOn(epic, 'pre')) return { pass: true, reason: 'none' }
|
|
359
|
+
var st = hookState(epic, 'pre')
|
|
360
|
+
if (st === 'done') return { pass: true, reason: 'done' }
|
|
361
|
+
return { pass: false, reason: st }
|
|
362
|
+
}
|
|
363
|
+
|
|
165
364
|
// ===== 史诗父卡语义层 =====
|
|
166
365
|
// 父卡自动流转:子任务被派发时(poolCycle 占位 claim 的 dispatch 分支调用),
|
|
167
366
|
// 若父卡 status 为 pending → 父卡转 in-progress(ah 记「首个子任务派发,史诗进入推进态」),
|
|
@@ -172,20 +371,27 @@ export function verifyApply(d, t, sid, verdict, comment) { var ps = t.status; if
|
|
|
172
371
|
export function parentKickOnDispatch(d, t) { if (!isb(t)) return null; var p = gpt(t, d.tasks); if (!p || p.status !== 'pending') return null; p.status = 'in-progress'; ah(p, 'pending', 'in-progress', 'system', '首个子任务派发,史诗进入推进态'); return p }
|
|
173
372
|
|
|
174
373
|
// childStats 聚合(get-tasks 返回体字段,按 tasks 现算零存储):
|
|
175
|
-
// { <parentId>: { total, resolved, active, activeTitle } }
|
|
176
|
-
// 口径:total=该 parentId
|
|
177
|
-
//
|
|
178
|
-
// activeTitle=第一个 in-progress
|
|
179
|
-
//
|
|
374
|
+
// { <parentId>: { total, settled, resolved, active, activeTitle } }
|
|
375
|
+
// 口径:total=该 parentId 的**全部**子任务数(**含已归档**);settled=其中已了结数,分子口径
|
|
376
|
+
// settled = resolved | cancelled | archived;resolved=settled 的兼容别名(既有对外字段名,同值,
|
|
377
|
+
// 老前端/老断言按 resolved 读仍成立);active=in-progress 数;activeTitle=第一个 in-progress
|
|
378
|
+
// 子任务标题(按看板顺序,无则空串)。active/activeTitle 口径不变:archived 永不算 active。
|
|
379
|
+
// 为什么归档必须计入(task-muupgfot):归档=人已显式了结该卡;若把 archived 从 total/settled 里
|
|
380
|
+
// 一起排除,归档一张子卡会让史诗进度从 0/10「退化」成 0/9——分母无故缩水、分子永不前进,
|
|
381
|
+
// 正是用户报的「有子任务完成后史诗进度从 0/10 变 0/9」。进度只增不减是这条口径的红线。
|
|
382
|
+
// 近似说明:v1.7.1 起 archive-task 放行「无活跃 run 的 in-progress 僵尸卡」,故 archived 不再严格
|
|
383
|
+
// 等于「曾经完成」;但归档动作本身仍是人的显式了结,进度语义上计入是对的(僵尸出清场景里父卡
|
|
384
|
+
// 通常随之归档,不影响在板卡片的展示)。注意与 isChildSettled(父卡自动收口口径,archived 不算)
|
|
385
|
+
// 的区别:那条管「是否推动父卡流转」,本条只管「进度分母/分子的展示口径」,两者刻意不合并。
|
|
180
386
|
export function aggregateChildStats(tasks) {
|
|
181
387
|
var out = {}
|
|
182
388
|
var list = Array.isArray(tasks) ? tasks : []
|
|
183
389
|
for (var i = 0; i < list.length; i++) {
|
|
184
390
|
var t = list[i]
|
|
185
|
-
if (!t || !isb(t)
|
|
186
|
-
var s = out[t.parentId] || (out[t.parentId] = { total: 0, resolved: 0, active: 0, activeTitle: '' })
|
|
391
|
+
if (!t || !isb(t)) continue
|
|
392
|
+
var s = out[t.parentId] || (out[t.parentId] = { total: 0, settled: 0, resolved: 0, active: 0, activeTitle: '' })
|
|
187
393
|
s.total++
|
|
188
|
-
if (t.status === 'resolved' || t.status === 'archived') s.resolved++
|
|
394
|
+
if (t.status === 'resolved' || t.status === 'cancelled' || t.status === 'archived') { s.settled++; s.resolved++ }
|
|
189
395
|
if (t.status === 'in-progress') { s.active++; if (!s.activeTitle) s.activeTitle = String(t.title || '') }
|
|
190
396
|
}
|
|
191
397
|
return out
|
|
@@ -251,6 +457,39 @@ export function pushLesson(t, text, at, by) {
|
|
|
251
457
|
return true
|
|
252
458
|
}
|
|
253
459
|
|
|
460
|
+
// ===== 驳回包全量带回(task-muvg15p5)=====
|
|
461
|
+
// 问题实证:驳回信息此前只走 history(histNotes 每条截 300 字),board_verdict 工具通道更把
|
|
462
|
+
// checks(逐条核对证据 = 真正可执行的驳回细节)整段丢弃;手动驳回 comment 可为空、且不写
|
|
463
|
+
// t.verification → 重派 Worker 只拿到一句被截断的「rejected」,无从据此返工。
|
|
464
|
+
// 修法(零新通道):三条驳回路径统一往 t.messages 追一条 kind='rejection' 的完整驳回包,
|
|
465
|
+
// buildMessages 会把 t.messages 全量拼进 Worker prompt(每条 2000 字,远超 history 的 300 字)。
|
|
466
|
+
// history 保持现状不动——它是审计轨,不是执行载荷。
|
|
467
|
+
// 文本口径:有 summary/checks 时「验收驳回 · <summary>」+「核对项:<checks>」;两者皆空(手动驳回
|
|
468
|
+
// 未填原因)时给一句可执行的兜底,而不是留一条空消息。
|
|
469
|
+
export function rejectionText(summary, checks) {
|
|
470
|
+
var s = String(summary == null ? '' : summary).trim()
|
|
471
|
+
var c = String(checks == null ? '' : checks).trim()
|
|
472
|
+
if (!s && !c) return '(驳回方未填写原因,请先自查交付物与验收脚本差距)'
|
|
473
|
+
return '验收驳回 · ' + s + (c ? '\n\n核对项:\n' + c : '')
|
|
474
|
+
}
|
|
475
|
+
// 往任务 messages 追一条完整驳回包(kind='rejection')。轻量判重(与 pushLesson 同口径):
|
|
476
|
+
// 同一 at 或正文前 80 字相同视为同一事件——文本结算路径在工具通道已处理时 already=true 会整段
|
|
477
|
+
// 跳过,判重是第二道保险,杜绝同一驳回双推。返回 true = 本次新落一条。
|
|
478
|
+
export function pushRejection(t, summary, checks, at, by) {
|
|
479
|
+
if (!t) return false
|
|
480
|
+
if (!Array.isArray(t.messages)) t.messages = []
|
|
481
|
+
var stamp = at || new Date().toISOString()
|
|
482
|
+
var text = rejectionText(summary, checks)
|
|
483
|
+
var head = text.slice(0, 80)
|
|
484
|
+
for (var i = 0; i < t.messages.length; i++) {
|
|
485
|
+
var m = t.messages[i]
|
|
486
|
+
if (!m || m.kind !== 'rejection') continue
|
|
487
|
+
if (m.at === stamp || String(m.text || '').slice(0, 80) === head) return false
|
|
488
|
+
}
|
|
489
|
+
t.messages.push({ kind: 'rejection', text: text, at: stamp, by: by || 'system' })
|
|
490
|
+
return true
|
|
491
|
+
}
|
|
492
|
+
|
|
254
493
|
// ===== 一次性子代理 prompt 构建(上下文由主窗口 agent 写入 description/instructions,系统只追加生命周期记录)=====
|
|
255
494
|
// 学习飞轮 v1 软召回引导(feedbackEnabled 开时才拼进 prompt):环境里若有笔记/记忆类工具,先查历史教训再动手。
|
|
256
495
|
// 只是"提示先搜"——看板不代查、不调用任何记忆工具、也无从知道有没有这类工具(零耦合)。
|
|
@@ -261,28 +500,45 @@ export function buildMessages(t) {
|
|
|
261
500
|
if (!Array.isArray(t.messages) || t.messages.length === 0) return ''
|
|
262
501
|
return t.messages.map(function (m) { return '### [' + (m.kind || 'note') + '] (' + (m.at || '') + ' by ' + (m.by || '') + ')\n' + String(m.text || '').slice(0, 2000) }).join('\n\n')
|
|
263
502
|
}
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
503
|
+
// 主窗口预研上下文段(瘦身分离形态,task-muvjs392):**笔记全文 + 文件清单**,
|
|
504
|
+
// 由 host 组装后传入(core 保持纯函数不碰 IO)。文件内容本体不进 prompt——由 Worker 用 read 工具
|
|
505
|
+
// 按行号范围自取(执行时盘面,更新鲜),既免了大文件正文撑爆 prompt,也免了随 runtime 快照每轮重发
|
|
506
|
+
//(旧形态走 systemPrompt.context,自治 run 中实测 6×48.8K 字符≈白烧 75–100K token)。
|
|
507
|
+
// 注意:{{ }} 净化沿用「systemPrompt context 严格插值」时代的封闭变换(内容里 {{...}} 会直接抛异常
|
|
508
|
+
// 毁掉子代理的整个 prompt 组装)。通道虽已退役,净化是幂等纯变换且单测锁定口径,保留以防历史笔记复发。
|
|
267
509
|
// 教训:replace(/\{\{/g,'{ {') 是开变换——三连括号 {{{lo}}}(Python f-string)会得到
|
|
268
510
|
// "{ {{lo}}}",替换结果自己又造出 {{。按"连 { 整 run 拆开"才是封闭变换。
|
|
269
511
|
function sanitizeCtx(s) { return String(s).replace(/\{+/g, function (m) { return m.length === 1 ? m : m.split('').join(' ') }) }
|
|
512
|
+
// contextFiles 条目写法:「路径[:L起[-L止]][ — 一句用途]」。
|
|
513
|
+
// 用途可选(缺省只给路径行号,不造新字段):分隔符用「 — 」(em dash 带两侧空格,路径/锚点里不会出现)。
|
|
514
|
+
// 锚点按 parseAnchorPath 口径归一(a.js:L10-20 → a.js:L10-L20;非法锚点剥掉,不误导 Worker 去读空段)。
|
|
515
|
+
// 返回 { path: 清单展示串(含归一锚点), file: 纯路径(存在性预检用,剥掉锚点与用途), usage }。
|
|
516
|
+
export function parseContextFileEntry(entry) {
|
|
517
|
+
var s = String(entry == null ? '' : entry).trim()
|
|
518
|
+
var usage = ''
|
|
519
|
+
var sep = s.indexOf(' — ')
|
|
520
|
+
if (sep > 0) { usage = s.slice(sep + 3).trim(); s = s.slice(0, sep).trim() }
|
|
521
|
+
var a = parseAnchorPath(s)
|
|
522
|
+
var p = a.from != null ? (a.file + ':L' + a.from + (a.to != null ? '-L' + a.to : '')) : a.file
|
|
523
|
+
return { path: p, file: a.file, usage: usage }
|
|
524
|
+
}
|
|
525
|
+
// 组装瘦身清单:笔记段(全文,调用方已按 8000 字符截断)+ 文件清单段(每行「路径:L行号 — 一句用途」)。
|
|
526
|
+
// files 收 { path, usage }(兼容裸字符串);两者皆空 → 空串(调用方据此跳过注入,保持无清单 parity)。
|
|
270
527
|
export function buildContextPackSection(files, notes) {
|
|
271
528
|
var parts = []
|
|
272
529
|
if (notes && String(notes).trim()) parts.push('### 主窗口调研笔记(结论/思路/原始需求,直接采信)\n' + sanitizeCtx(String(notes)))
|
|
273
|
-
|
|
274
|
-
|
|
530
|
+
var list = []
|
|
531
|
+
if (Array.isArray(files)) {
|
|
275
532
|
for (var i = 0; i < files.length; i++) {
|
|
276
533
|
var f = files[i]
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
parts.push('结构索引(' + sanitizeCtx(f.path) + ' 的函数/标题及行号,可用 路径:L起-L止 锚点补读):\n' + f.outline.map(function (x) { return sanitizeCtx(x) }).join('\n'))
|
|
283
|
-
}
|
|
534
|
+
var path = (typeof f === 'string') ? f : (f && f.path)
|
|
535
|
+
if (!path) continue
|
|
536
|
+
var line = '- ' + sanitizeCtx(path)
|
|
537
|
+
if (f && typeof f === 'object' && f.usage) line += ' — ' + sanitizeCtx(f.usage)
|
|
538
|
+
list.push(line)
|
|
284
539
|
}
|
|
285
540
|
}
|
|
541
|
+
if (list.length) parts.push('### 调研文件清单(附带的调研文件清单请按需用 read 工具自行读取,不要全量盲读;给出行号的按行号范围读)\n' + list.join('\n'))
|
|
286
542
|
return parts.length ? parts.join('\n\n') : ''
|
|
287
543
|
}
|
|
288
544
|
|
|
@@ -290,6 +546,9 @@ export function buildContextPackSection(files, notes) {
|
|
|
290
546
|
// 背景:contextFiles 注入大文件被 8KB 头部截断,目标代码段在中部/尾部,Worker 仍需全文盘点;
|
|
291
547
|
// 且只标"截断"二字,不知道截掉了什么、该补读哪里。
|
|
292
548
|
// 三个纯函数落在这里可单测;零依赖纯行正则,宁可漏检不可误切(不引 parser)。
|
|
549
|
+
// 【task-muvjs392 后现状】派发侧已改为「清单 + Worker 按需 read 自取」,host 不再读盘、不再切段/附索引:
|
|
550
|
+
// parseAnchorPath 仍在使用(清单条目归一,见 parseContextFileEntry);sliceLines / buildFileOutline
|
|
551
|
+
// 暂留为纯工具(口径由单测锁定),不删以免丢历史行为证据、也备将来主窗口要行段预切时复用——宿主路径上已无调用点。
|
|
293
552
|
// 锚点行段长度上限(行):超出截断到该上限并标记 capped
|
|
294
553
|
export var CONTEXT_SLICE_MAX_LINES = 400
|
|
295
554
|
// 结构索引条数上限:超出附一条省略标注
|
|
@@ -358,6 +617,11 @@ export function buildFileOutline(content) {
|
|
|
358
617
|
}
|
|
359
618
|
return out
|
|
360
619
|
}
|
|
620
|
+
// 口径说明(epicSplit 总开关审计):Worker prompt **不含任何拆分引导条款**——拆分引导只出现在主窗口侧
|
|
621
|
+
// 两处动态面(Team 提示词第 6 条 + create-task/task_create 的 suggestSplit 软提示),Worker 拿到的是
|
|
622
|
+
// 已经建好的单张卡(要拆也轮不到它拆,真觉得大应走歧义上报)。故 epicSplit=false 时 Worker prompt 字面
|
|
623
|
+
// 与 true 时逐字相同(单测锁定 parity,防止将来有人往这里塞拆分条款而漏接门禁);buildWorkerPrompt
|
|
624
|
+
// 因此不引入 epicSplit 形参——没有条款可跳过,加个无用参数只会是死代码。
|
|
361
625
|
export function buildWorkerPrompt(t, pack, feedbackEnabled) {
|
|
362
626
|
var notes = histNotes(t)
|
|
363
627
|
var msgs = buildMessages(t)
|
|
@@ -391,18 +655,70 @@ export function buildVerifierPrompt(t, pack) {
|
|
|
391
655
|
return p
|
|
392
656
|
}
|
|
393
657
|
|
|
658
|
+
// ===== hook run prompt(薄框架模板,hooks=agent run)=====
|
|
659
|
+
// hook 点 = 一次真实 agent 运行:prompt 只给「契约一句话 + epic 上下文 + 子任务清单」,
|
|
660
|
+
// 具体做什么由 hook agent 按现场自行决策;吃不准/信息不足一律歧义上报,禁止硬闯。
|
|
661
|
+
// commit/push 不进任何默认形态——前置不许默认提交,后置明确写「不要默认提交/推送」(红线③)。
|
|
662
|
+
// pre 契约:让这批子任务具备开跑条件(做什么准备由 agent 判断,可只读调研后什么都不改)。
|
|
663
|
+
// post 契约:把这批已完成的工作收口(验证/总结/(自行决定并自负其责的)提交都算)。
|
|
664
|
+
export function buildHookPrompt(epic, phase, childTasks) {
|
|
665
|
+
var kids = Array.isArray(childTasks) ? childTasks : []
|
|
666
|
+
var pre = phase !== 'post'
|
|
667
|
+
var p = '你是一个一次性史诗 hook 执行 Agent(hooks=' + (pre ? 'pre' : 'post') + ' run)。本次运行由任务看板的派发周期发起,完成(或上报歧义)后本会话即销毁。\n\n'
|
|
668
|
+
p += 'hook 点位:' + (pre ? 'pre(前置准备闸门)' : 'post(收口闸门)') + '\n'
|
|
669
|
+
p += '所属史诗:' + epic.id + ' · ' + String(epic.title || '') + '\n'
|
|
670
|
+
p += '史诗状态:' + String(epic.status || '') + '\n'
|
|
671
|
+
p += '史诗描述:' + String(epic.description || '(无)').slice(0, 2000) + '\n'
|
|
672
|
+
if (epic.context && epic.context.instructions) p += '史诗指引:' + String(epic.context.instructions).slice(0, 1000) + '\n'
|
|
673
|
+
p += '\n主窗口给本次 hook 的运行契约(薄框架:只定边界,不做具体动作安排):\n' + String((epic.hooks && epic.hooks[phase] && epic.hooks[phase].prompt) || '(未填写)').slice(0, 4000) + '\n'
|
|
674
|
+
p += '\n该史诗的子任务清单(' + kids.length + ' 个):\n'
|
|
675
|
+
if (!kids.length) p += '- (暂无子任务)\n'
|
|
676
|
+
for (var i = 0; i < kids.length; i++) {
|
|
677
|
+
var c = kids[i]
|
|
678
|
+
p += '- [' + (c.status || '') + '] ' + c.id + ' · ' + String(c.title || '').slice(0, 120)
|
|
679
|
+
if (c.pipeline) p += '(管线 ' + c.pipeline + ')'
|
|
680
|
+
p += '\n'
|
|
681
|
+
}
|
|
682
|
+
p += '\n你的任务:' + (pre
|
|
683
|
+
? '让这批子任务具备开跑条件。做什么准备由你根据上下文判断(可只读调研、制定方案、补齐约定,也可以判断为「无需准备」)。'
|
|
684
|
+
: '把这批已完成的工作收口。收口动作由你判断(通常可能涉及验证、总结,也可能涉及提交——自行决策并自负其责)。')
|
|
685
|
+
p += '信息不足、吃不准、或需要用户/主窗口决策时:优先调用 board_report(kind=escalate, taskId=' + epic.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测、不要硬闯。\n'
|
|
686
|
+
p += '红灯纪律:不要默认提交(git commit)或推送(git push)——除非上面的运行契约明确要求,或你判断确实是本次收口不可省略的一步;那也要在完成说明里写清做了什么、为什么。\n'
|
|
687
|
+
if (!pre) p += '本次收口完成后,史诗将自动转入 verifying(交人验收)。\n'
|
|
688
|
+
p += '\n完成契约(双模,工具优先):\n'
|
|
689
|
+
if (pre) p += '1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + epic.id + ',summary=做了什么准备/changes=改动清单(无改动就写「无」)/selfTest=自测情况/diffStat=变更概要)。工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。完成即代表 pre 闸门放行,之后 epic 的子任务会开始派发。\n'
|
|
690
|
+
else p += '1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + epic.id + ',summary=收口做了什么/changes=改动清单(无改动就写「无」)/selfTest=自测情况/diffStat=变更概要)。工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n'
|
|
691
|
+
p += ' **board_report 调用成功即本次运行终点:立即结束输出,不要再修改/验证任何文件**。\n'
|
|
692
|
+
p += '2. 歧义/信息不足:board_report(kind=escalate, taskId=' + epic.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。上报后直接结束本轮。\n'
|
|
693
|
+
p += '3. 进展汇报:只在有实际产物/结论时报(board_report kind="progress"),禁止表演式汇报。\n'
|
|
694
|
+
return p
|
|
695
|
+
}
|
|
696
|
+
|
|
394
697
|
// ===== 文件锁持有集合(touches 排他)=====
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
698
|
+
// 口径(用户 2026-10-06 裁决):锁只护「正在写」的阶段——**状态流转到已完成(resolved)即放锁**。
|
|
699
|
+
// 这是对 task-muv7c8ja / e969f57「锁持到归档」的回调:归档应回归纯收纳动作,不再是释放点。
|
|
700
|
+
// 原话:「文件锁归档后才解锁不合理,应该状态自动流转到已完成时解锁」(反馈 n-muupqg81u575 的
|
|
701
|
+
// 真问题——验收后-提交前窗口期污染——改由流程承接,不再用长持锁兜底)。
|
|
702
|
+
// 持锁两态(声明了 touches 为前提):
|
|
703
|
+
// - in-progress + claimedBy:Worker 正在改文件(claimedBy 为空=占位未落座/僵尸,不算持锁);
|
|
704
|
+
// - verifying:Worker 已停笔但**尚未落定**——驳回会回 in-progress 让同一批文件继续被改,
|
|
705
|
+
// 故锁不能在 in-progress→verifying 断(Verifier 只读,不参与排他)。
|
|
706
|
+
// 即放(不持有):resolved(完成即放,未归档也放)、cancelled(放弃语义=不再产出)、
|
|
707
|
+
// archived(归档只是收纳,对锁零影响)、pending/blocked/draft(没有 Worker 在改文件)。
|
|
708
|
+
// 承接关系(「验收后-提交前」窗口期为什么不再靠锁兜底):
|
|
709
|
+
// ① 主窗口即时门禁纪律:收到完成回执就立即提交,不等不看别的卡(提交时点与回执对齐);
|
|
710
|
+
// ② 史诗 post-hook:提交动作收进任务生命周期(post=done 才收口 epic),提交可追溯、可审计。
|
|
711
|
+
// 锁只表达「文件正在被写」,「改动尚未提交」由上面两条承接——否则 resolved 卡长期不归档
|
|
712
|
+
// 会一直堵住同批文件、把本可并行的批次全串行化,这正是本次回调要消除的代价。
|
|
399
713
|
// 返回 [{id, touches}],id 用于 blockedTouches.conflicts 展示"在等谁"。
|
|
400
714
|
export function holdsFiles(d) {
|
|
401
715
|
var out = []
|
|
402
716
|
if (!d || !Array.isArray(d.tasks)) return out
|
|
403
717
|
for (var i = 0; i < d.tasks.length; i++) {
|
|
404
718
|
var t = d.tasks[i]
|
|
405
|
-
if (t
|
|
719
|
+
if (!t || !Array.isArray(t.touches) || !t.touches.length) continue
|
|
720
|
+
var holds = t.status === 'verifying' || (t.status === 'in-progress' && !!t.claimedBy)
|
|
721
|
+
if (holds) out.push({ id: t.id, touches: t.touches })
|
|
406
722
|
}
|
|
407
723
|
return out
|
|
408
724
|
}
|
|
@@ -422,6 +738,9 @@ export function touchesConflict(t, holds) {
|
|
|
422
738
|
// 不进 pendings,改记 [{id, conflicts:[持有任务id...]}],由 index.mjs 的 poolCycle 写展示态字段
|
|
423
739
|
// t.waitingForTouches(每心跳刷新的 UI 展示,不参与其他逻辑)。verifs 不受 touches 影响(Verifier 只读)。
|
|
424
740
|
// 注意:frozen/dependsOn/escalation/上限 的优先级不变——先过滤再算 touches 冲突。
|
|
741
|
+
// pre hook 串行闸门(hooks=agent run 接线①):候选所属 epic 的 hooks.pre 未 done 时整批不派
|
|
742
|
+
// (见 preHookGate),由 poolCycle 改 spawn hook-pre run——同一 epic 的子任务在准备完成前
|
|
743
|
+
// 一张都不派,这是「pre 闸门」的串行语义;未声明 hooks 的 epic 逐字不受影响。
|
|
425
744
|
export function pickDispatch(d, capW, capV, busyTaskIds) {
|
|
426
745
|
var blockedTouches = []
|
|
427
746
|
var pendings = []
|
|
@@ -429,7 +748,7 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
|
|
|
429
748
|
// 防止同一轮 cycle 派出的两个任务声明重叠 touches。
|
|
430
749
|
var holds = holdsFiles(d)
|
|
431
750
|
if (capW > 0) {
|
|
432
|
-
var cands = d.tasks.filter(function (t) { return t.status === 'pending' && !t.claimedBy && !t.frozen && t.assignMode !== 'manual' && t.pipeline !== 'direct' && depsSatisfied(d, t) && !t.escalation })
|
|
751
|
+
var cands = d.tasks.filter(function (t) { return t.status === 'pending' && !t.claimedBy && !t.frozen && t.assignMode !== 'manual' && t.pipeline !== 'direct' && depsSatisfied(d, t) && !t.escalation && preHookGate(gpt(t, d.tasks)).pass })
|
|
433
752
|
.sort(function (a, b) { var p = (PRIO_RANK[b.priority] || 2) - (PRIO_RANK[a.priority] || 2); return p !== 0 ? p : (a.createdAt || '').localeCompare(b.createdAt || '') })
|
|
434
753
|
for (var i = 0; i < cands.length && pendings.length < capW; i++) {
|
|
435
754
|
var conflicts = touchesConflict(cands[i], holds)
|
|
@@ -441,7 +760,7 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
|
|
|
441
760
|
var verifs = capV > 0 ? d.tasks.filter(function (t) { return t.status === 'verifying' && !t.frozen && (!t.pipeline || t.pipeline === 'full') && !t.escalation && t.verifierRun !== 'spawn-pending' && !(busyTaskIds && busyTaskIds[t.id]) }).slice(0, capV) : []
|
|
442
761
|
return { pendings: pendings, verifs: verifs, blockedTouches: blockedTouches }
|
|
443
762
|
}
|
|
444
|
-
// 孤儿回收判定:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟
|
|
763
|
+
// 孤儿回收判定:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟
|
|
445
764
|
export function isOrphan(d, t, runs, now) {
|
|
446
765
|
return t.status === 'in-progress' && t.claimedBy && t.claimedBy !== d.ownerSession && !(runs && runs[t.id]) && !t.escalation && (now - new Date(t.claimedAt || 0).getTime()) > 120000
|
|
447
766
|
}
|
|
@@ -534,7 +853,8 @@ export function attachContextSuggestions(out, warnings, touches, exists) {
|
|
|
534
853
|
|
|
535
854
|
// epic 发布预检:父卡 publish 时对其子任务做轻量调研注入预检。
|
|
536
855
|
// 口径:只看 pipeline≠direct 的非归档子任务;「无调研注入」= context.files/context.notes 皆空,
|
|
537
|
-
// 或 files 列了路径但全部不存在(exists 回调判定,锚点 :L
|
|
856
|
+
// 或 files 列了路径但全部不存在(exists 回调判定,锚点 :L 段与可选「 — 一句用途」后缀先剥掉再查——
|
|
857
|
+
// 存在性是对文件而言的,见 parseContextFileEntry)。
|
|
538
858
|
// exists(path) 由调用方注入(fs.existsSync + 会话工作区相对解析包装);缺省时退化为只查字段有无。
|
|
539
859
|
// 返回 { total, missing: [{id, title, reason}] };total=参与预检的子任务数(供汇总文案 N/M)。
|
|
540
860
|
export function epicPrecheck(tasks, parentId, exists) {
|
|
@@ -548,8 +868,8 @@ export function epicPrecheck(tasks, parentId, exists) {
|
|
|
548
868
|
var reason = ''
|
|
549
869
|
if (!files.length && !hasNotes) reason = '无 contextFiles/contextNotes'
|
|
550
870
|
else if (files.length && typeof exists === 'function') {
|
|
551
|
-
var anyExist = files.some(function (p) { try { return !!exists(
|
|
552
|
-
if (!anyExist) reason = '
|
|
871
|
+
var anyExist = files.some(function (p) { try { return !!exists(parseContextFileEntry(p).file) } catch (_) { return false } })
|
|
872
|
+
if (!anyExist) reason = '预研文件路径全部不存在(子代理按需 read 时也会全部读不到)'
|
|
553
873
|
}
|
|
554
874
|
if (reason) missing.push({ id: String(k.id || ''), title: String(k.title || '').slice(0, 40), reason: reason })
|
|
555
875
|
}
|