@kkelly-offical/kkcode 0.1.2 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -178
- package/package.json +46 -46
- package/src/agent/agent.mjs +41 -0
- package/src/agent/prompt/frontend-designer.txt +58 -0
- package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
- package/src/agent/prompt/longagent-coding-agent.txt +37 -0
- package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
- package/src/agent/prompt/longagent-preview-agent.txt +63 -0
- package/src/config/defaults.mjs +260 -195
- package/src/config/schema.mjs +71 -6
- package/src/core/constants.mjs +91 -46
- package/src/index.mjs +1 -1
- package/src/knowledge/frontend-aesthetics.txt +39 -0
- package/src/knowledge/loader.mjs +2 -1
- package/src/knowledge/tailwind.txt +12 -3
- package/src/mcp/client-http.mjs +141 -157
- package/src/mcp/client-sse.mjs +288 -286
- package/src/mcp/client-stdio.mjs +533 -451
- package/src/mcp/constants.mjs +2 -0
- package/src/mcp/registry.mjs +479 -394
- package/src/mcp/stdio-framing.mjs +133 -127
- package/src/mcp/tool-result.mjs +24 -0
- package/src/observability/index.mjs +42 -0
- package/src/observability/metrics.mjs +137 -0
- package/src/observability/tracer.mjs +137 -0
- package/src/orchestration/background-manager.mjs +372 -358
- package/src/orchestration/background-worker.mjs +305 -245
- package/src/orchestration/longagent-manager.mjs +171 -116
- package/src/orchestration/stage-scheduler.mjs +728 -489
- package/src/permission/exec-policy.mjs +9 -11
- package/src/provider/anthropic.mjs +1 -0
- package/src/provider/openai.mjs +340 -339
- package/src/provider/retry-policy.mjs +68 -68
- package/src/provider/router.mjs +241 -228
- package/src/provider/sse.mjs +104 -91
- package/src/repl.mjs +1 -1
- package/src/session/checkpoint.mjs +66 -3
- package/src/session/engine.mjs +227 -225
- package/src/session/longagent-4stage.mjs +460 -0
- package/src/session/longagent-hybrid.mjs +1081 -0
- package/src/session/longagent-plan.mjs +365 -329
- package/src/session/longagent-project-memory.mjs +53 -0
- package/src/session/longagent-scaffold.mjs +291 -100
- package/src/session/longagent-task-bus.mjs +54 -0
- package/src/session/longagent-utils.mjs +472 -0
- package/src/session/longagent.mjs +884 -1462
- package/src/session/project-context.mjs +30 -0
- package/src/session/store.mjs +510 -503
- package/src/session/task-validator.mjs +4 -3
- package/src/skill/builtin/design.mjs +76 -0
- package/src/skill/builtin/frontend.mjs +8 -0
- package/src/skill/registry.mjs +390 -336
- package/src/storage/ghost-commit-store.mjs +18 -8
- package/src/tool/executor.mjs +11 -0
- package/src/tool/git-auto.mjs +0 -19
- package/src/tool/registry.mjs +71 -37
- package/src/ui/activity-renderer.mjs +664 -410
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{ pattern: /\b(
|
|
10
|
-
{ pattern: /\b(
|
|
11
|
-
{ pattern: /\b(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
sessionId,
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
{
|
|
468
|
-
taskId
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
+
}
|