Skip to content

Commit e409e65

Browse files
shuanbaoclaude
andcommitted
feat: auto-transition task statuses based on agent idle detection and chief reports
Three-layer auto-transition: chief reports completion via [任务完成] section, idle detection (10min → completed, 30min+low progress → failed), and global sweep on startup for historical stale tasks. Covers all non-terminal states (in_progress, rework, assigned, review) so every task eventually reaches completed or failed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 80e2ff7 commit e409e65

4 files changed

Lines changed: 210 additions & 8 deletions

File tree

scripts/autopilot/constants.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ const MAX_DOMAIN_KNOWLEDGE_CHARS = 3000 // Max chars for domain knowledge f
4848
const SESSION_RESET_INPUT_TOKENS = 80000 // Reset session when inputTokens exceeds this
4949
const SESSION_FORCE_COMPACT_TOKENS = 50000 // Force compact when inputTokens exceeds this
5050

51+
// Task auto-transition thresholds
52+
const IDLE_COMPLETE_MINS = 10 // Agent idle N minutes → in_progress task auto-completed
53+
const STALE_TASK_MINS = 30 // Agent idle N minutes + low progress → task failed
54+
5155
// Chief response validation
5256
const MIN_EFFECTIVE_RESPONSE_LENGTH = 50 // Below this char count = ineffective response
5357
const MAX_CONSECUTIVE_FAILURES = 3 // Trigger fallback dispatch after N consecutive failures
@@ -86,6 +90,8 @@ module.exports = {
8690
MAX_DOMAIN_KNOWLEDGE_CHARS,
8791
SESSION_RESET_INPUT_TOKENS,
8892
SESSION_FORCE_COMPACT_TOKENS,
93+
IDLE_COMPLETE_MINS,
94+
STALE_TASK_MINS,
8995
MIN_EFFECTIVE_RESPONSE_LENGTH,
9096
MAX_CONSECUTIVE_FAILURES,
9197
}

scripts/autopilot/department-loop.cjs

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ const {
1414
COMPACT_TOKEN_RATIO, DEFAULT_CONTEXT_TOKENS, HEALTH_CHECK_INTERVAL,
1515
SESSIONS_DIR, SESSION_RESET_INPUT_TOKENS, SESSION_FORCE_COMPACT_TOKENS,
1616
MIN_EFFECTIVE_RESPONSE_LENGTH, MAX_CONSECUTIVE_FAILURES,
17+
IDLE_COMPLETE_MINS, STALE_TASK_MINS,
1718
} = require('./constants.cjs')
18-
const { loadDeptConfig, loadDeptState, saveDeptState, getSessionTokenInfo, readAgentActivity } = require('./readers.cjs')
19+
const { loadDeptConfig, loadDeptState, saveDeptState, getSessionTokenInfo, readAgentActivity, readProjectTasks, readStandaloneTasks } = require('./readers.cjs')
1920
const { sendToAgent, compactSession, killSession } = require('./gateway.cjs')
2021
const { buildDepartmentDirective } = require('./dept-directive.cjs')
2122
const { compressMemoryByRole } = require('./memory.cjs')
@@ -56,6 +57,136 @@ function parseTaskAssignments(text) {
5657
return assignments
5758
}
5859

60+
/**
61+
* Parse task completions from the chief's structured response.
62+
* Extracts [任务完成] section (priority) and [进展汇报] section,
63+
* looking for task IDs with completion keywords.
64+
*
65+
* @param {string} text - Chief's response text
66+
* @returns {string[]} - Deduplicated list of completed task IDs
67+
*/
68+
function parseTaskCompletions(text) {
69+
if (!text) return []
70+
71+
const completedIds = new Set()
72+
const completionKeywords = /|||done|finished|completed|100%/i
73+
74+
// Parse [任务完成] section (priority)
75+
const completionMatch = text.match(/\[\]\s*\n([\s\S]*?)(?=\n\[|$)/)
76+
if (completionMatch) {
77+
const lines = completionMatch[1].split('\n')
78+
for (const line of lines) {
79+
const m = line.match(/(task-[a-z0-9-]+)/i)
80+
if (m && !//.test(line.trim())) {
81+
completedIds.add(m[1])
82+
}
83+
}
84+
}
85+
86+
// Parse [进展汇报] section for completion keywords
87+
const progressMatch = text.match(/\[\]\s*\n([\s\S]*?)(?=\n\[|$)/)
88+
if (progressMatch) {
89+
const lines = progressMatch[1].split('\n')
90+
for (const line of lines) {
91+
const m = line.match(/(task-[a-z0-9-]+)/i)
92+
if (m && completionKeywords.test(line)) {
93+
completedIds.add(m[1])
94+
}
95+
}
96+
}
97+
98+
return [...completedIds]
99+
}
100+
101+
/**
102+
* Auto-transition tasks based on agent activity and chief reports.
103+
*
104+
* Rules:
105+
* - Chief reports task completed → completed
106+
* - in_progress/rework + agent idle >= IDLE_COMPLETE_MINS → completed
107+
* - in_progress/rework + agent idle >= STALE_TASK_MINS + progress < 50 → failed
108+
* - assigned + agent active (idle < 5) → in_progress
109+
* - assigned + agent idle >= STALE_TASK_MINS → failed (never picked up)
110+
* - review + idle >= IDLE_COMPLETE_MINS → completed (auto-approve)
111+
*
112+
* @param {string} deptId
113+
* @param {object} config - Department config
114+
* @param {string} chiefResponseText - Chief's response text
115+
*/
116+
async function autoTransitionTasks(deptId, config, chiefResponseText) {
117+
const agentActivity = readAgentActivity()
118+
const agents = config.agents || []
119+
120+
// Collect all tasks assigned to department agents
121+
const projects = readProjectTasks()
122+
const allTasks = []
123+
for (const proj of projects) {
124+
for (const t of (proj.tasks || [])) {
125+
const assignees = [t.assignedAgent, ...(t.assignees || [])]
126+
if (assignees.some(a => agents.includes(a))) {
127+
allTasks.push(t)
128+
}
129+
}
130+
}
131+
const standalone = readStandaloneTasks()
132+
for (const t of standalone) {
133+
const assignees = [t.assignedAgent, ...(t.assignees || [])]
134+
if (assignees.some(a => agents.includes(a))) {
135+
allTasks.push(t)
136+
}
137+
}
138+
139+
if (allTasks.length === 0) return
140+
141+
// 1. Chief-reported completions
142+
const chiefCompletions = parseTaskCompletions(chiefResponseText)
143+
for (const taskId of chiefCompletions) {
144+
const task = allTasks.find(t => t.id === taskId)
145+
if (task && (task.status === 'in_progress' || task.status === 'rework')) {
146+
const assignee = task.assignedAgent || (task.assignees && task.assignees[0])
147+
if (assignee) updateTaskStatus(assignee, taskId, 'completed')
148+
logger.info('dept-loop', `Chief reported task ${taskId} completed in ${deptId}`)
149+
}
150+
}
151+
152+
// 2. Idle-based auto-complete / stale cleanup
153+
for (const task of allTasks) {
154+
if (!['in_progress', 'rework', 'assigned', 'review'].includes(task.status)) continue
155+
if (chiefCompletions.includes(task.id)) continue
156+
157+
const assignee = task.assignedAgent || (task.assignees && task.assignees[0])
158+
if (!assignee) continue
159+
const activity = agentActivity[assignee]
160+
const idleMins = activity ? activity.idleMins : 9999
161+
162+
if (task.status === 'assigned') {
163+
if (idleMins < 5) {
164+
// Agent active → promote to in_progress
165+
updateTaskStatus(assignee, task.id, 'in_progress')
166+
logger.debug('dept-loop', `Auto-promoted assigned task ${task.id} to in_progress`)
167+
} else if (idleMins >= STALE_TASK_MINS) {
168+
// Agent never picked it up → failed
169+
updateTaskStatus(assignee, task.id, 'failed')
170+
logger.warn('dept-loop', `Assigned task ${task.id} never started, marked failed (agent ${assignee} idle ${idleMins}m)`)
171+
}
172+
} else if (task.status === 'review') {
173+
if (idleMins >= IDLE_COMPLETE_MINS) {
174+
// No one reviewed → auto-approve
175+
updateTaskStatus(assignee, task.id, 'completed')
176+
logger.info('dept-loop', `Review task ${task.id} auto-approved (idle ${idleMins}m)`)
177+
}
178+
} else if (task.status === 'in_progress' || task.status === 'rework') {
179+
if (idleMins >= STALE_TASK_MINS && (task.progress || 0) < 50) {
180+
updateTaskStatus(assignee, task.id, 'failed')
181+
logger.warn('dept-loop', `Stale task ${task.id} marked failed (idle ${idleMins}m, progress ${task.progress || 0}%)`)
182+
} else if (idleMins >= IDLE_COMPLETE_MINS) {
183+
updateTaskStatus(assignee, task.id, 'completed')
184+
logger.info('dept-loop', `Auto-completed task ${task.id} (agent ${assignee} idle ${idleMins}m)`)
185+
}
186+
}
187+
}
188+
}
189+
59190
/**
60191
* Run a single department cycle.
61192
*
@@ -139,6 +270,11 @@ async function runDepartmentCycle(deptId) {
139270
})
140271
}
141272

273+
// ── Auto-transition stale tasks ──
274+
autoTransitionTasks(deptId, config, result.text).catch(e =>
275+
logger.debug('dept-loop', `Auto-transition error for ${deptId}`, e)
276+
)
277+
142278
// ── Response validation: token check ──
143279
const responseLength = (result.text || '').trim().length
144280
const isEffective = responseLength >= MIN_EFFECTIVE_RESPONSE_LENGTH
@@ -234,6 +370,10 @@ async function runDepartmentCycle(deptId) {
234370
return { ok: true, text: result.text }
235371
} else {
236372
logger.error('dept-loop', `Department ${deptId} cycle failed: ${result.error}`)
373+
// Still run idle-based auto-transition even when chief fails
374+
autoTransitionTasks(deptId, config, '').catch(e =>
375+
logger.debug('dept-loop', `Auto-transition error for ${deptId} (on failure path)`, e)
376+
)
237377
state.consecutiveFailures = (state.consecutiveFailures || 0) + 1
238378
state.status = 'error'
239379
state.lastCycleResult = `Error: ${result.error}`
@@ -243,6 +383,8 @@ async function runDepartmentCycle(deptId) {
243383
}
244384
} catch (err) {
245385
logger.error('dept-loop', `Department ${deptId} cycle error`, err)
386+
// Still run idle-based auto-transition even on exception
387+
autoTransitionTasks(deptId, config, '').catch(() => {})
246388
state.consecutiveFailures = (state.consecutiveFailures || 0) + 1
247389
state.status = 'error'
248390
state.lastCycleResult = `Error: ${err.message}`
@@ -584,4 +726,4 @@ if (require.main === module) {
584726
})
585727
}
586728

587-
module.exports = { runDepartmentCycle, generateDepartmentReport, ensureSessionHealth, fallbackDispatch, parseTaskAssignments }
729+
module.exports = { runDepartmentCycle, generateDepartmentReport, ensureSessionHealth, fallbackDispatch, parseTaskAssignments, parseTaskCompletions, autoTransitionTasks }

scripts/autopilot/dept-directive.cjs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,10 @@ peer-send 消息中引用任务 ID:\`[Task: task-xxx] 具体指令...\`
204204
205205
### 其他行动
206206
1. **检查进行中任务的产出质量** — 确保输出符合标准
207-
2. **向 CEO 汇报关键进展** — 将重要信息写入部门报告
208-
3. **更新你的 MEMORY.md** — 记录本轮做了什么
209-
4. **如果部门方向、工作重点发生变化,更新部门使命文件** — 写入 config/departments/${deptId}/mission.md
207+
2. **检查已完成的任务** — 如果有 agent 已经完成了某个任务,在输出的 \`[任务完成]\` section 中明确列出 task ID
208+
3. **向 CEO 汇报关键进展** — 将重要信息写入部门报告
209+
4. **更新你的 MEMORY.md** — 记录本轮做了什么
210+
5. **如果部门方向、工作重点发生变化,更新部门使命文件** — 写入 config/departments/${deptId}/mission.md
210211
211212
## 行动原则
212213
- **空闲 agent 必须有事做** — 发现空闲 agent 不分配任务是严重失职
@@ -219,6 +220,8 @@ peer-send 消息中引用任务 ID:\`[Task: task-xxx] 具体指令...\`
219220
\`\`\`
220221
[任务分配]
221222
- <agent-id>: <分配的任务摘要> (peer-send 已发送/无需分配)
223+
[任务完成]
224+
- <task-id>: <完成情况> 或 "无"
222225
[进展汇报]
223226
- <关键进展>
224227
[阻塞项]

scripts/autopilot/index.cjs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@ const {
1515
DEFAULT_INTERVAL_SEC, MAX_HISTORY_ENTRIES, MAX_CYCLE_RESULT_LENGTH, MAX_HISTORY_RESULT_LENGTH,
1616
DEPARTMENTS_DIR, AGENTS_DIR,
1717
CEO_COORDINATION_INTERVAL_SEC, CEO_STRATEGY_INTERVAL_SEC, DEFAULT_DEPT_INTERVAL_SEC,
18+
IDLE_COMPLETE_MINS, STALE_TASK_MINS,
1819
} = require('./constants.cjs')
1920
const { loadState, saveState } = require('./state.cjs')
2021
const { sendToCeo } = require('./gateway.cjs')
21-
const { fetchSessionTokens } = require('./readers.cjs')
22+
const { fetchSessionTokens, readProjectTasks, readStandaloneTasks, readAgentActivity } = require('./readers.cjs')
2223
const { buildDirective } = require('./directive.cjs')
2324
const { syncProjects } = require('./sync.cjs')
2425
const { buildMemoryContext, compressMemory } = require('./memory.cjs')
25-
const { runDepartmentCycle } = require('./department-loop.cjs')
26-
const { createCycleTask, completeCycleTask } = require('./task-bridge.cjs')
26+
const { runDepartmentCycle, autoTransitionTasks } = require('./department-loop.cjs')
27+
const { createCycleTask, completeCycleTask, updateTaskStatus } = require('./task-bridge.cjs')
2728
const logger = require('./logger.cjs')
2829

2930
const MAX_HISTORY = 50
@@ -334,6 +335,51 @@ async function runCeoCycleForAll(cycleType = 'coordination') {
334335
}
335336
}
336337

338+
// ── Global task sweep: clean up stale tasks across ALL agents ────
339+
async function sweepStaleTasks() {
340+
const agentActivity = readAgentActivity()
341+
342+
// Gather all non-terminal tasks from projects + standalone
343+
const allTasks = []
344+
for (const proj of readProjectTasks()) {
345+
for (const t of (proj.tasks || [])) allTasks.push(t)
346+
}
347+
for (const t of readStandaloneTasks()) allTasks.push(t)
348+
349+
const activeStatuses = ['in_progress', 'rework', 'assigned', 'review']
350+
const staleTasks = allTasks.filter(t => activeStatuses.includes(t.status))
351+
if (staleTasks.length === 0) return
352+
353+
let transitioned = 0
354+
for (const task of staleTasks) {
355+
const assignee = task.assignedAgent || (task.assignees && task.assignees[0])
356+
if (!assignee) continue
357+
const activity = agentActivity[assignee]
358+
const idleMins = activity ? activity.idleMins : 9999
359+
360+
let newStatus = null
361+
if (task.status === 'assigned' && idleMins >= STALE_TASK_MINS) {
362+
newStatus = 'failed'
363+
} else if (task.status === 'review' && idleMins >= IDLE_COMPLETE_MINS) {
364+
newStatus = 'completed'
365+
} else if ((task.status === 'in_progress' || task.status === 'rework') && idleMins >= STALE_TASK_MINS && (task.progress || 0) < 50) {
366+
newStatus = 'failed'
367+
} else if ((task.status === 'in_progress' || task.status === 'rework') && idleMins >= IDLE_COMPLETE_MINS) {
368+
newStatus = 'completed'
369+
}
370+
371+
if (newStatus) {
372+
updateTaskStatus(assignee, task.id, newStatus)
373+
transitioned++
374+
logger.info('main', `Sweep: task ${task.id} (${task.status}) → ${newStatus} (agent ${assignee} idle ${idleMins}m)`)
375+
}
376+
}
377+
378+
if (transitioned > 0) {
379+
logger.info('main', `Sweep completed: ${transitioned} stale tasks transitioned`)
380+
}
381+
}
382+
337383
// ── Start all: CEO cycles + department cycles ───────────────────
338384
async function startAll() {
339385
await killExistingAutopilot()
@@ -358,6 +404,11 @@ async function startAll() {
358404
process.on('SIGTERM', shutdown)
359405
process.on('SIGINT', shutdown)
360406

407+
// 0. Sweep stale tasks from previous runs
408+
await sweepStaleTasks().catch(e =>
409+
logger.warn('main', 'Stale task sweep failed', e)
410+
)
411+
361412
// 1. Run initial CEO coordination cycle
362413
await runCeoCycleForAll('coordination')
363414

0 commit comments

Comments
 (0)