@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.1

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 (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -1,728 +1,733 @@
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
- }
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
+ fileLocks.unlock(item.taskId)
572
+ item.status = "error"
573
+ item.lastError = `file ownership conflict: ${conflicting.slice(0, 3).join(", ")} locked by other tasks`
574
+ item.errorCategory = ERROR_CATEGORIES.PERMANENT
575
+ continue
576
+ }
577
+ }
578
+
579
+ // #20: Release file locks when task finishes
580
+ fileLocks.unlock(item.taskId)
581
+
582
+ if (bg.status === "completed" && remainingFromResult.length === 0) {
583
+ item.status = "completed"
584
+ item.lastError = ""
585
+ item.errorCategory = null
586
+ } else if (bg.status === "completed" && remainingFromResult.length > 0) {
587
+ item.lastError = "task completed but remaining files still pending"
588
+ item.errorCategory = ERROR_CATEGORIES.TRANSIENT
589
+ item.status = item.attempt <= item.maxRetries ? "retrying" : "error"
590
+ } else {
591
+ item.lastError = bg.error || "task failed"
592
+ const category = classifyError(item.lastError, bg.status)
593
+ item.errorCategory = category
594
+ if (category === ERROR_CATEGORIES.PERMANENT || category === ERROR_CATEGORIES.UNKNOWN) {
595
+ item.status = "error"
596
+ item.skipReason = `${category} error: ${item.lastError.slice(0, 100)}`
597
+ } else {
598
+ item.status = item.attempt <= item.maxRetries ? "retrying" : (bg.status === "cancelled" ? "cancelled" : "error")
599
+ }
600
+ }
601
+ item.lastReply = String(result.reply || "")
602
+ item.lastCost = Number(result.cost || 0)
603
+
604
+ await EventBus.emit({
605
+ type: EVENT_TYPES.LONGAGENT_STAGE_TASK_FINISHED,
606
+ sessionId,
607
+ payload: {
608
+ stageId: stage.stageId,
609
+ taskId: item.taskId,
610
+ status: item.status,
611
+ attempt: item.attempt,
612
+ remainingFiles: item.remainingFiles,
613
+ errorCategory: item.errorCategory || null
614
+ }
615
+ })
616
+
617
+ // #17: Real-time TaskBus parsing — completed tasks broadcast immediately
618
+ if (taskBus && item.lastReply) {
619
+ taskBus.parseTaskOutput(item.taskId, item.lastReply)
620
+ }
621
+
622
+ // Phase 3: stuck tracker 集成
623
+ if (stuckTracker && result.toolEvents?.length) {
624
+ const stuckResult = stuckTracker.track(result.toolEvents)
625
+ if (stuckResult.isStuck) {
626
+ await EventBus.emit({
627
+ type: EVENT_TYPES.LONGAGENT_ALERT,
628
+ sessionId,
629
+ payload: {
630
+ kind: stuckResult.reason === "write_loop_detected" || stuckResult.reason === "edit_cycle_detected"
631
+ ? "write_loop_warning" : "stuck_warning",
632
+ message: `Task ${item.taskId} in stage ${stage.stageId}: ${stuckResult.reason}`,
633
+ taskId: item.taskId,
634
+ stageId: stage.stageId,
635
+ reason: stuckResult.reason
636
+ }
637
+ })
638
+ }
639
+ }
640
+
641
+ // Phase 7: task checkpoint 回调
642
+ if (onTaskComplete && item.status === "completed") {
643
+ try {
644
+ await onTaskComplete({
645
+ stageId: stage.stageId,
646
+ taskId: item.taskId,
647
+ status: item.status,
648
+ completedFiles: item.completedFiles,
649
+ fileChanges: item.fileChanges,
650
+ attempt: item.attempt
651
+ })
652
+ } catch { /* ignore checkpoint errors */ }
653
+ }
654
+
655
+ if (["pending", "retrying", "running"].includes(item.status)) pending += 1
656
+ }
657
+
658
+ if (pending <= 0) break
659
+
660
+ // Budget circuit breaker: abort remaining tasks if cost exceeds limit
661
+ if (cfg.budgetLimitUsd > 0) {
662
+ const spent = [...logical.values()].reduce((s, i) => s + (Number.isFinite(i.lastCost) ? i.lastCost : 0), 0)
663
+ if (spent >= cfg.budgetLimitUsd) {
664
+ for (const item of logical.values()) {
665
+ if (["pending", "retrying"].includes(item.status)) {
666
+ item.status = "error"
667
+ item.lastError = `budget limit exceeded ($${spent.toFixed(2)} >= $${cfg.budgetLimitUsd})`
668
+ }
669
+ if (item.backgroundTaskId && item.status === "running") {
670
+ await BackgroundManager.cancel(item.backgroundTaskId).catch(() => {})
671
+ }
672
+ }
673
+ await EventBus.emit({
674
+ type: EVENT_TYPES.LONGAGENT_ALERT,
675
+ sessionId,
676
+ payload: { kind: "budget_breaker", spent, limit: cfg.budgetLimitUsd, stageId: stage.stageId }
677
+ })
678
+ break
679
+ }
680
+ }
681
+
682
+ // Collect running background task IDs for precise event-driven waiting
683
+ const runningBgIds = [...logical.values()]
684
+ .filter(item => item.status === "running" && item.backgroundTaskId)
685
+ .map(item => item.backgroundTaskId)
686
+ await BackgroundManager.waitForAny(runningBgIds, cfg.pollIntervalMs)
687
+ }
688
+
689
+ const items = [...logical.values()]
690
+ const successCount = items.filter((item) => item.status === "completed").length
691
+ const failItems = items.filter((item) => item.status !== "completed")
692
+ const retryCount = items.reduce((sum, item) => sum + Math.max(0, item.attempt - 1), 0)
693
+ const remainingFiles = mergeUnique(...items.map((item) => item.remainingFiles))
694
+ const completionMarkerSeen = items.some((item) => String(item.lastReply || "").toLowerCase().includes("[task_complete]"))
695
+ const totalCost = items.reduce((sum, item) => sum + (Number.isFinite(item.lastCost) ? item.lastCost : 0), 0)
696
+ const fileChanges = mergeFileChanges(...items.map((item) => item.fileChanges))
697
+
698
+ const summary = {
699
+ stageId: stage.stageId,
700
+ successCount,
701
+ failCount: failItems.length,
702
+ retryCount,
703
+ remainingFiles,
704
+ completionMarkerSeen,
705
+ totalCost,
706
+ fileChanges,
707
+ allSuccess: failItems.length === 0,
708
+ taskProgress: Object.fromEntries(
709
+ items.map((item) => [
710
+ item.taskId,
711
+ {
712
+ taskId: item.taskId,
713
+ attempt: item.attempt,
714
+ status: item.status,
715
+ plannedFiles: item.plannedFiles,
716
+ completedFiles: item.completedFiles,
717
+ remainingFiles: item.remainingFiles,
718
+ fileChanges: item.fileChanges,
719
+ lastError: item.lastError || "",
720
+ lastReply: item.lastReply || ""
721
+ }
722
+ ])
723
+ )
724
+ }
725
+
726
+ await EventBus.emit({
727
+ type: EVENT_TYPES.LONGAGENT_STAGE_FINISHED,
728
+ sessionId,
729
+ payload: summary
730
+ })
731
+
732
+ return summary
733
+ }