Skip to content

Commit e373cc3

Browse files
author
shuanbao
committed
fix: 稳住 autopilot 循环并升级 openclaw
- 自检硬校验空 output 直接判零分,避免无产出任务被 LLM 打高分进入 rework 死循环 - readTaskOutput 支持逗号/数组多路径与 legacy 内联内容,多文件任务不再因单路径读失败整个置空 - parseTaskAssignments 扩展否定短语与动作动词白名单,过滤 chief 叙述型文本避免僵尸任务污染 - 部门循环分离 task 空闲与 agent 空闲,加 in_progress/rework 硬上限防止 agent 假汇报长期挂单 - CLAUDE.md 精简冗长架构图 - openclaw 升级到 2026.4.9 Co-Authored-By: shuanbao <[email protected]>
1 parent bf7308a commit e373cc3

9 files changed

Lines changed: 596 additions & 971 deletions

File tree

CLAUDE.md

Lines changed: 321 additions & 940 deletions
Large diffs are not rendered by default.

core/autopilot/department-loop.cjs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ async function autoTransitionTasks(deptId, config, chiefResponseText, options =
232232
const idleMins = activity
233233
? activity.idleMins
234234
: Math.floor((Date.now() - new Date(task.updatedAt || task.createdAt).getTime()) / 60000)
235+
// Separate "agent idle" vs "task idle". Agent idle is reset every time the
236+
// dept-loop queries the agent's :main for status, so for a task in review
237+
// that's what we've been measuring wrong: the task can sit for an hour in
238+
// review while the agent is trivially reactive to our own pings. Use the
239+
// task's own updatedAt for the review-pickup decision.
240+
const taskIdleMins = Math.floor((Date.now() - new Date(task.updatedAt || task.createdAt).getTime()) / 60000)
235241

236242
if (task.status === 'assigned') {
237243
if (idleMins < 5) {
@@ -242,11 +248,20 @@ async function autoTransitionTasks(deptId, config, chiefResponseText, options =
242248
logger.warn('dept-loop', `Assigned task ${task.id} never started, marked failed (agent ${assignee} idle ${idleMins}m)`)
243249
}
244250
} else if (task.status === 'review') {
245-
// Collect review tasks for parallel quality gate processing
246-
if (idleMins >= IDLE_COMPLETE_MINS) {
251+
// Use taskIdleMins not agent idleMins — dept-loop pings reset agent idle.
252+
if (taskIdleMins >= IDLE_COMPLETE_MINS) {
247253
reviewItems.push({ task, assignee })
248254
}
249255
} else if (task.status === 'in_progress' || task.status === 'rework') {
256+
// Hard ceiling on dual-session: if the task's own updatedAt shows it has
257+
// been stuck for more than STALE_TASK_MINS * 2, move it on regardless of
258+
// what the agent reports. Without this, agents that keep replying
259+
// "working" on a ghost task can stall the same task for hours.
260+
if (taskIdleMins >= STALE_TASK_MINS * 2) {
261+
transition(task, assignee, task.status, 'failed', `task 停滞 ${taskIdleMins}m,超过硬上限`)
262+
logger.warn('dept-loop', `Hard ceiling: ${task.id} stuck ${taskIdleMins}m regardless of agent status → failed`)
263+
continue
264+
}
250265
if (dualEnabled && statusQueryResults) {
251266
// Dual-session path: use explicit status query instead of idle guessing
252267
const status = statusQueryResults[assignee]

core/repo/task.cjs

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -213,20 +213,52 @@ class TaskRepository extends BaseRepository {
213213
}
214214

215215
/**
216-
* Read the content of a task's output file
217-
* @param {object} task - Task with optional output path
218-
* @returns {string|null} file content or null if not available
216+
* Read the content of a task's output file(s).
217+
*
218+
* task.output can be:
219+
* - a single file path: "projects/foo/docs/report.md"
220+
* - comma/newline-separated paths: "a.md, b.md, c.md"
221+
* - an array of paths: ["a.md", "b.md"]
222+
* - inline content (legacy): a string that isn't a path
223+
*
224+
* Returns concatenated content from all existing files (each prefixed with
225+
* a "# <path>" header), or null if no paths resolve to readable files.
226+
* For inline-content task.output (no path-like tokens), returns it as-is.
227+
*
228+
* @param {object} task
229+
* @returns {string|null}
219230
*/
220231
readTaskOutput(task) {
221232
if (!task || !task.output) return null
222-
const outputPath = resolve(PROJECT_ROOT, task.output)
223-
try {
224-
if (!existsSync(outputPath)) return null
225-
return readFileSync(outputPath, 'utf-8')
226-
} catch (err) {
227-
logger.debug('task-repo', 'failed to read task output', { outputPath, error: err.message })
228-
return null
233+
234+
const raw = task.output
235+
const tokens = Array.isArray(raw)
236+
? raw
237+
: String(raw).split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
238+
239+
if (tokens.length === 0) return null
240+
241+
const contents = []
242+
let sawPathLike = false
243+
for (const token of tokens) {
244+
// Skip tokens that don't look like file paths (no slash, no .ext) —
245+
// those are likely inline text, not a path.
246+
if (!/[\\/]/.test(token) && !/\.[a-z0-9]{1,8}$/i.test(token)) continue
247+
sawPathLike = true
248+
const abs = resolve(PROJECT_ROOT, token)
249+
try {
250+
if (!existsSync(abs)) continue
251+
const body = readFileSync(abs, 'utf-8')
252+
contents.push(tokens.length > 1 ? `# ${token}\n\n${body}` : body)
253+
} catch (err) {
254+
logger.debug('task-repo', 'failed to read task output path', { path: abs, error: err.message })
255+
}
229256
}
257+
258+
if (contents.length > 0) return contents.join('\n\n---\n\n')
259+
// No path-like tokens at all → treat original as inline content (legacy).
260+
if (!sawPathLike && typeof raw === 'string') return raw
261+
return null
230262
}
231263

232264
/** Update a task in-place via DB + sync to file if project task */

core/task/auto-transition.cjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ const logger = require('../common/logger.cjs')
1616
* @param {string} text
1717
* @returns {Array<{agentId: string, summary: string, projectId?: string}>}
1818
*/
19+
// Matches phrases chief uses to signal "no new task for this agent". Kept as a
20+
// single regex so additions go in one place. The patterns are intentionally
21+
// broad because chief prose varies a lot; false negatives are much worse than
22+
// false positives here (a skipped line just means no task is created, while a
23+
// missed filter creates a zombie task that pollutes the project).
24+
const NO_ASSIGNMENT_RE = /|||||||||(?:|)|||(?:|||)||(?:||)||(?:|)|||🔴|🔵|🔴\s*busy|\s*\d+\s*/
25+
1926
function parseTaskAssignments(text) {
2027
if (!text) return []
2128
const match = text.match(/[\[][\]]\s*\n([\s\S]*?)(?=\n[\[]|$)/)
@@ -27,14 +34,19 @@ function parseTaskAssignments(text) {
2734
const m = line.match(/^(?:[-*]|\d+[.)]\s*)\s*(\S+?)[:\uff1a]\s*(.+?)(?:\s*[\(\uff08].*[\)\uff09])?\s*$/)
2835
if (!m) continue
2936
const [, agentId, rawSummary] = m
30-
if (/||/.test(rawSummary)) continue
37+
if (NO_ASSIGNMENT_RE.test(rawSummary)) continue
3138
if (/task-[a-z0-9]/.test(line)) continue
3239

3340
// Extract optional [project: xxx]
3441
const projMatch = rawSummary.match(/\[project:\s*([^\]]+)\]/)
3542
const projectId = projMatch ? projMatch[1].trim() : undefined
3643
const summary = rawSummary.replace(/\[project:\s*[^\]]+\]\s*/, '').trim()
3744

45+
// Reject if the cleaned summary looks like a status sentence rather than a
46+
// concrete task directive. A real assignment has an action verb (分配/
47+
// 创建/产出/写/review 等); status sentences don't.
48+
if (!/|||||||||||||||||||||research|review|test|implement|write|build|create|fix|add|update|refactor/i.test(summary)) continue
49+
3850
const entry = { agentId, summary }
3951
if (projectId) entry.projectId = projectId
4052
assignments.push(entry)

core/task/quality-orchestrator.cjs

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,28 @@ class QualityOrchestrator {
186186
}
187187
}
188188

189+
/**
190+
* @private
191+
* Given a task.output value (string or array), return the subset of
192+
* path-like tokens that do not resolve to an existing file. Used for
193+
* clearer self-check failure messages when multi-path outputs are in play.
194+
*/
195+
_listMissingOutputPaths(output) {
196+
if (!output) return []
197+
const { existsSync } = require('fs')
198+
const { resolve } = require('path')
199+
const { PROJECT_ROOT } = require('../common/paths.cjs')
200+
const tokens = Array.isArray(output)
201+
? output
202+
: String(output).split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
203+
const missing = []
204+
for (const token of tokens) {
205+
if (!/[\\/]/.test(token) && !/\.[a-z0-9]{1,8}$/i.test(token)) continue
206+
if (!existsSync(resolve(PROJECT_ROOT, token))) missing.push(token)
207+
}
208+
return missing
209+
}
210+
189211
/**
190212
* Select a peer reviewer from the department.
191213
*/
@@ -252,25 +274,29 @@ class QualityOrchestrator {
252274
async _requestSelfCheck(agentId, task, deptId) {
253275
if (!agentId) return { passed: false, score: 0, checklist: ['无执行者'], at: new Date().toISOString() }
254276

255-
// Hard validation: check output content via injected readTaskOutput
256-
if (task.output) {
257-
const content = this._readTaskOutput(task)
258-
if (!content) {
259-
return { passed: false, score: 0, checklist: ['产出文件不存在: ' + task.output], at: new Date().toISOString() }
260-
}
261-
if (content.length < 500) {
262-
return { passed: false, score: 0, checklist: [`产出仅 ${content.length} 字符,最低要求 500`], at: new Date().toISOString() }
263-
}
264-
if (/\$\{[^}]+\}/.test(content.slice(0, 5000))) {
265-
return { passed: false, score: 0, checklist: ['含未渲染模板变量 ${...}'], at: new Date().toISOString() }
266-
}
277+
// Hard validation: empty output is an automatic fail. A task that reaches
278+
// self-check without any output field means nothing was produced —
279+
// previously the `if (task.output)` guard would silently skip validation
280+
// and let the LLM grade a phantom task, which is how empty rework chains
281+
// kept scoring high without ever writing anything.
282+
if (!task.output) {
283+
return { passed: false, score: 0, checklist: ['产出为空:task.output 字段未设置'], at: new Date().toISOString() }
267284
}
268-
269-
let outputContent = ''
270-
if (task.output) {
271-
const raw = this._readTaskOutput(task)
272-
outputContent = raw ? raw.slice(0, 5000) : `(无法读取: ${task.output})`
285+
const content = this._readTaskOutput(task)
286+
if (!content) {
287+
const missing = this._listMissingOutputPaths(task.output)
288+
const detail = missing.length > 0 ? missing.join(', ') : String(task.output)
289+
return { passed: false, score: 0, checklist: ['产出文件不存在: ' + detail], at: new Date().toISOString() }
273290
}
291+
if (content.length < 500) {
292+
return { passed: false, score: 0, checklist: [`产出仅 ${content.length} 字符,最低要求 500`], at: new Date().toISOString() }
293+
}
294+
if (/\$\{[^}]+\}/.test(content.slice(0, 5000))) {
295+
return { passed: false, score: 0, checklist: ['含未渲染模板变量 ${...}'], at: new Date().toISOString() }
296+
}
297+
298+
// `content` was already read during hard validation above
299+
const outputContent = content.slice(0, 5000)
274300

275301
// Build checklist: prefer type-specific from task-standards.md, fallback to generic
276302
let checklistItems

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"@anthropic-ai/sdk": "^0.39.0",
2323
"better-sqlite3": "^11.10.0",
2424
"clawhub": "^0.7.0",
25-
"openclaw": "^2026.3.31",
25+
"openclaw": "^2026.4.9",
2626
"ws": "^8.19.0"
2727
},
2828
"engines": {

tests/core/repo/task.test.cjs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
'use strict'
22
const { describe, it } = require('node:test')
33
const assert = require('node:assert/strict')
4+
const { mkdtempSync, writeFileSync, rmSync } = require('fs')
5+
const { tmpdir } = require('os')
6+
const { join } = require('path')
47

58
describe('TaskRepository', () => {
69
it('exports TaskRepository and taskRepo', () => {
@@ -41,4 +44,79 @@ describe('TaskRepository', () => {
4144
assert.deepEqual(task.assignees, ['writer-a'])
4245
assert.equal(task.assignedAgent, 'writer-a')
4346
})
47+
48+
describe('readTaskOutput', () => {
49+
const { taskRepo } = require('../../../core/repo/task.cjs')
50+
51+
it('returns null for missing / empty output', () => {
52+
assert.equal(taskRepo.readTaskOutput(null), null)
53+
assert.equal(taskRepo.readTaskOutput({}), null)
54+
assert.equal(taskRepo.readTaskOutput({ output: '' }), null)
55+
})
56+
57+
it('reads a single existing file path (absolute)', () => {
58+
const dir = mkdtempSync(join(tmpdir(), 'af-readoutput-'))
59+
try {
60+
const f = join(dir, 'a.md')
61+
writeFileSync(f, '# Hello')
62+
const content = taskRepo.readTaskOutput({ output: f })
63+
assert.equal(content, '# Hello')
64+
} finally {
65+
rmSync(dir, { recursive: true, force: true })
66+
}
67+
})
68+
69+
it('reads comma-separated multi-path output (the bug that caused the rework death spiral)', () => {
70+
const dir = mkdtempSync(join(tmpdir(), 'af-readoutput-'))
71+
try {
72+
const a = join(dir, 'a.md')
73+
const b = join(dir, 'b.md')
74+
writeFileSync(a, 'Alpha content')
75+
writeFileSync(b, 'Beta content')
76+
const content = taskRepo.readTaskOutput({ output: `${a}, ${b}` })
77+
assert.ok(content, 'should return non-null')
78+
assert.match(content, /Alpha content/)
79+
assert.match(content, /Beta content/)
80+
assert.match(content, /---/, 'should separate files with divider')
81+
} finally {
82+
rmSync(dir, { recursive: true, force: true })
83+
}
84+
})
85+
86+
it('accepts array of paths', () => {
87+
const dir = mkdtempSync(join(tmpdir(), 'af-readoutput-'))
88+
try {
89+
const a = join(dir, 'a.md')
90+
writeFileSync(a, 'content A')
91+
const content = taskRepo.readTaskOutput({ output: [a] })
92+
assert.match(content, /content A/)
93+
} finally {
94+
rmSync(dir, { recursive: true, force: true })
95+
}
96+
})
97+
98+
it('skips non-existent paths but returns content from existing ones', () => {
99+
const dir = mkdtempSync(join(tmpdir(), 'af-readoutput-'))
100+
try {
101+
const a = join(dir, 'a.md')
102+
writeFileSync(a, 'real content')
103+
const content = taskRepo.readTaskOutput({ output: `${a}, ${dir}/missing.md` })
104+
assert.match(content, /real content/)
105+
} finally {
106+
rmSync(dir, { recursive: true, force: true })
107+
}
108+
})
109+
110+
it('returns null when all paths are missing', () => {
111+
assert.equal(
112+
taskRepo.readTaskOutput({ output: '/nope/a.md, /nope/b.md' }),
113+
null
114+
)
115+
})
116+
117+
it('treats non-path strings as inline content (legacy)', () => {
118+
const content = taskRepo.readTaskOutput({ output: 'PM Review 通过: commit abc — tests OK' })
119+
assert.equal(content, 'PM Review 通过: commit abc — tests OK')
120+
})
121+
})
44122
})

tests/core/task/auto-transition.test.cjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,36 @@ describe('AutoTransition', () => {
227227
assert.equal(result[1].summary, '写第二章')
228228
assert.equal(result[1].projectId, undefined)
229229
})
230+
231+
it('filters chief prose that signals "no assignment" (regression: fake task pollution)', () => {
232+
// Real chief responses observed in production death-spiral cycles.
233+
// Each of these previously produced a zombie task named after the prose.
234+
const cases = [
235+
'[任务分配]\n- apple-tester: 本轮不新增分配,继续执行先收口、后扩张',
236+
'[任务分配]\n- apple-release: 当前无新增紧急待办且历史无响应风险仍在,维持低并发不分配新任务',
237+
'[任务分配]\n- ios-developer: 无需分配(遵循先完成再开始)',
238+
'[任务分配]\n- apple-designer: 无需分配(🔵 工作中,且已有 2 个进行中任务,严格不加并发)',
239+
'[任务分配]\n- apple-tester: 继续收口 hot-topics 需求分析链路中的待确认项,引用现有任务,不新建',
240+
]
241+
for (const text of cases) {
242+
const result = parseTaskAssignments(text)
243+
assert.equal(result.length, 0, `should skip prose: ${text.split('\n')[1]}`)
244+
}
245+
})
246+
247+
it('still accepts real assignments with action verbs', () => {
248+
const cases = [
249+
['[任务分配]\n- apple-designer: 分配需求文档体验补强包 [project: apple-dev/hot-topics]', '分配需求文档体验补强包'],
250+
['[任务分配]\n- ios-developer: 实现登录页面', '实现登录页面'],
251+
['[任务分配]\n- apple-tester: 编写 CI 测试用例', '编写 CI 测试用例'],
252+
['[任务分配]\n- apple-release: review 发布检查清单', 'review 发布检查清单'],
253+
]
254+
for (const [text, expectedSummary] of cases) {
255+
const result = parseTaskAssignments(text)
256+
assert.equal(result.length, 1, `should parse: ${text}`)
257+
assert.equal(result[0].summary, expectedSummary)
258+
}
259+
})
230260
})
231261

232262
describe('parseTaskRecoveries', () => {

0 commit comments

Comments
 (0)