@kkelly-offical/kkcode 0.1.3 → 0.1.7

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.
Files changed (66) hide show
  1. package/README.md +110 -172
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +220 -170
  4. package/src/agent/prompt/bug-hunter.txt +90 -0
  5. package/src/agent/prompt/frontend-designer.txt +58 -0
  6. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  7. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  8. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  9. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  10. package/src/config/defaults.mjs +260 -195
  11. package/src/config/schema.mjs +71 -6
  12. package/src/core/constants.mjs +91 -46
  13. package/src/index.mjs +1 -1
  14. package/src/knowledge/frontend-aesthetics.txt +39 -0
  15. package/src/knowledge/loader.mjs +2 -1
  16. package/src/knowledge/tailwind.txt +12 -3
  17. package/src/mcp/client-http.mjs +141 -157
  18. package/src/mcp/client-sse.mjs +288 -286
  19. package/src/mcp/client-stdio.mjs +533 -451
  20. package/src/mcp/constants.mjs +2 -0
  21. package/src/mcp/registry.mjs +479 -394
  22. package/src/mcp/stdio-framing.mjs +133 -127
  23. package/src/mcp/tool-result.mjs +24 -0
  24. package/src/observability/index.mjs +42 -0
  25. package/src/observability/metrics.mjs +137 -0
  26. package/src/observability/tracer.mjs +137 -0
  27. package/src/orchestration/background-manager.mjs +372 -358
  28. package/src/orchestration/background-worker.mjs +305 -245
  29. package/src/orchestration/longagent-manager.mjs +171 -116
  30. package/src/orchestration/stage-scheduler.mjs +728 -489
  31. package/src/permission/exec-policy.mjs +9 -11
  32. package/src/provider/anthropic.mjs +1 -0
  33. package/src/provider/openai.mjs +340 -339
  34. package/src/provider/retry-policy.mjs +68 -68
  35. package/src/provider/router.mjs +241 -228
  36. package/src/provider/sse.mjs +104 -91
  37. package/src/repl.mjs +59 -7
  38. package/src/session/checkpoint.mjs +66 -3
  39. package/src/session/compaction.mjs +298 -276
  40. package/src/session/engine.mjs +232 -225
  41. package/src/session/longagent-4stage.mjs +460 -0
  42. package/src/session/longagent-hybrid.mjs +1097 -0
  43. package/src/session/longagent-plan.mjs +365 -329
  44. package/src/session/longagent-project-memory.mjs +53 -0
  45. package/src/session/longagent-scaffold.mjs +291 -100
  46. package/src/session/longagent-task-bus.mjs +54 -0
  47. package/src/session/longagent-utils.mjs +472 -0
  48. package/src/session/longagent.mjs +900 -1462
  49. package/src/session/loop.mjs +65 -40
  50. package/src/session/project-context.mjs +30 -0
  51. package/src/session/prompt/agent.txt +25 -0
  52. package/src/session/prompt/plan.txt +31 -9
  53. package/src/session/rollback.mjs +196 -0
  54. package/src/session/store.mjs +519 -503
  55. package/src/session/system-prompt.mjs +273 -260
  56. package/src/session/task-validator.mjs +4 -3
  57. package/src/skill/builtin/design.mjs +76 -0
  58. package/src/skill/builtin/frontend.mjs +8 -0
  59. package/src/skill/registry.mjs +390 -336
  60. package/src/storage/ghost-commit-store.mjs +18 -8
  61. package/src/tool/executor.mjs +11 -0
  62. package/src/tool/git-auto.mjs +0 -19
  63. package/src/tool/question-prompt.mjs +93 -86
  64. package/src/tool/registry.mjs +71 -37
  65. package/src/ui/activity-renderer.mjs +664 -410
  66. package/src/util/git.mjs +23 -0
@@ -1,489 +1,728 @@
1
- import { BackgroundManager } from "./background-manager.mjs"
2
- import { EventBus } from "../core/events.mjs"
3
- import { EVENT_TYPES } from "../core/constants.mjs"
4
- import { getAgent } from "../agent/agent.mjs"
5
-
6
- const AGENT_HINTS = [
7
- { pattern: /\b(test|spec|jest|mocha|vitest|coverage)\b/i, agent: "tdd-guide" },
8
- { pattern: /\b(review|audit|lint|quality)\b/i, agent: "reviewer" },
9
- { pattern: /\b(secur|vuln|owasp|xss|inject|auth)\b/i, agent: "security-reviewer" },
10
- { pattern: /\b(architect|design|blueprint|interface|api.*design)\b/i, agent: "architect" },
11
- { pattern: /\b(build.*fix|compile.*error|type.*error|syntax.*error)\b/i, agent: "build-fixer" }
12
- ]
13
-
14
- function inferSubagentType(taskPrompt, taskId) {
15
- const text = `${taskPrompt} ${taskId}`
16
- for (const { pattern, agent } of AGENT_HINTS) {
17
- if (pattern.test(text) && getAgent(agent)) return agent
18
- }
19
- return null
20
- }
21
-
22
- function sleep(ms) {
23
- return new Promise((resolve) => setTimeout(resolve, ms))
24
- }
25
-
26
- function normalizeFiles(list) {
27
- if (!Array.isArray(list)) return []
28
- return [...new Set(list.map((item) => String(item || "").trim()).filter(Boolean))]
29
- }
30
-
31
- function mergeUnique(...lists) {
32
- const merged = []
33
- for (const list of lists) {
34
- if (!Array.isArray(list)) continue
35
- merged.push(...list)
36
- }
37
- return [...new Set(merged)]
38
- }
39
-
40
- function normalizeFileChanges(list) {
41
- if (!Array.isArray(list)) return []
42
- return list
43
- .map((item) => ({
44
- path: String(item?.path || "").trim(),
45
- addedLines: Math.max(0, Number(item?.addedLines || 0)),
46
- removedLines: Math.max(0, Number(item?.removedLines || 0)),
47
- stageId: item?.stageId ? String(item.stageId) : "",
48
- taskId: item?.taskId ? String(item.taskId) : ""
49
- }))
50
- .filter((item) => item.path)
51
- }
52
-
53
- function mergeFileChanges(...lists) {
54
- const map = new Map()
55
- for (const list of lists) {
56
- for (const item of normalizeFileChanges(list)) {
57
- const key = `${item.path}::${item.stageId}::${item.taskId}`
58
- const prev = map.get(key) || { ...item, addedLines: 0, removedLines: 0 }
59
- prev.addedLines += item.addedLines
60
- prev.removedLines += item.removedLines
61
- map.set(key, prev)
62
- }
63
- }
64
- return [...map.values()]
65
- }
66
-
67
- function computeRemaining(planned = [], completed = []) {
68
- const done = new Set(normalizeFiles(completed))
69
- return normalizeFiles(planned).filter((file) => !done.has(file))
70
- }
71
-
72
- function stageConfig(config = {}) {
73
- const parallel = config.agent?.longagent?.parallel || {}
74
- return {
75
- maxConcurrency: Math.max(1, Number(parallel.max_concurrency || 3)),
76
- taskTimeoutMs: Math.max(1000, Number(parallel.task_timeout_ms || 600000)),
77
- taskMaxRetries: Math.max(0, Number(parallel.task_max_retries ?? 2)),
78
- budgetLimitUsd: Number(parallel.budget_limit_usd || 0),
79
- passRule: "all_success"
80
- }
81
- }
82
-
83
- function retryPrompt(taskPrompt, remainingFiles = [], attempt = 1, lastError = "") {
84
- const parts = [
85
- taskPrompt,
86
- "",
87
- `Retry attempt: ${attempt}`,
88
- "Continue from previous progress. Focus ONLY on remaining files."
89
- ]
90
- if (remainingFiles.length) {
91
- parts.push(`Remaining files: ${remainingFiles.join(", ")}`)
92
- }
93
- if (lastError) {
94
- parts.push(`Previous failure: ${lastError}`)
95
- }
96
- return parts.join("\n")
97
- }
98
-
99
- function buildEnrichedPrompt({ stage, task, logicalTask, objective, stageIndex, stageCount, allTasks, priorContext }) {
100
- const parts = []
101
-
102
- parts.push("## Global Objective")
103
- parts.push(objective || "(not specified)")
104
- parts.push("")
105
-
106
- if (priorContext) {
107
- parts.push("## Prior Stage Results")
108
- parts.push(priorContext)
109
- parts.push("")
110
- }
111
-
112
- parts.push("## Current Stage")
113
- parts.push(`Stage ${stageIndex + 1}/${stageCount}: ${stage.name || stage.stageId}`)
114
- parts.push("")
115
-
116
- parts.push("## Your Task")
117
- parts.push(logicalTask.prompt)
118
- parts.push("")
119
-
120
- if (logicalTask.plannedFiles.length > 0) {
121
- parts.push("## Files You Own (ONLY modify these)")
122
- for (const file of logicalTask.plannedFiles) {
123
- parts.push(`- ${file}`)
124
- }
125
- parts.push("")
126
- }
127
-
128
- const siblings = (allTasks || []).filter((t) => t.taskId !== task.taskId)
129
- if (siblings.length > 0) {
130
- parts.push("## Other Tasks in This Stage (DO NOT touch their files)")
131
- for (const sibling of siblings) {
132
- const files = normalizeFiles(sibling.plannedFiles)
133
- parts.push(`- ${sibling.taskId}: ${files.length > 0 ? files.join(", ") : "(no files)"}`)
134
- }
135
- parts.push("")
136
- }
137
-
138
- if (logicalTask.acceptance.length > 0) {
139
- parts.push("## Acceptance Criteria")
140
- for (const criterion of logicalTask.acceptance) {
141
- parts.push(`- ${criterion}`)
142
- }
143
- parts.push("")
144
- }
145
-
146
- return parts.join("\n")
147
- }
148
-
149
- function checkFileIsolation(tasks) {
150
- const ownership = new Map()
151
- const overlaps = []
152
- for (const task of tasks) {
153
- for (const file of normalizeFiles(task.plannedFiles)) {
154
- if (ownership.has(file)) {
155
- overlaps.push({ file, tasks: [ownership.get(file), task.taskId] })
156
- } else {
157
- ownership.set(file, task.taskId)
158
- }
159
- }
160
- }
161
- return overlaps
162
- }
163
-
164
- async function launchTask({
165
- stage,
166
- task,
167
- logicalTask,
168
- config,
169
- sessionId,
170
- model,
171
- providerType,
172
- objective,
173
- stageIndex,
174
- stageCount,
175
- allTasks,
176
- priorContext
177
- }) {
178
- const enrichedPrompt = buildEnrichedPrompt({
179
- stage,
180
- task,
181
- logicalTask,
182
- objective,
183
- stageIndex: stageIndex || 0,
184
- stageCount: stageCount || 1,
185
- allTasks,
186
- priorContext
187
- })
188
-
189
- const autoAgent = !task.subagentType ? inferSubagentType(logicalTask.prompt, task.taskId) : null
190
-
191
- const payload = {
192
- parentSessionId: sessionId,
193
- subSessionId: logicalTask.subSessionId,
194
- prompt: enrichedPrompt,
195
- cwd: process.cwd(),
196
- model,
197
- providerType,
198
- subagent: task.subagentType || autoAgent || null,
199
- category: task.category || null,
200
- subagentType: task.subagentType || autoAgent || null,
201
- stageId: stage.stageId,
202
- logicalTaskId: task.taskId,
203
- plannedFiles: logicalTask.plannedFiles,
204
- remainingFiles: logicalTask.remainingFiles,
205
- attempt: logicalTask.attempt,
206
- workerTimeoutMs: logicalTask.timeoutMs
207
- }
208
-
209
- const taskDescription = `${stage.stageId}:${task.taskId}#${logicalTask.attempt}`
210
- const bg = await BackgroundManager.launchDelegateTask({
211
- description: taskDescription,
212
- payload,
213
- config: {
214
- ...config,
215
- background: {
216
- ...(config.background || {}),
217
- max_parallel: Math.max(
218
- Number(config.background?.max_parallel || 1),
219
- Number(config.agent?.longagent?.parallel?.max_concurrency || 3)
220
- )
221
- }
222
- }
223
- })
224
-
225
- await EventBus.emit({
226
- type: EVENT_TYPES.LONGAGENT_STAGE_TASK_DISPATCHED,
227
- sessionId,
228
- payload: {
229
- stageId: stage.stageId,
230
- taskId: task.taskId,
231
- backgroundTaskId: bg.id,
232
- attempt: logicalTask.attempt
233
- }
234
- })
235
-
236
- return bg.id
237
- }
238
-
239
- export async function runStageBarrier({
240
- stage,
241
- sessionId,
242
- config,
243
- model,
244
- providerType,
245
- seedTaskProgress = {},
246
- objective = "",
247
- stageIndex = 0,
248
- stageCount = 1,
249
- priorContext = ""
250
- }) {
251
- const cfg = stageConfig(config)
252
- const logical = new Map()
253
-
254
- // File isolation check: overlapping files = plan bug, fail-fast
255
- const overlaps = checkFileIsolation(stage.tasks || [])
256
- if (overlaps.length > 0) {
257
- const details = overlaps.map((o) => `"${o.file}" claimed by [${o.tasks.join(", ")}]`).join("; ")
258
- await EventBus.emit({
259
- type: EVENT_TYPES.LONGAGENT_STAGE_STARTED,
260
- sessionId,
261
- payload: { error: `File isolation violation in stage ${stage.stageId}: ${details}`, stageId: stage.stageId }
262
- })
263
- throw new Error(`Stage ${stage.stageId}: file isolation violation — ${details}. Fix the plan to avoid overlapping file ownership.`)
264
- }
265
-
266
- for (const task of stage.tasks || []) {
267
- const seeded = seedTaskProgress[task.taskId] || {}
268
- const planned = normalizeFiles(task.plannedFiles)
269
- const completed = normalizeFiles(seeded.completedFiles || [])
270
- const remaining = normalizeFiles(seeded.remainingFiles || computeRemaining(planned, completed))
271
- logical.set(task.taskId, {
272
- stageId: stage.stageId,
273
- taskId: task.taskId,
274
- subSessionId: seeded.subSessionId || `sub_${sessionId}_${task.taskId}`,
275
- plannedFiles: planned,
276
- completedFiles: completed,
277
- remainingFiles: remaining,
278
- acceptance: Array.isArray(task.acceptance) ? task.acceptance : [],
279
- prompt: seeded.prompt || task.prompt,
280
- status: seeded.status || "pending",
281
- attempt: Number(seeded.attempt || 0),
282
- maxRetries: Number(task.maxRetries ?? cfg.taskMaxRetries),
283
- timeoutMs: Number(task.timeoutMs || cfg.taskTimeoutMs),
284
- backgroundTaskId: null,
285
- lastError: seeded.lastError || "",
286
- fileChanges: normalizeFileChanges(seeded.fileChanges || [])
287
- })
288
- }
289
-
290
- await EventBus.emit({
291
- type: EVENT_TYPES.LONGAGENT_STAGE_STARTED,
292
- sessionId,
293
- payload: {
294
- stageId: stage.stageId,
295
- taskCount: logical.size,
296
- passRule: cfg.passRule
297
- }
298
- })
299
-
300
- while (true) {
301
- await BackgroundManager.tick({
302
- ...config,
303
- background: {
304
- ...(config.background || {}),
305
- max_parallel: Math.max(
306
- Number(config.background?.max_parallel || 1),
307
- cfg.maxConcurrency
308
- )
309
- }
310
- })
311
-
312
- let activeCount = [...logical.values()].filter((item) => item.status === "running").length
313
- if (activeCount < cfg.maxConcurrency) {
314
- const toLaunch = []
315
- for (const task of stage.tasks || []) {
316
- const item = logical.get(task.taskId)
317
- if (!item || item.backgroundTaskId) continue
318
- if (!["pending", "retrying"].includes(item.status)) continue
319
- if (activeCount + toLaunch.length >= cfg.maxConcurrency) break
320
- item.attempt += 1
321
- item.status = "running"
322
- if (item.attempt > 1) {
323
- item.prompt = retryPrompt(task.prompt, item.remainingFiles, item.attempt, item.lastError)
324
- }
325
- toLaunch.push({ task, item })
326
- }
327
- if (toLaunch.length > 0) {
328
- const bgIds = await Promise.all(toLaunch.map(({ task, item }) =>
329
- launchTask({ stage, task, logicalTask: item, config, sessionId, model, providerType, objective, stageIndex, stageCount, allTasks: stage.tasks || [], priorContext })
330
- ))
331
- for (let i = 0; i < toLaunch.length; i++) {
332
- toLaunch[i].item.backgroundTaskId = bgIds[i]
333
- }
334
- }
335
- }
336
-
337
- let pending = 0
338
- for (const item of logical.values()) {
339
- if (!item.backgroundTaskId) {
340
- if (["pending", "retrying", "running"].includes(item.status)) pending += 1
341
- continue
342
- }
343
- const bg = await BackgroundManager.get(item.backgroundTaskId)
344
- if (!bg) {
345
- item.status = "error"
346
- item.lastError = "background worker disappeared"
347
- item.backgroundTaskId = null
348
- continue
349
- }
350
- if (!["completed", "error", "interrupted", "cancelled"].includes(bg.status)) {
351
- pending += 1
352
- continue
353
- }
354
-
355
- const result = bg.result || {}
356
- const completedFromResult = mergeUnique(
357
- item.completedFiles,
358
- normalizeFiles(result.completed_files || result.completedFiles || [])
359
- )
360
- const remainingFromResult = normalizeFiles(
361
- result.remaining_files || result.remainingFiles || computeRemaining(item.plannedFiles, completedFromResult)
362
- )
363
- item.completedFiles = completedFromResult
364
- item.remainingFiles = remainingFromResult
365
- item.fileChanges = mergeFileChanges(
366
- item.fileChanges,
367
- result.file_changes || result.fileChanges || []
368
- )
369
- item.backgroundTaskId = null
370
-
371
- // Runtime file ownership check: warn if task touched files outside its plan
372
- const plannedSet = new Set(item.plannedFiles)
373
- const outOfScope = item.fileChanges
374
- .map(fc => fc.path)
375
- .filter(p => p && !plannedSet.has(p))
376
- if (outOfScope.length > 0) {
377
- await EventBus.emit({
378
- type: EVENT_TYPES.LONGAGENT_ALERT,
379
- sessionId,
380
- payload: {
381
- kind: "file_ownership_violation",
382
- message: `Task ${item.taskId} modified ${outOfScope.length} file(s) outside its plan: ${outOfScope.slice(0, 5).join(", ")}`,
383
- taskId: item.taskId,
384
- stageId: stage.stageId,
385
- outOfScopeFiles: outOfScope
386
- }
387
- })
388
- }
389
-
390
- if (bg.status === "completed" && remainingFromResult.length === 0) {
391
- item.status = "completed"
392
- item.lastError = ""
393
- } else if (bg.status === "completed" && remainingFromResult.length > 0) {
394
- item.status = item.attempt <= item.maxRetries ? "retrying" : "error"
395
- item.lastError = "task completed but remaining files still pending"
396
- } else {
397
- item.lastError = bg.error || "task failed"
398
- item.status = item.attempt <= item.maxRetries ? "retrying" : (bg.status === "cancelled" ? "cancelled" : "error")
399
- }
400
- item.lastReply = String(result.reply || "")
401
- item.lastCost = Number(result.cost || 0)
402
-
403
- await EventBus.emit({
404
- type: EVENT_TYPES.LONGAGENT_STAGE_TASK_FINISHED,
405
- sessionId,
406
- payload: {
407
- stageId: stage.stageId,
408
- taskId: item.taskId,
409
- status: item.status,
410
- attempt: item.attempt,
411
- remainingFiles: item.remainingFiles
412
- }
413
- })
414
-
415
- if (["pending", "retrying", "running"].includes(item.status)) pending += 1
416
- }
417
-
418
- if (pending <= 0) break
419
-
420
- // Budget circuit breaker: abort remaining tasks if cost exceeds limit
421
- if (cfg.budgetLimitUsd > 0) {
422
- const spent = [...logical.values()].reduce((s, i) => s + (Number.isFinite(i.lastCost) ? i.lastCost : 0), 0)
423
- if (spent >= cfg.budgetLimitUsd) {
424
- for (const item of logical.values()) {
425
- if (["pending", "retrying"].includes(item.status)) {
426
- item.status = "error"
427
- item.lastError = `budget limit exceeded ($${spent.toFixed(2)} >= $${cfg.budgetLimitUsd})`
428
- }
429
- if (item.backgroundTaskId && item.status === "running") {
430
- await BackgroundManager.cancel(item.backgroundTaskId).catch(() => {})
431
- }
432
- }
433
- await EventBus.emit({
434
- type: EVENT_TYPES.LONGAGENT_ALERT,
435
- sessionId,
436
- payload: { kind: "budget_breaker", spent, limit: cfg.budgetLimitUsd, stageId: stage.stageId }
437
- })
438
- break
439
- }
440
- }
441
-
442
- await sleep(300)
443
- }
444
-
445
- const items = [...logical.values()]
446
- const successCount = items.filter((item) => item.status === "completed").length
447
- const failItems = items.filter((item) => item.status !== "completed")
448
- const retryCount = items.reduce((sum, item) => sum + Math.max(0, item.attempt - 1), 0)
449
- const remainingFiles = mergeUnique(...items.map((item) => item.remainingFiles))
450
- const completionMarkerSeen = items.some((item) => String(item.lastReply || "").toLowerCase().includes("[task_complete]"))
451
- const totalCost = items.reduce((sum, item) => sum + (Number.isFinite(item.lastCost) ? item.lastCost : 0), 0)
452
- const fileChanges = mergeFileChanges(...items.map((item) => item.fileChanges))
453
-
454
- const summary = {
455
- stageId: stage.stageId,
456
- successCount,
457
- failCount: failItems.length,
458
- retryCount,
459
- remainingFiles,
460
- completionMarkerSeen,
461
- totalCost,
462
- fileChanges,
463
- allSuccess: failItems.length === 0,
464
- taskProgress: Object.fromEntries(
465
- items.map((item) => [
466
- item.taskId,
467
- {
468
- taskId: item.taskId,
469
- attempt: item.attempt,
470
- status: item.status,
471
- plannedFiles: item.plannedFiles,
472
- completedFiles: item.completedFiles,
473
- remainingFiles: item.remainingFiles,
474
- fileChanges: item.fileChanges,
475
- lastError: item.lastError || "",
476
- lastReply: item.lastReply || ""
477
- }
478
- ])
479
- )
480
- }
481
-
482
- await EventBus.emit({
483
- type: EVENT_TYPES.LONGAGENT_STAGE_FINISHED,
484
- sessionId,
485
- payload: summary
486
- })
487
-
488
- return summary
489
- }
1
+ import { BackgroundManager } from "./background-manager.mjs"
2
+ import { EventBus } from "../core/events.mjs"
3
+ import { EVENT_TYPES } from "../core/constants.mjs"
4
+ import { getAgent } from "../agent/agent.mjs"
5
+ import { classifyError, ERROR_CATEGORIES } from "../session/longagent-utils.mjs"
6
+
7
+ // #19: Agent capability scoring — multi-pattern weighted routing
8
+ const AGENT_HINTS = [
9
+ { pattern: /\b(test|spec|jest|mocha|vitest|coverage)\b/i, agent: "tdd-guide", weight: 2 },
10
+ { pattern: /\b(review|audit|lint|quality)\b/i, agent: "reviewer", weight: 1 },
11
+ { pattern: /\b(secur|vuln|owasp|xss|inject|auth)\b/i, agent: "security-reviewer", weight: 3 },
12
+ { pattern: /\b(ui|ux|frontend|front.?end|component|page|layout|style|css|tailwind|theme|responsive|landing|dashboard)\b/i, agent: "frontend-designer", weight: 2 },
13
+ { pattern: /\b(architect|blueprint|interface|api.*design)\b/i, agent: "architect", weight: 1 },
14
+ { pattern: /\b(build.*fix|compile.*error|type.*error|syntax.*error)\b/i, agent: "build-fixer", weight: 3 }
15
+ ]
16
+
17
+ function inferSubagentType(taskPrompt, taskId) {
18
+ const text = `${taskPrompt} ${taskId}`
19
+ // Score each agent by summing weights of all matching patterns
20
+ const scores = new Map()
21
+ for (const { pattern, agent, weight } of AGENT_HINTS) {
22
+ if (pattern.test(text) && getAgent(agent)) {
23
+ scores.set(agent, (scores.get(agent) || 0) + weight)
24
+ }
25
+ }
26
+ if (scores.size === 0) return null
27
+ // Return highest-scoring agent
28
+ let best = null, bestScore = 0
29
+ for (const [agent, score] of scores) {
30
+ if (score > bestScore) { best = agent; bestScore = score }
31
+ }
32
+ return best
33
+ }
34
+
35
+ function sleep(ms) {
36
+ return new Promise((resolve) => setTimeout(resolve, ms))
37
+ }
38
+
39
+ function normalizeFiles(list) {
40
+ if (!Array.isArray(list)) return []
41
+ return [...new Set(list.map((item) => String(item || "").trim()).filter(Boolean))]
42
+ }
43
+
44
+ function mergeUnique(...lists) {
45
+ const merged = []
46
+ for (const list of lists) {
47
+ if (!Array.isArray(list)) continue
48
+ merged.push(...list)
49
+ }
50
+ return [...new Set(merged)]
51
+ }
52
+
53
+ function normalizeFileChanges(list) {
54
+ if (!Array.isArray(list)) return []
55
+ return list
56
+ .map((item) => ({
57
+ path: String(item?.path || "").trim(),
58
+ addedLines: Math.max(0, Number(item?.addedLines || 0)),
59
+ removedLines: Math.max(0, Number(item?.removedLines || 0)),
60
+ stageId: item?.stageId ? String(item.stageId) : "",
61
+ taskId: item?.taskId ? String(item.taskId) : ""
62
+ }))
63
+ .filter((item) => item.path)
64
+ }
65
+
66
+ function mergeFileChanges(...lists) {
67
+ const map = new Map()
68
+ for (const list of lists) {
69
+ for (const item of normalizeFileChanges(list)) {
70
+ const key = `${item.path}::${item.stageId}::${item.taskId}`
71
+ const prev = map.get(key) || { ...item, addedLines: 0, removedLines: 0 }
72
+ prev.addedLines += item.addedLines
73
+ prev.removedLines += item.removedLines
74
+ map.set(key, prev)
75
+ }
76
+ }
77
+ return [...map.values()]
78
+ }
79
+
80
+ function computeRemaining(planned = [], completed = []) {
81
+ const done = new Set(normalizeFiles(completed))
82
+ return normalizeFiles(planned).filter((file) => !done.has(file))
83
+ }
84
+
85
+ // #20: Runtime file lock registry — tracks which files are actively being modified
86
+ function createFileLockRegistry() {
87
+ const locks = new Map() // path → { taskId, lockedAt }
88
+ return {
89
+ tryLock(filePath, taskId) {
90
+ const existing = locks.get(filePath)
91
+ if (existing && existing.taskId !== taskId) return false
92
+ locks.set(filePath, { taskId, lockedAt: Date.now() })
93
+ return true
94
+ },
95
+ unlock(taskId) {
96
+ for (const [path, lock] of locks) {
97
+ if (lock.taskId === taskId) locks.delete(path)
98
+ }
99
+ },
100
+ getConflicts(files, taskId) {
101
+ const conflicts = []
102
+ for (const f of files) {
103
+ const lock = locks.get(f)
104
+ if (lock && lock.taskId !== taskId) {
105
+ conflicts.push({ file: f, heldBy: lock.taskId })
106
+ }
107
+ }
108
+ return conflicts
109
+ }
110
+ }
111
+ }
112
+
113
+ function stageConfig(config = {}) {
114
+ const parallel = config.agent?.longagent?.parallel || {}
115
+ return {
116
+ maxConcurrency: Math.max(1, Number(parallel.max_concurrency || 3)),
117
+ taskTimeoutMs: Math.max(1000, Number(parallel.task_timeout_ms || 600000)),
118
+ taskMaxRetries: Math.max(0, Number(parallel.task_max_retries ?? 2)),
119
+ budgetLimitUsd: Number.isFinite(Number(parallel.budget_limit_usd)) ? Number(parallel.budget_limit_usd) : 0,
120
+ pollIntervalMs: Math.max(50, Number(parallel.poll_interval_ms || 300)),
121
+ passRule: "all_success"
122
+ }
123
+ }
124
+
125
+ function retryPrompt(taskPrompt, remainingFiles = [], attempt = 1, lastError = "") {
126
+ const parts = [
127
+ taskPrompt,
128
+ "",
129
+ `Retry attempt: ${attempt}`,
130
+ "Continue from previous progress. Focus ONLY on remaining files."
131
+ ]
132
+ if (remainingFiles.length) {
133
+ parts.push(`Remaining files: ${remainingFiles.join(", ")}`)
134
+ }
135
+ if (lastError) {
136
+ parts.push(`Previous failure: ${lastError}`)
137
+ }
138
+ return parts.join("\n")
139
+ }
140
+
141
+ function buildEnrichedPrompt({ stage, task, logicalTask, objective, stageIndex, stageCount, allTasks, priorContext, taskBusContext }) {
142
+ const parts = []
143
+
144
+ parts.push("## Your Role")
145
+ parts.push("You are an IMPLEMENTATION agent. The scaffold files already contain detailed inline comments describing what to implement. Your job is to READ those comments and REPLACE them with working code.")
146
+ parts.push("")
147
+
148
+ parts.push("## Global Objective")
149
+ parts.push(objective || "(not specified)")
150
+ parts.push("")
151
+
152
+ if (priorContext) {
153
+ parts.push("## Prior Stage Results")
154
+ parts.push(priorContext)
155
+ parts.push("")
156
+ }
157
+
158
+ parts.push("## Current Stage")
159
+ parts.push(`Stage ${stageIndex + 1}/${stageCount}: ${stage.name || stage.stageId}`)
160
+ parts.push("")
161
+
162
+ parts.push("## Your Task")
163
+ parts.push(logicalTask.prompt)
164
+ parts.push("")
165
+
166
+ if (logicalTask.plannedFiles.length > 0) {
167
+ parts.push("## Files You Own (ONLY modify these)")
168
+ for (const file of logicalTask.plannedFiles) {
169
+ parts.push(`- ${file}`)
170
+ }
171
+ parts.push("")
172
+ }
173
+
174
+ const siblings = (allTasks || []).filter((t) => t.taskId !== task.taskId)
175
+ if (siblings.length > 0) {
176
+ parts.push("## Other Tasks in This Stage (DO NOT touch their files)")
177
+ for (const sibling of siblings) {
178
+ const files = normalizeFiles(sibling.plannedFiles)
179
+ parts.push(`- ${sibling.taskId}: ${files.length > 0 ? files.join(", ") : "(no files)"}`)
180
+ }
181
+ parts.push("")
182
+ }
183
+
184
+ if (logicalTask.acceptance.length > 0) {
185
+ parts.push("## Acceptance Criteria")
186
+ for (const criterion of logicalTask.acceptance) {
187
+ parts.push(`- ${criterion}`)
188
+ }
189
+ parts.push("")
190
+ }
191
+
192
+ // #17: Inject TaskBus shared context so parallel tasks see each other's broadcasts
193
+ if (taskBusContext) {
194
+ parts.push("## Shared Context (from sibling tasks)")
195
+ parts.push(taskBusContext)
196
+ parts.push("")
197
+ }
198
+
199
+ parts.push("## Workflow")
200
+ parts.push("1. READ each file you own — the inline comments are your implementation spec")
201
+ parts.push("2. IMPLEMENT by replacing comments with working code (keep the file header comment)")
202
+ parts.push("3. VERIFY with acceptance criteria (run tests, syntax checks, etc.)")
203
+ parts.push("4. Say [TASK_COMPLETE] when done")
204
+ parts.push("")
205
+
206
+ parts.push("## Tool Usage Guide")
207
+ parts.push("USE `read` first — read your scaffold files to understand the implementation spec")
208
+ parts.push("USE `edit` to replace comment blocks with real code (preferred over `write` for existing files)")
209
+ parts.push("USE `write` only for files that don't exist yet or need full rewrite")
210
+ parts.push("USE `bash` to run tests, syntax checks, or build commands from acceptance criteria")
211
+ parts.push("USE `grep`/`glob` to find imports, references, or patterns in the codebase")
212
+ parts.push("AVOID `bash` for file reading (use `read`), file editing (use `edit`), or file searching (use `grep`/`glob`)")
213
+ parts.push("AVOID modifying files outside your ownership list")
214
+
215
+ return parts.join("\n")
216
+ }
217
+
218
+ function checkFileIsolation(tasks) {
219
+ const ownership = new Map()
220
+ const overlaps = []
221
+ for (const task of tasks) {
222
+ for (const file of normalizeFiles(task.plannedFiles)) {
223
+ if (ownership.has(file)) {
224
+ overlaps.push({ file, tasks: [ownership.get(file), task.taskId] })
225
+ } else {
226
+ ownership.set(file, task.taskId)
227
+ }
228
+ }
229
+ }
230
+ return overlaps
231
+ }
232
+
233
+ function checkDependencyCycles(tasks) {
234
+ const graph = new Map()
235
+ for (const task of tasks) {
236
+ graph.set(task.taskId, Array.isArray(task.dependsOn) ? task.dependsOn : [])
237
+ }
238
+ const visited = new Set()
239
+ const inStack = new Set()
240
+ const cycles = []
241
+
242
+ function dfs(node, path) {
243
+ if (inStack.has(node)) {
244
+ cycles.push([...path, node])
245
+ return
246
+ }
247
+ if (visited.has(node)) return
248
+ visited.add(node)
249
+ inStack.add(node)
250
+ for (const dep of graph.get(node) || []) {
251
+ dfs(dep, [...path, node])
252
+ }
253
+ inStack.delete(node)
254
+ }
255
+
256
+ for (const taskId of graph.keys()) {
257
+ dfs(taskId, [])
258
+ }
259
+ return cycles
260
+ }
261
+
262
+ async function launchTask({
263
+ stage,
264
+ task,
265
+ logicalTask,
266
+ config,
267
+ sessionId,
268
+ model,
269
+ providerType,
270
+ objective,
271
+ stageIndex,
272
+ stageCount,
273
+ allTasks,
274
+ priorContext,
275
+ taskBusContext
276
+ }) {
277
+ const enrichedPrompt = buildEnrichedPrompt({
278
+ stage,
279
+ task,
280
+ logicalTask,
281
+ objective,
282
+ stageIndex: stageIndex || 0,
283
+ stageCount: stageCount || 1,
284
+ allTasks,
285
+ priorContext,
286
+ taskBusContext
287
+ })
288
+
289
+ const autoAgent = !task.subagentType ? inferSubagentType(logicalTask.prompt, task.taskId) : null
290
+
291
+ const payload = {
292
+ parentSessionId: sessionId,
293
+ subSessionId: logicalTask.subSessionId,
294
+ prompt: enrichedPrompt,
295
+ cwd: process.cwd(),
296
+ model,
297
+ providerType,
298
+ subagent: task.subagentType || autoAgent || null,
299
+ category: task.category || null,
300
+ subagentType: task.subagentType || autoAgent || null,
301
+ stageId: stage.stageId,
302
+ logicalTaskId: task.taskId,
303
+ plannedFiles: logicalTask.plannedFiles,
304
+ remainingFiles: logicalTask.remainingFiles,
305
+ attempt: logicalTask.attempt,
306
+ workerTimeoutMs: logicalTask.timeoutMs
307
+ }
308
+
309
+ const taskDescription = `${stage.stageId}:${task.taskId}#${logicalTask.attempt}`
310
+ const bg = await BackgroundManager.launchDelegateTask({
311
+ description: taskDescription,
312
+ payload,
313
+ config: {
314
+ ...config,
315
+ background: {
316
+ ...(config.background || {}),
317
+ max_parallel: Math.max(
318
+ Number(config.background?.max_parallel || 1),
319
+ Number(config.agent?.longagent?.parallel?.max_concurrency || 3)
320
+ )
321
+ }
322
+ }
323
+ })
324
+
325
+ await EventBus.emit({
326
+ type: EVENT_TYPES.LONGAGENT_STAGE_TASK_DISPATCHED,
327
+ sessionId,
328
+ payload: {
329
+ stageId: stage.stageId,
330
+ taskId: task.taskId,
331
+ backgroundTaskId: bg.id,
332
+ attempt: logicalTask.attempt
333
+ }
334
+ })
335
+
336
+ return bg.id
337
+ }
338
+
339
+ export async function runStageBarrier({
340
+ stage,
341
+ sessionId,
342
+ config,
343
+ model,
344
+ providerType,
345
+ seedTaskProgress = {},
346
+ objective = "",
347
+ stageIndex = 0,
348
+ stageCount = 1,
349
+ priorContext = "",
350
+ stuckTracker = null,
351
+ onTaskComplete = null,
352
+ taskBus = null
353
+ }) {
354
+ const cfg = stageConfig(config)
355
+ const logical = new Map()
356
+
357
+ // File isolation check: overlapping files = plan bug, fail-fast
358
+ const overlaps = checkFileIsolation(stage.tasks || [])
359
+ if (overlaps.length > 0) {
360
+ const details = overlaps.map((o) => `"${o.file}" claimed by [${o.tasks.join(", ")}]`).join("; ")
361
+ await EventBus.emit({
362
+ type: EVENT_TYPES.LONGAGENT_STAGE_STARTED,
363
+ sessionId,
364
+ payload: { error: `File isolation violation in stage ${stage.stageId}: ${details}`, stageId: stage.stageId }
365
+ })
366
+ throw new Error(`Stage ${stage.stageId}: file isolation violation — ${details}. Fix the plan to avoid overlapping file ownership.`)
367
+ }
368
+
369
+ // Dependency cycle check: circular dependsOn = deadlock, fail-fast
370
+ const cycles = checkDependencyCycles(stage.tasks || [])
371
+ if (cycles.length > 0) {
372
+ const detail = cycles[0].join(" → ")
373
+ await EventBus.emit({
374
+ type: EVENT_TYPES.LONGAGENT_ALERT,
375
+ sessionId,
376
+ payload: { kind: "dependency_cycle", message: `Cycle in stage ${stage.stageId}: ${detail}`, stageId: stage.stageId }
377
+ })
378
+ throw new Error(`Stage ${stage.stageId}: dependency cycle detected — ${detail}. Fix the plan to remove circular dependencies.`)
379
+ }
380
+
381
+ // #20: Runtime file lock registry
382
+ const fileLocks = createFileLockRegistry()
383
+
384
+ for (const task of stage.tasks || []) {
385
+ const seeded = seedTaskProgress[task.taskId] || {}
386
+ const planned = normalizeFiles(task.plannedFiles)
387
+ const completed = normalizeFiles(seeded.completedFiles || [])
388
+ const remaining = normalizeFiles(seeded.remainingFiles || computeRemaining(planned, completed))
389
+ logical.set(task.taskId, {
390
+ stageId: stage.stageId,
391
+ taskId: task.taskId,
392
+ subSessionId: seeded.subSessionId || `sub_${sessionId}_${task.taskId}`,
393
+ plannedFiles: planned,
394
+ completedFiles: completed,
395
+ remainingFiles: remaining,
396
+ acceptance: Array.isArray(task.acceptance) ? task.acceptance : [],
397
+ prompt: seeded.prompt || task.prompt,
398
+ status: seeded.status || "pending",
399
+ attempt: Number(seeded.attempt || 0),
400
+ maxRetries: Number(task.maxRetries ?? cfg.taskMaxRetries),
401
+ timeoutMs: Number(task.timeoutMs || cfg.taskTimeoutMs),
402
+ backgroundTaskId: null,
403
+ lastError: seeded.lastError || "",
404
+ fileChanges: normalizeFileChanges(seeded.fileChanges || [])
405
+ })
406
+ }
407
+
408
+ await EventBus.emit({
409
+ type: EVENT_TYPES.LONGAGENT_STAGE_STARTED,
410
+ sessionId,
411
+ payload: {
412
+ stageId: stage.stageId,
413
+ taskCount: logical.size,
414
+ passRule: cfg.passRule
415
+ }
416
+ })
417
+
418
+ while (true) {
419
+ await BackgroundManager.tick({
420
+ ...config,
421
+ background: {
422
+ ...(config.background || {}),
423
+ max_parallel: Math.max(
424
+ Number(config.background?.max_parallel || 1),
425
+ cfg.maxConcurrency
426
+ )
427
+ }
428
+ })
429
+
430
+ // Recount active tasks each iteration to avoid stale counts
431
+ const activeCount = [...logical.values()].filter((item) => item.status === "running" && item.backgroundTaskId).length
432
+ if (activeCount < cfg.maxConcurrency) {
433
+ const slotsAvailable = cfg.maxConcurrency - activeCount
434
+ const toLaunch = []
435
+ for (const task of stage.tasks || []) {
436
+ const item = logical.get(task.taskId)
437
+ if (!item || item.backgroundTaskId) continue
438
+ if (!["pending", "retrying"].includes(item.status)) continue
439
+ if (toLaunch.length >= slotsAvailable) break
440
+ // #7 依赖感知:等待 dependsOn 的 task 全部完成
441
+ const deps = Array.isArray(task.dependsOn) ? task.dependsOn : []
442
+ if (deps.length > 0) {
443
+ // Cascade: if any dependency failed/errored/missing, skip this task
444
+ const anyDepFailed = deps.some(depId => {
445
+ const dep = logical.get(depId)
446
+ if (!dep) return true // missing dependency = treat as failed
447
+ return ["failed", "error", "cancelled", "skipped"].includes(dep.status)
448
+ })
449
+ if (anyDepFailed) {
450
+ item.status = "skipped"
451
+ item.lastError = "dependency_failed"
452
+ EventBus.emit({
453
+ type: EVENT_TYPES.LONGAGENT_STAGE_TASK_SKIPPED,
454
+ sessionId,
455
+ payload: { stageId: stage.stageId, taskId: task.taskId, reason: "dependency_failed" }
456
+ }).catch(() => {})
457
+ continue
458
+ }
459
+ const allDepsCompleted = deps.every(depId => {
460
+ const dep = logical.get(depId)
461
+ return dep && dep.status === "completed"
462
+ })
463
+ if (!allDepsCompleted) continue
464
+ }
465
+ // #20: Acquire file locks before launching (atomic: rollback on conflict)
466
+ const lockFailures = []
467
+ for (const f of item.plannedFiles) {
468
+ if (!fileLocks.tryLock(f, task.taskId)) lockFailures.push(f)
469
+ }
470
+ if (lockFailures.length > 0) {
471
+ fileLocks.unlock(task.taskId)
472
+ EventBus.emit({
473
+ type: EVENT_TYPES.LONGAGENT_ALERT,
474
+ sessionId,
475
+ payload: {
476
+ kind: "file_lock_conflict",
477
+ message: `Task ${task.taskId} could not lock: ${lockFailures.join(", ")}`,
478
+ taskId: task.taskId,
479
+ stageId: stage.stageId,
480
+ files: lockFailures
481
+ }
482
+ }).catch(() => {})
483
+ continue
484
+ }
485
+ item.attempt += 1
486
+ item.status = "running"
487
+ if (item.attempt > 1) {
488
+ item.prompt = retryPrompt(task.prompt, item.remainingFiles, item.attempt, item.lastError)
489
+ }
490
+ toLaunch.push({ task, item })
491
+ }
492
+ if (toLaunch.length > 0) {
493
+ // #17: Inject real-time TaskBus context into each launched task
494
+ const busCtx = taskBus ? taskBus.toContextString() : ""
495
+ const results = await Promise.allSettled(toLaunch.map(({ task, item }) =>
496
+ launchTask({ stage, task, logicalTask: item, config, sessionId, model, providerType, objective, stageIndex, stageCount, allTasks: stage.tasks || [], priorContext, taskBusContext: busCtx || undefined })
497
+ ))
498
+ for (let i = 0; i < toLaunch.length; i++) {
499
+ const r = results[i]
500
+ if (r.status === "fulfilled") {
501
+ toLaunch[i].item.backgroundTaskId = r.value
502
+ } else {
503
+ // Launch failed — mark error so it won't be orphaned
504
+ toLaunch[i].item.status = "error"
505
+ toLaunch[i].item.lastError = `launch failed: ${r.reason?.message || "unknown"}`
506
+ toLaunch[i].item.errorCategory = ERROR_CATEGORIES.TRANSIENT
507
+ // Release file locks held by this task to prevent deadlock
508
+ fileLocks.unlock(toLaunch[i].task.taskId)
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ let pending = 0
515
+ for (const item of logical.values()) {
516
+ if (!item.backgroundTaskId) {
517
+ if (["pending", "retrying", "running"].includes(item.status)) pending += 1
518
+ continue
519
+ }
520
+ const bg = await BackgroundManager.get(item.backgroundTaskId)
521
+ if (!bg) {
522
+ item.status = "error"
523
+ item.lastError = "background worker disappeared"
524
+ item.backgroundTaskId = null
525
+ continue
526
+ }
527
+ if (!["completed", "error", "interrupted", "cancelled"].includes(bg.status)) {
528
+ pending += 1
529
+ continue
530
+ }
531
+
532
+ const result = bg.result || {}
533
+ const completedFromResult = mergeUnique(
534
+ item.completedFiles,
535
+ normalizeFiles(result.completed_files || result.completedFiles || [])
536
+ )
537
+ const remainingFromResult = normalizeFiles(
538
+ result.remaining_files || result.remainingFiles || computeRemaining(item.plannedFiles, completedFromResult)
539
+ )
540
+ item.completedFiles = completedFromResult
541
+ item.remainingFiles = remainingFromResult
542
+ item.fileChanges = mergeFileChanges(
543
+ item.fileChanges,
544
+ result.file_changes || result.fileChanges || []
545
+ )
546
+ item.backgroundTaskId = null
547
+
548
+ // Runtime file ownership check: warn if task touched files outside its plan
549
+ const plannedSet = new Set(item.plannedFiles)
550
+ const outOfScope = item.fileChanges
551
+ .map(fc => fc.path)
552
+ .filter(p => p && !plannedSet.has(p))
553
+ if (outOfScope.length > 0) {
554
+ // Check if any out-of-scope file is locked by another task
555
+ const conflicts = fileLocks.getConflicts(outOfScope, item.taskId)
556
+ const conflicting = conflicts.map(c => c.file)
557
+ await EventBus.emit({
558
+ type: EVENT_TYPES.LONGAGENT_ALERT,
559
+ sessionId,
560
+ payload: {
561
+ kind: "file_ownership_violation",
562
+ message: `Task ${item.taskId} modified ${outOfScope.length} file(s) outside its plan: ${outOfScope.slice(0, 5).join(", ")}`,
563
+ taskId: item.taskId,
564
+ stageId: stage.stageId,
565
+ outOfScopeFiles: outOfScope,
566
+ conflicting
567
+ }
568
+ })
569
+ // Escalate to error if conflicting with another task's locked files
570
+ if (conflicting.length > 0) {
571
+ item.status = "error"
572
+ item.lastError = `file ownership conflict: ${conflicting.slice(0, 3).join(", ")} locked by other tasks`
573
+ item.errorCategory = ERROR_CATEGORIES.PERMANENT
574
+ continue
575
+ }
576
+ }
577
+
578
+ // #20: Release file locks when task finishes
579
+ fileLocks.unlock(item.taskId)
580
+
581
+ if (bg.status === "completed" && remainingFromResult.length === 0) {
582
+ item.status = "completed"
583
+ item.lastError = ""
584
+ item.errorCategory = null
585
+ } else if (bg.status === "completed" && remainingFromResult.length > 0) {
586
+ item.lastError = "task completed but remaining files still pending"
587
+ item.errorCategory = ERROR_CATEGORIES.TRANSIENT
588
+ item.status = item.attempt <= item.maxRetries ? "retrying" : "error"
589
+ } else {
590
+ item.lastError = bg.error || "task failed"
591
+ const category = classifyError(item.lastError, bg.status)
592
+ item.errorCategory = category
593
+ if (category === ERROR_CATEGORIES.PERMANENT || category === ERROR_CATEGORIES.UNKNOWN) {
594
+ item.status = "error"
595
+ item.skipReason = `${category} error: ${item.lastError.slice(0, 100)}`
596
+ } else {
597
+ item.status = item.attempt <= item.maxRetries ? "retrying" : (bg.status === "cancelled" ? "cancelled" : "error")
598
+ }
599
+ }
600
+ item.lastReply = String(result.reply || "")
601
+ item.lastCost = Number(result.cost || 0)
602
+
603
+ await EventBus.emit({
604
+ type: EVENT_TYPES.LONGAGENT_STAGE_TASK_FINISHED,
605
+ sessionId,
606
+ payload: {
607
+ stageId: stage.stageId,
608
+ taskId: item.taskId,
609
+ status: item.status,
610
+ attempt: item.attempt,
611
+ remainingFiles: item.remainingFiles,
612
+ errorCategory: item.errorCategory || null
613
+ }
614
+ })
615
+
616
+ // #17: Real-time TaskBus parsing — completed tasks broadcast immediately
617
+ if (taskBus && item.lastReply) {
618
+ taskBus.parseTaskOutput(item.taskId, item.lastReply)
619
+ }
620
+
621
+ // Phase 3: stuck tracker 集成
622
+ if (stuckTracker && result.toolEvents?.length) {
623
+ const stuckResult = stuckTracker.track(result.toolEvents)
624
+ if (stuckResult.isStuck) {
625
+ await EventBus.emit({
626
+ type: EVENT_TYPES.LONGAGENT_ALERT,
627
+ sessionId,
628
+ payload: {
629
+ kind: stuckResult.reason === "write_loop_detected" || stuckResult.reason === "edit_cycle_detected"
630
+ ? "write_loop_warning" : "stuck_warning",
631
+ message: `Task ${item.taskId} in stage ${stage.stageId}: ${stuckResult.reason}`,
632
+ taskId: item.taskId,
633
+ stageId: stage.stageId,
634
+ reason: stuckResult.reason
635
+ }
636
+ })
637
+ }
638
+ }
639
+
640
+ // Phase 7: task 级 checkpoint 回调
641
+ if (onTaskComplete && item.status === "completed") {
642
+ try {
643
+ await onTaskComplete({
644
+ stageId: stage.stageId,
645
+ taskId: item.taskId,
646
+ status: item.status,
647
+ completedFiles: item.completedFiles,
648
+ fileChanges: item.fileChanges,
649
+ attempt: item.attempt
650
+ })
651
+ } catch { /* ignore checkpoint errors */ }
652
+ }
653
+
654
+ if (["pending", "retrying", "running"].includes(item.status)) pending += 1
655
+ }
656
+
657
+ if (pending <= 0) break
658
+
659
+ // Budget circuit breaker: abort remaining tasks if cost exceeds limit
660
+ if (cfg.budgetLimitUsd > 0) {
661
+ const spent = [...logical.values()].reduce((s, i) => s + (Number.isFinite(i.lastCost) ? i.lastCost : 0), 0)
662
+ if (spent >= cfg.budgetLimitUsd) {
663
+ for (const item of logical.values()) {
664
+ if (["pending", "retrying"].includes(item.status)) {
665
+ item.status = "error"
666
+ item.lastError = `budget limit exceeded ($${spent.toFixed(2)} >= $${cfg.budgetLimitUsd})`
667
+ }
668
+ if (item.backgroundTaskId && item.status === "running") {
669
+ await BackgroundManager.cancel(item.backgroundTaskId).catch(() => {})
670
+ }
671
+ }
672
+ await EventBus.emit({
673
+ type: EVENT_TYPES.LONGAGENT_ALERT,
674
+ sessionId,
675
+ payload: { kind: "budget_breaker", spent, limit: cfg.budgetLimitUsd, stageId: stage.stageId }
676
+ })
677
+ break
678
+ }
679
+ }
680
+
681
+ await sleep(cfg.pollIntervalMs)
682
+ }
683
+
684
+ const items = [...logical.values()]
685
+ const successCount = items.filter((item) => item.status === "completed").length
686
+ const failItems = items.filter((item) => item.status !== "completed")
687
+ const retryCount = items.reduce((sum, item) => sum + Math.max(0, item.attempt - 1), 0)
688
+ const remainingFiles = mergeUnique(...items.map((item) => item.remainingFiles))
689
+ const completionMarkerSeen = items.some((item) => String(item.lastReply || "").toLowerCase().includes("[task_complete]"))
690
+ const totalCost = items.reduce((sum, item) => sum + (Number.isFinite(item.lastCost) ? item.lastCost : 0), 0)
691
+ const fileChanges = mergeFileChanges(...items.map((item) => item.fileChanges))
692
+
693
+ const summary = {
694
+ stageId: stage.stageId,
695
+ successCount,
696
+ failCount: failItems.length,
697
+ retryCount,
698
+ remainingFiles,
699
+ completionMarkerSeen,
700
+ totalCost,
701
+ fileChanges,
702
+ allSuccess: failItems.length === 0,
703
+ taskProgress: Object.fromEntries(
704
+ items.map((item) => [
705
+ item.taskId,
706
+ {
707
+ taskId: item.taskId,
708
+ attempt: item.attempt,
709
+ status: item.status,
710
+ plannedFiles: item.plannedFiles,
711
+ completedFiles: item.completedFiles,
712
+ remainingFiles: item.remainingFiles,
713
+ fileChanges: item.fileChanges,
714
+ lastError: item.lastError || "",
715
+ lastReply: item.lastReply || ""
716
+ }
717
+ ])
718
+ )
719
+ }
720
+
721
+ await EventBus.emit({
722
+ type: EVENT_TYPES.LONGAGENT_STAGE_FINISHED,
723
+ sessionId,
724
+ payload: summary
725
+ })
726
+
727
+ return summary
728
+ }