@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.
Files changed (58) hide show
  1. package/README.md +120 -178
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +41 -0
  4. package/src/agent/prompt/frontend-designer.txt +58 -0
  5. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  6. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  7. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  8. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  9. package/src/config/defaults.mjs +260 -195
  10. package/src/config/schema.mjs +71 -6
  11. package/src/core/constants.mjs +91 -46
  12. package/src/index.mjs +1 -1
  13. package/src/knowledge/frontend-aesthetics.txt +39 -0
  14. package/src/knowledge/loader.mjs +2 -1
  15. package/src/knowledge/tailwind.txt +12 -3
  16. package/src/mcp/client-http.mjs +141 -157
  17. package/src/mcp/client-sse.mjs +288 -286
  18. package/src/mcp/client-stdio.mjs +533 -451
  19. package/src/mcp/constants.mjs +2 -0
  20. package/src/mcp/registry.mjs +479 -394
  21. package/src/mcp/stdio-framing.mjs +133 -127
  22. package/src/mcp/tool-result.mjs +24 -0
  23. package/src/observability/index.mjs +42 -0
  24. package/src/observability/metrics.mjs +137 -0
  25. package/src/observability/tracer.mjs +137 -0
  26. package/src/orchestration/background-manager.mjs +372 -358
  27. package/src/orchestration/background-worker.mjs +305 -245
  28. package/src/orchestration/longagent-manager.mjs +171 -116
  29. package/src/orchestration/stage-scheduler.mjs +728 -489
  30. package/src/permission/exec-policy.mjs +9 -11
  31. package/src/provider/anthropic.mjs +1 -0
  32. package/src/provider/openai.mjs +340 -339
  33. package/src/provider/retry-policy.mjs +68 -68
  34. package/src/provider/router.mjs +241 -228
  35. package/src/provider/sse.mjs +104 -91
  36. package/src/repl.mjs +1 -1
  37. package/src/session/checkpoint.mjs +66 -3
  38. package/src/session/engine.mjs +227 -225
  39. package/src/session/longagent-4stage.mjs +460 -0
  40. package/src/session/longagent-hybrid.mjs +1081 -0
  41. package/src/session/longagent-plan.mjs +365 -329
  42. package/src/session/longagent-project-memory.mjs +53 -0
  43. package/src/session/longagent-scaffold.mjs +291 -100
  44. package/src/session/longagent-task-bus.mjs +54 -0
  45. package/src/session/longagent-utils.mjs +472 -0
  46. package/src/session/longagent.mjs +884 -1462
  47. package/src/session/project-context.mjs +30 -0
  48. package/src/session/store.mjs +510 -503
  49. package/src/session/task-validator.mjs +4 -3
  50. package/src/skill/builtin/design.mjs +76 -0
  51. package/src/skill/builtin/frontend.mjs +8 -0
  52. package/src/skill/registry.mjs +390 -336
  53. package/src/storage/ghost-commit-store.mjs +18 -8
  54. package/src/tool/executor.mjs +11 -0
  55. package/src/tool/git-auto.mjs +0 -19
  56. package/src/tool/registry.mjs +71 -37
  57. package/src/ui/activity-renderer.mjs +664 -410
  58. package/src/util/git.mjs +23 -0
@@ -1,1462 +1,884 @@
1
- import { LongAgentManager } from "../orchestration/longagent-manager.mjs"
2
- import { processTurnLoop } from "./loop.mjs"
3
- import { markSessionStatus } from "./store.mjs"
4
- import { EventBus } from "../core/events.mjs"
5
- import {
6
- EVENT_TYPES,
7
- DEFAULT_LONGAGENT_RETRY_STORM_THRESHOLD,
8
- DEFAULT_LONGAGENT_TOKEN_ALERT_THRESHOLD
9
- } from "../core/constants.mjs"
10
- import { saveCheckpoint, loadCheckpoint } from "./checkpoint.mjs"
11
- import {
12
- runUsabilityGates,
13
- hasGatePreferences,
14
- getGatePreferences,
15
- saveGatePreferences,
16
- buildGatePromptText,
17
- parseGateSelection
18
- } from "./usability-gates.mjs"
19
- import { runIntakeDialogue, buildStagePlan } from "./longagent-plan.mjs"
20
- import { runStageBarrier } from "../orchestration/stage-scheduler.mjs"
21
- import { runScaffoldPhase } from "./longagent-scaffold.mjs"
22
- import { createValidator } from "./task-validator.mjs"
23
- import * as git from "../util/git.mjs"
24
-
25
- function isComplete(text) {
26
- const lower = String(text || "").toLowerCase()
27
- if (lower.includes("[task_complete]")) return true
28
- if (lower.includes("task complete")) return true
29
- if (lower.includes("completed successfully")) return true
30
- return false
31
- }
32
-
33
- function normalizeReply(text) {
34
- return String(text || "").trim().toLowerCase().replace(/\s+/g, " ")
35
- }
36
-
37
- function extractStructuredProgress(text) {
38
- const str = String(text || "")
39
- const progressMatch = str.match(/\[PROGRESS:\s*(\d+)%\]/)
40
- const stepMatch = str.match(/\[STEP:\s*(\d+)\/(\d+)\]/)
41
- return {
42
- percentage: progressMatch ? Number(progressMatch[1]) : null,
43
- currentStep: stepMatch ? Number(stepMatch[1]) : null,
44
- totalSteps: stepMatch ? Number(stepMatch[2]) : null,
45
- hasStructuredSignal: Boolean(progressMatch || stepMatch)
46
- }
47
- }
48
-
49
- function detectProgress(currentReply, previousReplyNormalized, toolEventCount = 0) {
50
- const structured = extractStructuredProgress(currentReply)
51
- if (structured.hasStructuredSignal) {
52
- return { hasProgress: true, structured }
53
- }
54
- // Tool calls (file writes, bash executions) count as objective progress
55
- if (toolEventCount > 0) {
56
- return { hasProgress: true, structured }
57
- }
58
- const normalized = normalizeReply(currentReply)
59
- if (normalized !== previousReplyNormalized && normalized.length > 10) {
60
- return { hasProgress: true, structured }
61
- }
62
- return { hasProgress: false, structured }
63
- }
64
-
65
- function buildNextPrompt(original, reply, iteration, progress) {
66
- const parts = [
67
- `Original objective: ${original}`,
68
- `Iteration: ${iteration}`
69
- ]
70
- if (progress?.percentage !== null) {
71
- parts.push(`Current progress: ${progress.percentage}%`)
72
- }
73
- if (progress?.currentStep !== null && progress?.totalSteps !== null) {
74
- parts.push(`Current step: ${progress.currentStep}/${progress.totalSteps}`)
75
- }
76
- parts.push("Latest result:", reply, "")
77
- parts.push("Continue execution. Report progress with [PROGRESS: X%] and [STEP: N/M] markers.")
78
- parts.push("When the entire task is complete, include [TASK_COMPLETE] in your final answer.")
79
- return parts.join("\n")
80
- }
81
-
82
- function buildRecoveryPrompt(original, reply, iteration, reason, progress, checkpoint = null) {
83
- const parts = [
84
- `Original objective: ${original}`,
85
- `Recovery reason: ${reason}`,
86
- `Current iteration: ${iteration}`
87
- ]
88
- if (progress?.percentage !== null) {
89
- parts.push(`Last known progress: ${progress.percentage}%`)
90
- }
91
- if (checkpoint) {
92
- parts.push(
93
- `Checkpoint restored: name=${checkpoint.name || "latest"} savedAt=${new Date(checkpoint.savedAt || Date.now()).toISOString()}`
94
- )
95
- if (checkpoint.recentToolSummary) {
96
- parts.push(`Recent tool calls: ${checkpoint.recentToolSummary}`)
97
- }
98
- }
99
- parts.push("")
100
- parts.push("Latest output that failed to advance:")
101
- parts.push(reply || "(empty)")
102
- parts.push("")
103
- parts.push("Enter recovery mode now. Diagnose blockers, apply concrete fixes, and continue execution.")
104
- parts.push("Do not stop early. Only mark completion when objective is fully usable and include [TASK_COMPLETE].")
105
- return parts.join("\n")
106
- }
107
-
108
- function summarizeGateFailures(failures = []) {
109
- if (!failures.length) return ""
110
- return failures
111
- .slice(0, 5)
112
- .map((item) => `${item.gate}:${item.reason}`)
113
- .join("; ")
114
- }
115
-
116
- function stageProgressStats(taskProgress = {}) {
117
- if (!taskProgress || typeof taskProgress !== "object") {
118
- return { done: 0, total: 0, remainingFiles: [], remainingFilesCount: 0 }
119
- }
120
- const items = Object.values(taskProgress)
121
- const done = items.filter((item) => item.status === "completed").length
122
- const total = items.length
123
- const remainingFiles = [...new Set(items.flatMap((item) => Array.isArray(item.remainingFiles) ? item.remainingFiles : []))]
124
- return {
125
- done,
126
- total,
127
- remainingFiles,
128
- remainingFilesCount: remainingFiles.length
129
- }
130
- }
131
-
132
- const LONGAGENT_FILE_CHANGES_LIMIT = 400
133
-
134
- function normalizeFileChange(item = {}) {
135
- const path = String(item.path || "").trim()
136
- if (!path) return null
137
- return {
138
- path,
139
- addedLines: Math.max(0, Number(item.addedLines || 0)),
140
- removedLines: Math.max(0, Number(item.removedLines || 0)),
141
- stageId: item.stageId ? String(item.stageId) : "",
142
- taskId: item.taskId ? String(item.taskId) : ""
143
- }
144
- }
145
-
146
- function mergeCappedFileChanges(current = [], incoming = [], limit = LONGAGENT_FILE_CHANGES_LIMIT) {
147
- const maxEntries = Math.max(1, Number(limit || LONGAGENT_FILE_CHANGES_LIMIT))
148
- const map = new Map()
149
-
150
- const append = (entry) => {
151
- const normalized = normalizeFileChange(entry)
152
- if (!normalized) return
153
- const key = `${normalized.path}::${normalized.stageId}::${normalized.taskId}`
154
- const prev = map.get(key) || { ...normalized, addedLines: 0, removedLines: 0 }
155
- prev.addedLines += normalized.addedLines
156
- prev.removedLines += normalized.removedLines
157
- // keep newest insertion order so capped slice keeps most recent touched files
158
- map.delete(key)
159
- map.set(key, prev)
160
- }
161
-
162
- for (const item of current) append(item)
163
- for (const item of incoming) append(item)
164
-
165
- const merged = [...map.values()]
166
- return merged.length <= maxEntries ? merged : merged.slice(merged.length - maxEntries)
167
- }
168
-
169
- function isLikelyActionableObjective(prompt) {
170
- const text = String(prompt || "").trim()
171
- if (!text) return false
172
- const lower = text.toLowerCase()
173
- const greetings = [
174
- "hi", "hello", "hey", "你好", "您好", "在吗", "yo", "嗨"
175
- ]
176
- const codingSignals = [
177
- "fix", "build", "implement", "refactor", "debug", "test", "review", "write", "create", "add", "optimize", "migrate", "deploy",
178
- "bug", "issue", "error", "code", "repo", "file", "function", "api",
179
- "修复", "实现", "重构", "调试", "测试", "优化", "迁移", "部署", "代码", "仓库", "文件", "函数", "接口", "需求", "功能", "报错"
180
- ]
181
- if (codingSignals.some((kw) => lower.includes(kw))) return true
182
- if (greetings.some((g) => lower === g || lower === `${g}!` || lower === `${g}!`)) return false
183
- if (text.length <= 8 && !/[./\\:_-]/.test(text)) return false
184
- return true
185
- }
186
-
187
- async function runParallelLongAgent({
188
- prompt,
189
- model,
190
- providerType,
191
- sessionId,
192
- configState,
193
- baseUrl = null,
194
- apiKeyEnv = null,
195
- agent = null,
196
- maxIterations = 0,
197
- signal = null,
198
- output = null,
199
- allowQuestion = true,
200
- toolContext = {}
201
- }) {
202
- const longagentConfig = configState.config.agent.longagent || {}
203
- const plannerConfig = longagentConfig.planner || {}
204
- const intakeConfig = plannerConfig.intake_questions || {}
205
- const parallelConfig = longagentConfig.parallel || {}
206
- const noProgressLimit = Number(longagentConfig.no_progress_limit || 5)
207
- const checkpointInterval = Number(longagentConfig.checkpoint_interval || 5)
208
- const maxGateAttempts = Number(longagentConfig.max_gate_attempts || 5)
209
-
210
- const gitConfig = longagentConfig.git || {}
211
- const gitEnabled = gitConfig.enabled === true || gitConfig.enabled === "ask"
212
- const gitAsk = gitConfig.enabled === "ask"
213
-
214
- let iteration = 0
215
- let recoveryCount = 0
216
- let currentPhase = "L0"
217
- let currentGate = "intake"
218
- let gateStatus = {}
219
- let lastGateFailures = []
220
- let lastProgress = { percentage: 0, currentStep: 0, totalSteps: 0 }
221
- let finalReply = ""
222
- let stageIndex = 0
223
- let planFrozen = false
224
- let stagePlan = null
225
- let taskProgress = {}
226
- let fileChanges = []
227
- const fileChangesLimit = Math.max(20, Number(longagentConfig.file_changes_limit || LONGAGENT_FILE_CHANGES_LIMIT))
228
- const aggregateUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
229
- const toolEvents = []
230
- const startTime = Date.now()
231
- let completionMarkerSeen = false
232
- let gitBranch = null
233
- let gitBaseBranch = null
234
- let gitActive = false
235
-
236
- async function setPhase(nextPhase, reason = "") {
237
- if (currentPhase === nextPhase) return
238
- const prevPhase = currentPhase
239
- currentPhase = nextPhase
240
- await EventBus.emit({
241
- type: EVENT_TYPES.LONGAGENT_PHASE_CHANGED,
242
- sessionId,
243
- payload: { prevPhase, nextPhase, reason, iteration }
244
- })
245
- }
246
-
247
- async function syncState(patch = {}) {
248
- const stats = stageProgressStats(taskProgress)
249
- const stageCount = stagePlan?.stages?.length || 0
250
- const currentStage = stagePlan?.stages?.[stageIndex] || null
251
- await LongAgentManager.update(sessionId, {
252
- status: patch.status || "running",
253
- phase: currentPhase,
254
- gateStatus,
255
- currentGate,
256
- recoveryCount,
257
- lastGateFailures,
258
- iterations: iteration,
259
- heartbeatAt: Date.now(),
260
- noProgressCount: 0,
261
- progress: lastProgress,
262
- planFrozen,
263
- currentStageId: currentStage?.stageId || null,
264
- stageIndex,
265
- stageCount,
266
- stageStatus: patch.stageStatus || null,
267
- taskProgress,
268
- remainingFiles: stats.remainingFiles,
269
- remainingFilesCount: stats.remainingFilesCount,
270
- stageProgress: {
271
- done: stats.done,
272
- total: stats.total
273
- },
274
- ...patch
275
- })
276
- }
277
-
278
- await markSessionStatus(sessionId, "running-longagent")
279
- await syncState({
280
- status: "running",
281
- lastMessage: "longagent parallel mode started",
282
- stopRequested: false
283
- })
284
-
285
- if (!isLikelyActionableObjective(prompt)) {
286
- const blocked = "LongAgent 需要明确的编码目标。请直接描述要实现/修复的内容、涉及文件或验收标准。"
287
- await LongAgentManager.update(sessionId, {
288
- status: "blocked",
289
- phase: "L0",
290
- currentGate: "intake",
291
- gateStatus: {
292
- intake: {
293
- status: "blocked",
294
- reason: "objective_not_actionable"
295
- }
296
- },
297
- lastMessage: blocked
298
- })
299
- await markSessionStatus(sessionId, "active")
300
- return {
301
- sessionId,
302
- turnId: `turn_long_${Date.now()}`,
303
- reply: blocked,
304
- usage: aggregateUsage,
305
- toolEvents,
306
- iterations: 0,
307
- emittedText: false,
308
- context: null,
309
- status: "blocked",
310
- phase: "L0",
311
- gateStatus: { intake: { status: "blocked", reason: "objective_not_actionable" } },
312
- currentGate: "intake",
313
- lastGateFailures: [],
314
- recoveryCount: 0,
315
- progress: { percentage: 0, currentStep: 0, totalSteps: 0 },
316
- elapsed: 0,
317
- stageIndex: 0,
318
- stageCount: 0,
319
- currentStageId: null,
320
- planFrozen: false,
321
- taskProgress: {},
322
- stageProgress: { done: 0, total: 0, remainingFiles: [], remainingFilesCount: 0 },
323
- fileChanges: [],
324
- remainingFilesCount: 0
325
- }
326
- }
327
-
328
- await EventBus.emit({
329
- type: EVENT_TYPES.LONGAGENT_INTAKE_STARTED,
330
- sessionId,
331
- payload: { objective: prompt }
332
- })
333
-
334
- const intakeEnabled = intakeConfig.enabled !== false
335
- let intakeSummary = prompt
336
- if (intakeEnabled) {
337
- await setPhase("L0", "intake")
338
- const intake = await runIntakeDialogue({
339
- objective: prompt,
340
- model,
341
- providerType,
342
- sessionId,
343
- configState,
344
- baseUrl,
345
- apiKeyEnv,
346
- agent,
347
- signal,
348
- maxRounds: Number(intakeConfig.max_rounds || 6)
349
- })
350
- intakeSummary = intake.summary || prompt
351
- gateStatus.intake = {
352
- status: "pass",
353
- rounds: intake.transcript.length,
354
- summary: intakeSummary.slice(0, 500)
355
- }
356
- await syncState({
357
- lastMessage: `intake completed (${intake.transcript.length} qa pairs)`
358
- })
359
- }
360
-
361
- // --- Git branch creation (after intake, before planning) ---
362
- const cwd = process.cwd()
363
- const inGitRepo = gitEnabled && await git.isGitRepo(cwd)
364
- if (inGitRepo) {
365
- let userWantsGit = !gitAsk
366
- if (gitAsk && allowQuestion) {
367
- // Ask user via a lightweight turn
368
- const askResult = await processTurnLoop({
369
- prompt: [
370
- "[SYSTEM] Git 分支管理已就绪。是否为本次 LongAgent 会话创建独立分支?",
371
- "回复 yes/是 启用,no/否 跳过。",
372
- "启用后:自动创建特性分支 每阶段自动提交 完成后合并回主分支。"
373
- ].join("\n"),
374
- mode: "ask", model, providerType, sessionId, configState,
375
- baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
376
- })
377
- const answer = String(askResult.reply || "").toLowerCase().trim()
378
- userWantsGit = ["yes", "是", "y", "ok", "好", "确认", "开启", "启用"].some(k => answer.includes(k))
379
- aggregateUsage.input += askResult.usage.input || 0
380
- aggregateUsage.output += askResult.usage.output || 0
381
- }
382
-
383
- if (userWantsGit) {
384
- gitBaseBranch = await git.currentBranch(cwd)
385
- const branchName = git.generateBranchName(sessionId, prompt)
386
- const clean = await git.isClean(cwd)
387
- let stashed = false
388
- if (!clean) {
389
- const stashResult = await git.stash("kkcode-auto-stash-before-branch", cwd)
390
- stashed = stashResult.ok
391
- }
392
- const created = await git.createBranch(branchName, cwd)
393
- if (created.ok) {
394
- gitBranch = branchName
395
- gitActive = true
396
- gateStatus.git = { status: "pass", branch: branchName, baseBranch: gitBaseBranch }
397
- await EventBus.emit({
398
- type: EVENT_TYPES.LONGAGENT_GIT_BRANCH_CREATED,
399
- sessionId,
400
- payload: { branch: branchName, baseBranch: gitBaseBranch }
401
- })
402
- await syncState({ lastMessage: `git branch created: ${branchName}` })
403
- } else {
404
- gateStatus.git = { status: "warn", reason: created.message }
405
- }
406
- if (stashed) {
407
- await git.stashPop(cwd)
408
- }
409
- }
410
- }
411
-
412
- await setPhase("L1", "plan_frozen")
413
- currentGate = "planning"
414
- const planResult = await buildStagePlan({
415
- objective: prompt,
416
- intakeSummary,
417
- model,
418
- providerType,
419
- sessionId,
420
- configState,
421
- baseUrl,
422
- apiKeyEnv,
423
- agent,
424
- signal,
425
- defaults: {
426
- timeoutMs: Number(parallelConfig.task_timeout_ms || 600000),
427
- maxRetries: Number(parallelConfig.task_max_retries ?? 2)
428
- }
429
- })
430
-
431
- stagePlan = planResult.plan
432
- planFrozen = true
433
- gateStatus.plan = {
434
- status: planResult.errors.length ? "warn" : "pass",
435
- errors: planResult.errors
436
- }
437
-
438
- await EventBus.emit({
439
- type: EVENT_TYPES.LONGAGENT_PLAN_FROZEN,
440
- sessionId,
441
- payload: {
442
- planId: stagePlan.planId,
443
- stageCount: stagePlan.stages.length,
444
- errors: planResult.errors
445
- }
446
- })
447
-
448
- await syncState({
449
- stagePlan,
450
- planFrozen: true,
451
- lastMessage: `plan frozen with ${stagePlan.stages.length} stage(s)`
452
- })
453
-
454
- // --- L1.5: Scaffolding Phase ---
455
- const scaffoldEnabled = longagentConfig.scaffold?.enabled !== false
456
- if (scaffoldEnabled && stagePlan.stages.length > 0) {
457
- await setPhase("L1.5", "scaffolding")
458
- currentGate = "scaffold"
459
- await syncState({ lastMessage: "creating stub files for parallel agents" })
460
-
461
- const scaffoldResult = await runScaffoldPhase({
462
- objective: prompt,
463
- stagePlan,
464
- model,
465
- providerType,
466
- sessionId,
467
- configState,
468
- baseUrl,
469
- apiKeyEnv,
470
- agent,
471
- signal,
472
- toolContext
473
- })
474
-
475
- gateStatus.scaffold = {
476
- status: scaffoldResult.scaffolded ? "pass" : "skip",
477
- fileCount: scaffoldResult.fileCount,
478
- files: scaffoldResult.files || []
479
- }
480
-
481
- if (scaffoldResult.usage) {
482
- aggregateUsage.input += scaffoldResult.usage.input || 0
483
- aggregateUsage.output += scaffoldResult.usage.output || 0
484
- aggregateUsage.cacheRead += scaffoldResult.usage.cacheRead || 0
485
- aggregateUsage.cacheWrite += scaffoldResult.usage.cacheWrite || 0
486
- }
487
- if (scaffoldResult.toolEvents?.length) {
488
- toolEvents.push(...scaffoldResult.toolEvents)
489
- }
490
- if (scaffoldResult.files?.length) {
491
- fileChanges = mergeCappedFileChanges(
492
- fileChanges,
493
- scaffoldResult.files.map((f) => ({
494
- path: f, addedLines: 0, removedLines: 0, stageId: "scaffold", taskId: "scaffold"
495
- })),
496
- fileChangesLimit
497
- )
498
- }
499
-
500
- await syncState({ lastMessage: `scaffolded ${scaffoldResult.fileCount} file(s)` })
501
-
502
- await EventBus.emit({
503
- type: EVENT_TYPES.LONGAGENT_SCAFFOLD_COMPLETE,
504
- sessionId,
505
- payload: { fileCount: scaffoldResult.fileCount, files: scaffoldResult.files || [] }
506
- })
507
- }
508
- // --- End L1.5 ---
509
-
510
- let priorContext = ""
511
-
512
- while (stageIndex < stagePlan.stages.length) {
513
- const state = await LongAgentManager.get(sessionId)
514
- if (state?.retryStageId) {
515
- const targetIdx = stagePlan.stages.findIndex((stage) => stage.stageId === state.retryStageId)
516
- if (targetIdx >= 0) {
517
- stageIndex = targetIdx
518
- // Clear progress for target stage AND all subsequent stages
519
- for (let si = targetIdx; si < stagePlan.stages.length; si++) {
520
- const stageTasks = new Set((stagePlan.stages[si].tasks || []).map((task) => task.taskId))
521
- for (const taskId of Object.keys(taskProgress)) {
522
- if (stageTasks.has(taskId)) delete taskProgress[taskId]
523
- }
524
- }
525
- }
526
- await LongAgentManager.update(sessionId, { retryStageId: null })
527
- }
528
- if (state?.stopRequested || signal?.aborted) {
529
- await LongAgentManager.update(sessionId, {
530
- status: "stopped",
531
- phase: currentPhase,
532
- currentGate,
533
- gateStatus,
534
- lastMessage: "stop requested by user"
535
- })
536
- await markSessionStatus(sessionId, "stopped")
537
- break
538
- }
539
-
540
- iteration += 1
541
- const stage = stagePlan.stages[stageIndex]
542
- currentGate = `stage:${stage.stageId}`
543
- await setPhase("L2", `stage_running:${stage.stageId}`)
544
-
545
- if (maxIterations > 0 && iteration >= maxIterations && iteration % Math.max(1, maxIterations) === 0) {
546
- await EventBus.emit({
547
- type: EVENT_TYPES.LONGAGENT_GATE_CHECKED,
548
- sessionId,
549
- payload: { gate: "max_iterations", status: "warn", iteration, threshold: maxIterations }
550
- })
551
- }
552
-
553
- await syncState({
554
- stageStatus: "running",
555
- lastMessage: `running ${stage.stageId} (${stageIndex + 1}/${stagePlan.stages.length})`
556
- })
557
-
558
- const seeded = Object.fromEntries(
559
- stage.tasks
560
- .map((task) => [task.taskId, taskProgress[task.taskId]])
561
- .filter(([, value]) => Boolean(value))
562
- )
563
-
564
- const stageResult = await runStageBarrier({
565
- stage,
566
- sessionId,
567
- config: configState.config,
568
- model,
569
- providerType,
570
- seedTaskProgress: seeded,
571
- objective: prompt,
572
- stageIndex,
573
- stageCount: stagePlan.stages.length,
574
- priorContext
575
- })
576
-
577
- for (const [taskId, progress] of Object.entries(stageResult.taskProgress || {})) {
578
- taskProgress[taskId] = {
579
- ...taskProgress[taskId],
580
- ...progress
581
- }
582
- if (String(progress.lastReply || "").toLowerCase().includes("[task_complete]")) {
583
- completionMarkerSeen = true
584
- }
585
- }
586
- if (stageResult.completionMarkerSeen) completionMarkerSeen = true
587
- if (Array.isArray(stageResult.fileChanges) && stageResult.fileChanges.length) {
588
- fileChanges = mergeCappedFileChanges(fileChanges, stageResult.fileChanges, fileChangesLimit)
589
- }
590
-
591
- gateStatus[stage.stageId] = {
592
- status: stageResult.allSuccess ? "pass" : "fail",
593
- successCount: stageResult.successCount,
594
- failCount: stageResult.failCount,
595
- retryCount: stageResult.retryCount,
596
- remainingFiles: stageResult.remainingFiles
597
- }
598
-
599
- // Build inter-stage knowledge transfer summary
600
- const taskSummaries = Object.values(stageResult.taskProgress || {})
601
- .filter(t => t.lastReply)
602
- .map(t => `[${t.taskId}] ${t.status}: ${t.lastReply.slice(0, 300)}`)
603
- if (taskSummaries.length) {
604
- priorContext += `### Stage ${stageIndex + 1}: ${stage.name || stage.stageId} (${stageResult.allSuccess ? "PASS" : "FAIL"})\n${taskSummaries.join("\n")}\n\n`
605
- }
606
-
607
- lastProgress = {
608
- percentage: Math.round(((stageIndex + (stageResult.allSuccess ? 1 : 0)) / Math.max(1, stagePlan.stages.length)) * 100),
609
- currentStep: stageIndex + (stageResult.allSuccess ? 1 : 0),
610
- totalSteps: stagePlan.stages.length
611
- }
612
-
613
- await syncState({
614
- stageStatus: stageResult.allSuccess ? "completed" : "failed",
615
- lastMessage: stageResult.allSuccess
616
- ? `stage ${stage.stageId} completed`
617
- : `stage ${stage.stageId} failed (${stageResult.failCount})`
618
- })
619
-
620
- // --- Git: auto-commit after successful stage ---
621
- if (gitActive && stageResult.allSuccess && gitConfig.auto_commit_stages !== false) {
622
- const commitMsg = `[kkcode] stage ${stage.stageId} completed (${stageIndex + 1}/${stagePlan.stages.length})`
623
- const commitResult = await git.commitAll(commitMsg, cwd)
624
- if (commitResult.ok && !commitResult.empty) {
625
- await EventBus.emit({
626
- type: EVENT_TYPES.LONGAGENT_GIT_STAGE_COMMITTED,
627
- sessionId,
628
- payload: { stageId: stage.stageId, message: commitMsg }
629
- })
630
- }
631
- }
632
-
633
- if (!stageResult.allSuccess) {
634
- recoveryCount += 1
635
- // Exponential backoff before retry
636
- const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
637
- await new Promise(r => setTimeout(r, backoffMs))
638
- lastGateFailures = Object.values(stageResult.taskProgress || {})
639
- .filter((item) => item.status !== "completed")
640
- .map((item) => `${item.taskId}:${item.lastError || item.status}`)
641
-
642
- await EventBus.emit({
643
- type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
644
- sessionId,
645
- payload: {
646
- reason: `stage_failed:${stage.stageId}`,
647
- stageId: stage.stageId,
648
- recoveryCount,
649
- iteration
650
- }
651
- })
652
-
653
- await setPhase("L2.5", `stage_recover:${stage.stageId}`)
654
- currentGate = "stage_recovery"
655
- await syncState({
656
- status: "recovering",
657
- stageStatus: "recovering",
658
- lastMessage: `recovering stage ${stage.stageId}`
659
- })
660
-
661
- if (recoveryCount >= noProgressLimit) {
662
- await EventBus.emit({
663
- type: EVENT_TYPES.LONGAGENT_ALERT,
664
- sessionId,
665
- payload: {
666
- kind: "retry_storm",
667
- message: `stage recovery count reached ${recoveryCount}`,
668
- recoveryCount,
669
- threshold: noProgressLimit,
670
- iteration
671
- }
672
- })
673
- }
674
-
675
- // Circuit breaker: abort stage after max recovery attempts
676
- const maxStageRecoveries = Number(longagentConfig.max_stage_recoveries ?? 3)
677
- if (recoveryCount >= maxStageRecoveries) {
678
- await setPhase("L2.5", `stage_abort:${stage.stageId}`)
679
- await syncState({
680
- status: "error",
681
- stageStatus: "aborted",
682
- lastMessage: `stage ${stage.stageId} aborted after ${recoveryCount} recovery attempts`
683
- })
684
- await EventBus.emit({
685
- type: EVENT_TYPES.LONGAGENT_ALERT,
686
- sessionId,
687
- payload: {
688
- kind: "stage_aborted",
689
- message: `stage ${stage.stageId} aborted: max recoveries (${maxStageRecoveries}) exceeded`,
690
- recoveryCount,
691
- stageId: stage.stageId
692
- }
693
- })
694
- break
695
- }
696
-
697
- if (longagentConfig.resume_incomplete_files !== false) {
698
- // Reset failed tasks so runStageBarrier will re-dispatch them
699
- for (const [taskId, tp] of Object.entries(taskProgress)) {
700
- if (tp.status === "error") {
701
- taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
702
- }
703
- }
704
- continue
705
- }
706
- break
707
- }
708
-
709
- stageIndex += 1
710
- // Always checkpoint after each stage for reliable recovery
711
- await saveCheckpoint(sessionId, {
712
- name: `stage_${stage.stageId}`,
713
- iteration,
714
- currentPhase,
715
- currentGate,
716
- recoveryCount,
717
- gateStatus,
718
- taskProgress,
719
- stageIndex,
720
- stagePlan,
721
- planFrozen,
722
- lastProgress
723
- })
724
- }
725
-
726
- if (stagePlan && stageIndex >= stagePlan.stages.length) {
727
- // --- Gate preference prompt (first run only) ---
728
- const gatesConfig = longagentConfig.usability_gates || {}
729
- const shouldPromptGates = gatesConfig.prompt_user === "first_run" || gatesConfig.prompt_user === "always"
730
- if (shouldPromptGates && allowQuestion) {
731
- const hasPrefs = await hasGatePreferences()
732
- if (!hasPrefs || gatesConfig.prompt_user === "always") {
733
- const gateAskResult = await processTurnLoop({
734
- prompt: buildGatePromptText(),
735
- mode: "ask", model, providerType, sessionId, configState,
736
- baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
737
- })
738
- const gatePrefs = parseGateSelection(gateAskResult.reply)
739
- await saveGatePreferences(gatePrefs)
740
- // Apply preferences to runtime config
741
- for (const [gate, enabled] of Object.entries(gatePrefs)) {
742
- if (configState.config.agent.longagent.usability_gates[gate]) {
743
- configState.config.agent.longagent.usability_gates[gate].enabled = enabled
744
- }
745
- }
746
- aggregateUsage.input += gateAskResult.usage.input || 0
747
- aggregateUsage.output += gateAskResult.usage.output || 0
748
- } else {
749
- // Apply saved preferences
750
- const savedPrefs = await getGatePreferences()
751
- if (savedPrefs) {
752
- for (const [gate, enabled] of Object.entries(savedPrefs)) {
753
- if (configState.config.agent.longagent.usability_gates[gate]) {
754
- configState.config.agent.longagent.usability_gates[gate].enabled = enabled
755
- }
756
- }
757
- }
758
- }
759
- }
760
-
761
- // --- Structured completion verification ---
762
- const validationLevel = longagentConfig.validation_level || "standard"
763
- let validationReport = null
764
- try {
765
- const validator = await createValidator({ cwd, configState })
766
- validationReport = await validator.validate({ todoState: toolContext?._todoState, level: validationLevel })
767
- gateStatus.validation = {
768
- status: validationReport.verdict === "BLOCK" ? "fail" : "pass",
769
- verdict: validationReport.verdict,
770
- reason: validationReport.verdict === "APPROVE"
771
- ? "all checks passed"
772
- : `${validationReport.results.filter(r => !r.passed).length} check(s) failed`
773
- }
774
- } catch (valErr) {
775
- gateStatus.validation = { status: "warn", reason: `validation skipped: ${valErr.message}` }
776
- }
777
-
778
- const validationContext = validationReport
779
- ? `\n\nVerification Report:\n${validationReport.message}`
780
- : ""
781
-
782
- if (!completionMarkerSeen) {
783
- const markerTurn = await processTurnLoop({
784
- prompt: [
785
- `Objective: ${prompt}`,
786
- "All planned stages are done.",
787
- validationContext,
788
- validationReport?.verdict === "BLOCK"
789
- ? "Verification found critical issues. Fix them, then include [TASK_COMPLETE]."
790
- : "Validate if the task is truly complete. If complete, include [TASK_COMPLETE] exactly once."
791
- ].filter(Boolean).join("\n"),
792
- mode: "agent",
793
- model,
794
- providerType,
795
- sessionId,
796
- configState,
797
- baseUrl,
798
- apiKeyEnv,
799
- agent,
800
- signal,
801
- allowQuestion: plannerConfig.ask_user_after_plan_frozen === true && allowQuestion,
802
- toolContext
803
- })
804
- finalReply = markerTurn.reply
805
- aggregateUsage.input += markerTurn.usage.input || 0
806
- aggregateUsage.output += markerTurn.usage.output || 0
807
- aggregateUsage.cacheRead += markerTurn.usage.cacheRead || 0
808
- aggregateUsage.cacheWrite += markerTurn.usage.cacheWrite || 0
809
- toolEvents.push(...markerTurn.toolEvents)
810
- completionMarkerSeen = isComplete(markerTurn.reply)
811
- gateStatus.completionMarker = {
812
- status: completionMarkerSeen ? "pass" : "warn",
813
- reason: completionMarkerSeen ? "completion marker confirmed" : "marker missing"
814
- }
815
- } else {
816
- gateStatus.completionMarker = {
817
- status: "pass",
818
- reason: "completion marker present in stage outputs"
819
- }
820
- }
821
-
822
- let gateAttempt = 0
823
- while (gateAttempt < maxGateAttempts) {
824
- if (signal?.aborted) break
825
- const preState = await LongAgentManager.get(sessionId)
826
- if (preState?.stopRequested) break
827
-
828
- gateAttempt += 1
829
- currentGate = "usability_gates"
830
- await setPhase("L3", "usability-gate-check")
831
- const gateResult = await runUsabilityGates({
832
- sessionId,
833
- config: configState.config,
834
- cwd: process.cwd(),
835
- iteration
836
- })
837
- gateStatus.usability = gateResult.gates
838
-
839
- if (gateResult.allPass && completionMarkerSeen) {
840
- await LongAgentManager.update(sessionId, {
841
- status: "completed",
842
- phase: currentPhase,
843
- currentGate,
844
- gateStatus,
845
- recoveryCount,
846
- lastGateFailures: [],
847
- iterations: iteration,
848
- lastMessage: "parallel stages and usability gates passed"
849
- })
850
- await markSessionStatus(sessionId, "completed")
851
- break
852
- }
853
-
854
- const failureSummary = summarizeGateFailures(gateResult.failures)
855
- lastGateFailures = gateResult.failures.map((item) => `${item.gate}:${item.reason}`)
856
- recoveryCount += 1
857
- const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
858
- await new Promise(r => setTimeout(r, backoffMs))
859
-
860
- await EventBus.emit({
861
- type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
862
- sessionId,
863
- payload: {
864
- reason: `usability_gates_failed:${failureSummary || "unknown"}`,
865
- gateAttempt,
866
- recoveryCount,
867
- iteration
868
- }
869
- })
870
-
871
- await setPhase("L2.5", "gate_recovery")
872
- currentGate = "gate_recovery"
873
- await syncState({
874
- status: "recovering",
875
- stageStatus: "gate_recovery",
876
- lastMessage: `gate recovery #${gateAttempt}: ${failureSummary || "unknown"}`
877
- })
878
-
879
- // Re-run validation to give remediation agent fresh context
880
- let remediationContext = ""
881
- try {
882
- const reValidator = await createValidator({ cwd, configState })
883
- const reReport = await reValidator.validate({ todoState: toolContext?._todoState, level: validationLevel })
884
- remediationContext = `\n\nCurrent Verification:\n${reReport.message}`
885
- } catch { /* skip */ }
886
-
887
- const remediation = await processTurnLoop({
888
- prompt: [
889
- `Objective: ${prompt}`,
890
- "Usability gates failed.",
891
- `Failures: ${failureSummary || "unknown"}`,
892
- remediationContext,
893
- "Fix ALL failing checks, then include [TASK_COMPLETE] when fully usable."
894
- ].filter(Boolean).join("\n"),
895
- mode: "agent",
896
- model,
897
- providerType,
898
- sessionId,
899
- configState,
900
- baseUrl,
901
- apiKeyEnv,
902
- agent,
903
- signal,
904
- allowQuestion: false,
905
- toolContext
906
- })
907
- finalReply = remediation.reply
908
- aggregateUsage.input += remediation.usage.input || 0
909
- aggregateUsage.output += remediation.usage.output || 0
910
- aggregateUsage.cacheRead += remediation.usage.cacheRead || 0
911
- aggregateUsage.cacheWrite += remediation.usage.cacheWrite || 0
912
- toolEvents.push(...remediation.toolEvents)
913
- if (isComplete(remediation.reply)) {
914
- completionMarkerSeen = true
915
- }
916
- }
917
-
918
- // If gate loop exhausted without success, mark as failed
919
- const postGateState = await LongAgentManager.get(sessionId)
920
- if (postGateState?.status !== "completed" && gateAttempt >= maxGateAttempts) {
921
- await LongAgentManager.update(sessionId, {
922
- status: "failed",
923
- phase: currentPhase,
924
- currentGate,
925
- gateStatus,
926
- recoveryCount,
927
- lastGateFailures,
928
- iterations: iteration,
929
- lastMessage: `max gate recovery attempts (${maxGateAttempts}) exceeded`
930
- })
931
- await markSessionStatus(sessionId, "failed")
932
- }
933
- }
934
-
935
- // --- Git: final commit + merge back to base branch ---
936
- if (gitActive && gitBaseBranch && gitBranch) {
937
- try {
938
- await git.commitAll(`[kkcode] longagent session ${sessionId} completed`, cwd)
939
- if (gitConfig.auto_merge !== false) {
940
- const doneState = await LongAgentManager.get(sessionId)
941
- if (doneState?.status === "completed") {
942
- await git.checkoutBranch(gitBaseBranch, cwd)
943
- const mergeResult = await git.mergeBranch(gitBranch, cwd)
944
- if (mergeResult.ok) {
945
- await git.deleteBranch(gitBranch, cwd)
946
- gateStatus.git = { ...gateStatus.git, merged: true, mergeMessage: mergeResult.message }
947
- await EventBus.emit({
948
- type: EVENT_TYPES.LONGAGENT_GIT_MERGED,
949
- sessionId,
950
- payload: { branch: gitBranch, baseBranch: gitBaseBranch, merged: true }
951
- })
952
- } else {
953
- gateStatus.git = { ...gateStatus.git, merged: false, mergeError: mergeResult.message }
954
- await EventBus.emit({
955
- type: EVENT_TYPES.LONGAGENT_ALERT,
956
- sessionId,
957
- payload: {
958
- kind: "git_merge_failed",
959
- message: `Git merge failed: ${mergeResult.message}. Staying on branch "${gitBranch}" — resolve conflicts manually.`
960
- }
961
- })
962
- // Rollback: return to feature branch so user can resolve manually
963
- const rollback = await git.checkoutBranch(gitBranch, cwd)
964
- if (!rollback.ok) {
965
- gateStatus.git = { ...gateStatus.git, rollbackFailed: true, rollbackError: rollback.message }
966
- }
967
- }
968
- }
969
- }
970
- } catch (gitErr) {
971
- gateStatus.git = { ...gateStatus.git, error: gitErr.message }
972
- // Best-effort: try to return to feature branch
973
- try { await git.checkoutBranch(gitBranch, cwd) } catch { /* already on it or unrecoverable */ }
974
- }
975
- }
976
-
977
- const done = await LongAgentManager.get(sessionId)
978
- const totalElapsed = Math.round((Date.now() - startTime) / 1000)
979
- const stats = stageProgressStats(taskProgress)
980
-
981
- return {
982
- sessionId,
983
- turnId: `turn_long_${Date.now()}`,
984
- reply: finalReply || done?.lastMessage || "longagent stopped",
985
- usage: aggregateUsage,
986
- toolEvents,
987
- iterations: iteration,
988
- recoveryCount,
989
- phase: done?.phase || currentPhase,
990
- gateStatus: done?.gateStatus || gateStatus,
991
- currentGate: done?.currentGate || currentGate,
992
- lastGateFailures: done?.lastGateFailures || lastGateFailures,
993
- status: done?.status || "unknown",
994
- progress: lastProgress,
995
- elapsed: totalElapsed,
996
- stageIndex,
997
- stageCount: stagePlan?.stages?.length || 0,
998
- currentStageId: stagePlan?.stages?.[Math.min(stageIndex, (stagePlan?.stages?.length || 1) - 1)]?.stageId || null,
999
- planFrozen,
1000
- taskProgress,
1001
- fileChanges,
1002
- stageProgress: {
1003
- done: stats.done,
1004
- total: stats.total
1005
- },
1006
- remainingFilesCount: stats.remainingFilesCount
1007
- }
1008
- }
1009
-
1010
- async function runLegacyLongAgent({
1011
- prompt,
1012
- model,
1013
- providerType,
1014
- sessionId,
1015
- configState,
1016
- baseUrl = null,
1017
- apiKeyEnv = null,
1018
- agent = null,
1019
- maxIterations = 0,
1020
- signal = null,
1021
- fromCheckpoint = null,
1022
- output = null,
1023
- allowQuestion = true,
1024
- toolContext = {}
1025
- }) {
1026
- const longagentConfig = configState.config.agent.longagent || {}
1027
- const noProgressWarning = Number(longagentConfig.no_progress_warning || 3)
1028
- const noProgressLimit = Number(longagentConfig.no_progress_limit || 5)
1029
- const heartbeatTimeoutMs = Number(longagentConfig.heartbeat_timeout_ms || 120000)
1030
- const checkpointInterval = Number(longagentConfig.checkpoint_interval || 5)
1031
- const retryStormThreshold = Number(longagentConfig.retry_storm_threshold || DEFAULT_LONGAGENT_RETRY_STORM_THRESHOLD)
1032
- const tokenAlertThreshold = Number(longagentConfig.token_alert_threshold || DEFAULT_LONGAGENT_TOKEN_ALERT_THRESHOLD)
1033
-
1034
- let iteration = 0
1035
- let noProgressCount = 0
1036
- let recoveryCount = 0
1037
- let currentPhase = "L0"
1038
- let currentPrompt = prompt
1039
- let finalReply = ""
1040
- let previousReplyNormalized = ""
1041
- let lastProgress = { percentage: null, currentStep: null, totalSteps: null }
1042
- let gateStatus = {}
1043
- let currentGate = "execution"
1044
- let lastGateFailures = []
1045
- const aggregateUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
1046
- const toolEvents = []
1047
- const startTime = Date.now()
1048
- const lastAlertAtIteration = new Map()
1049
-
1050
- function shouldEmitAlert(key, every = 1) {
1051
- const prev = Number(lastAlertAtIteration.get(key) || 0)
1052
- if (iteration - prev < Math.max(1, every)) return false
1053
- lastAlertAtIteration.set(key, iteration)
1054
- return true
1055
- }
1056
-
1057
- async function emitAlert(kind, message, payload = {}, every = 1) {
1058
- if (!shouldEmitAlert(kind, every)) return
1059
- await EventBus.emit({
1060
- type: EVENT_TYPES.LONGAGENT_ALERT,
1061
- sessionId,
1062
- payload: {
1063
- kind,
1064
- message,
1065
- iteration,
1066
- phase: currentPhase,
1067
- ...payload
1068
- }
1069
- })
1070
- }
1071
-
1072
- async function saveRuntimeCheckpoint(name = "latest") {
1073
- // Save last N tool events as context summaries for recovery
1074
- const recentTools = toolEvents.slice(-20).map(e => `${e.tool}:${e.status || "ok"}`).join(", ")
1075
- await saveCheckpoint(sessionId, {
1076
- name,
1077
- iteration,
1078
- noProgressCount,
1079
- recoveryCount,
1080
- currentPhase,
1081
- currentPrompt,
1082
- previousReplyNormalized,
1083
- lastProgress,
1084
- gateStatus,
1085
- currentGate,
1086
- lastGateFailures,
1087
- recentToolSummary: recentTools
1088
- })
1089
- }
1090
-
1091
- async function setPhase(nextPhase, reason) {
1092
- if (currentPhase === nextPhase) return
1093
- const prevPhase = currentPhase
1094
- currentPhase = nextPhase
1095
- await EventBus.emit({
1096
- type: EVENT_TYPES.LONGAGENT_PHASE_CHANGED,
1097
- sessionId,
1098
- payload: { prevPhase, nextPhase, reason, iteration }
1099
- })
1100
- }
1101
-
1102
- async function enterRecovery(reason) {
1103
- recoveryCount += 1
1104
- // Exponential backoff: 1s → 2s → 4s → ... capped at 30s
1105
- const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
1106
- await new Promise(r => setTimeout(r, backoffMs))
1107
- const checkpoint = await loadCheckpoint(sessionId, "latest")
1108
- lastGateFailures = [reason]
1109
- gateStatus = {
1110
- ...gateStatus,
1111
- lastRecoveryReason: reason,
1112
- recoveryCount
1113
- }
1114
- await saveRuntimeCheckpoint(`recovery_${recoveryCount}`)
1115
- currentPrompt = buildRecoveryPrompt(prompt, finalReply, iteration, reason, lastProgress, checkpoint)
1116
- noProgressCount = 0
1117
- currentGate = "recovery"
1118
- await setPhase("L0", `recovery:${reason}`)
1119
- await LongAgentManager.update(sessionId, {
1120
- status: "recovering",
1121
- phase: currentPhase,
1122
- gateStatus,
1123
- currentGate,
1124
- recoveryCount,
1125
- lastGateFailures,
1126
- heartbeatAt: Date.now(),
1127
- lastMessage: `recovery #${recoveryCount}: ${reason}`,
1128
- iterations: iteration,
1129
- noProgressCount
1130
- })
1131
- await EventBus.emit({
1132
- type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
1133
- sessionId,
1134
- payload: { reason, iteration, recoveryCount }
1135
- })
1136
- await emitAlert("recovery_entered", `entered recovery (${reason})`, { recoveryCount }, 1)
1137
- await markSessionStatus(sessionId, "running-longagent")
1138
- }
1139
-
1140
- if (fromCheckpoint) {
1141
- const cp = await loadCheckpoint(sessionId, fromCheckpoint)
1142
- if (cp) {
1143
- iteration = cp.iteration || 0
1144
- noProgressCount = cp.noProgressCount || 0
1145
- recoveryCount = cp.recoveryCount || 0
1146
- currentPhase = cp.currentPhase || "L0"
1147
- currentPrompt = cp.currentPrompt || prompt
1148
- previousReplyNormalized = cp.previousReplyNormalized || ""
1149
- lastProgress = cp.lastProgress || lastProgress
1150
- gateStatus = cp.gateStatus || {}
1151
- currentGate = cp.currentGate || currentGate
1152
- lastGateFailures = cp.lastGateFailures || []
1153
- }
1154
- }
1155
-
1156
- await LongAgentManager.update(sessionId, {
1157
- status: "running",
1158
- phase: currentPhase,
1159
- gateStatus,
1160
- currentGate,
1161
- recoveryCount,
1162
- stopRequested: false,
1163
- iterations: iteration,
1164
- heartbeatAt: Date.now(),
1165
- lastMessage: fromCheckpoint ? `resumed from checkpoint (iteration ${iteration})` : "longagent started",
1166
- noProgressCount,
1167
- progress: lastProgress
1168
- })
1169
- await markSessionStatus(sessionId, "running-longagent")
1170
-
1171
- if (!isLikelyActionableObjective(prompt)) {
1172
- const blocked = "LongAgent 需要明确的编码目标。请直接描述要实现/修复的内容、涉及文件或验收标准。"
1173
- await LongAgentManager.update(sessionId, {
1174
- status: "blocked",
1175
- phase: "L0",
1176
- currentGate: "execution",
1177
- gateStatus: {
1178
- intake: {
1179
- status: "blocked",
1180
- reason: "objective_not_actionable"
1181
- }
1182
- },
1183
- lastMessage: blocked
1184
- })
1185
- await markSessionStatus(sessionId, "active")
1186
- return {
1187
- sessionId,
1188
- turnId: `turn_long_${Date.now()}`,
1189
- reply: blocked,
1190
- usage: aggregateUsage,
1191
- toolEvents,
1192
- iterations: 0,
1193
- emittedText: false,
1194
- context: null,
1195
- status: "blocked",
1196
- phase: "L0",
1197
- gateStatus: { intake: { status: "blocked", reason: "objective_not_actionable" } },
1198
- currentGate: "execution",
1199
- lastGateFailures: [],
1200
- recoveryCount: 0,
1201
- progress: { percentage: null, currentStep: null, totalSteps: null },
1202
- elapsed: 0
1203
- }
1204
- }
1205
-
1206
- while (true) {
1207
- const state = await LongAgentManager.get(sessionId)
1208
- if (state?.stopRequested || signal?.aborted) {
1209
- await saveRuntimeCheckpoint("stopped")
1210
- await LongAgentManager.update(sessionId, {
1211
- status: "stopped",
1212
- phase: currentPhase,
1213
- gateStatus,
1214
- currentGate,
1215
- recoveryCount,
1216
- lastGateFailures,
1217
- lastMessage: "stop requested by user"
1218
- })
1219
- await markSessionStatus(sessionId, "stopped")
1220
- break
1221
- }
1222
-
1223
- const staleHeartbeat = state?.heartbeatAt && Date.now() - state.heartbeatAt > heartbeatTimeoutMs
1224
- if (staleHeartbeat) {
1225
- await EventBus.emit({
1226
- type: EVENT_TYPES.LONGAGENT_GATE_CHECKED,
1227
- sessionId,
1228
- payload: { gate: "heartbeat", status: "warn", thresholdMs: heartbeatTimeoutMs, iteration }
1229
- })
1230
- await emitAlert("heartbeat_timeout", `heartbeat timeout (${heartbeatTimeoutMs}ms)`)
1231
- await enterRecovery(`heartbeat_timeout(${heartbeatTimeoutMs}ms)`)
1232
- continue
1233
- }
1234
-
1235
- iteration += 1
1236
- const elapsed = Math.round((Date.now() - startTime) / 1000)
1237
- const progressLabel = lastProgress.percentage !== null ? `${lastProgress.percentage}%` : "..."
1238
-
1239
- if (iteration <= 1) await setPhase("L0", "bootstrap")
1240
- else await setPhase("L1", "execution")
1241
-
1242
- if (maxIterations > 0 && iteration >= maxIterations && iteration % Math.max(1, maxIterations) === 0) {
1243
- await EventBus.emit({
1244
- type: EVENT_TYPES.LONGAGENT_GATE_CHECKED,
1245
- sessionId,
1246
- payload: { gate: "max_iterations", status: "warn", iteration, threshold: maxIterations }
1247
- })
1248
- await emitAlert("max_iterations_warn", `iteration reached warning threshold ${maxIterations}`, { maxIterations }, maxIterations)
1249
- }
1250
-
1251
- await LongAgentManager.update(sessionId, {
1252
- status: "running",
1253
- phase: currentPhase,
1254
- gateStatus,
1255
- currentGate,
1256
- recoveryCount,
1257
- lastGateFailures,
1258
- iterations: iteration,
1259
- heartbeatAt: Date.now(),
1260
- lastMessage: `iteration ${iteration}${maxIterations > 0 ? "/" + maxIterations : ""} | phase=${currentPhase} | gate=${currentGate} | progress: ${progressLabel} | elapsed: ${elapsed}s`,
1261
- noProgressCount,
1262
- progress: lastProgress
1263
- })
1264
- await EventBus.emit({
1265
- type: EVENT_TYPES.LONGAGENT_HEARTBEAT,
1266
- sessionId,
1267
- payload: {
1268
- iteration,
1269
- maxIterations,
1270
- noProgressCount,
1271
- progress: lastProgress,
1272
- elapsed,
1273
- phase: currentPhase,
1274
- gate: currentGate
1275
- }
1276
- })
1277
-
1278
- if (typeof output?.write === "function") {
1279
- output.write(`\n--- Iteration ${iteration}${maxIterations > 0 ? "/" + maxIterations : ""} | phase=${currentPhase} | gate=${currentGate} | progress: ${progressLabel} | elapsed: ${elapsed}s ---\n`)
1280
- }
1281
-
1282
- const turn = await processTurnLoop({
1283
- prompt: currentPrompt,
1284
- mode: "agent",
1285
- model,
1286
- providerType,
1287
- sessionId,
1288
- configState,
1289
- baseUrl,
1290
- apiKeyEnv,
1291
- agent,
1292
- signal,
1293
- output,
1294
- allowQuestion,
1295
- toolContext
1296
- })
1297
-
1298
- finalReply = turn.reply
1299
- aggregateUsage.input += turn.usage.input || 0
1300
- aggregateUsage.output += turn.usage.output || 0
1301
- aggregateUsage.cacheRead += turn.usage.cacheRead || 0
1302
- aggregateUsage.cacheWrite += turn.usage.cacheWrite || 0
1303
- toolEvents.push(...turn.toolEvents)
1304
-
1305
- const progressDetection = detectProgress(turn.reply, previousReplyNormalized, turn.toolEvents?.length || 0)
1306
- if (progressDetection.structured.hasStructuredSignal) {
1307
- lastProgress = {
1308
- percentage: progressDetection.structured.percentage ?? lastProgress.percentage,
1309
- currentStep: progressDetection.structured.currentStep ?? lastProgress.currentStep,
1310
- totalSteps: progressDetection.structured.totalSteps ?? lastProgress.totalSteps
1311
- }
1312
- }
1313
-
1314
- if (progressDetection.hasProgress) {
1315
- noProgressCount = 0
1316
- gateStatus = { ...gateStatus, progress: "pass" }
1317
- } else {
1318
- noProgressCount += 1
1319
- gateStatus = { ...gateStatus, progress: "warn" }
1320
- }
1321
- previousReplyNormalized = normalizeReply(turn.reply)
1322
-
1323
- const totalTokens = aggregateUsage.input + aggregateUsage.output
1324
- if (totalTokens >= tokenAlertThreshold) {
1325
- await emitAlert(
1326
- "token_pressure",
1327
- `high token usage (${totalTokens})`,
1328
- { totalTokens, threshold: tokenAlertThreshold },
1329
- 3
1330
- )
1331
- }
1332
- if (recoveryCount >= retryStormThreshold) {
1333
- await emitAlert(
1334
- "retry_storm",
1335
- `recovery entered ${recoveryCount} times`,
1336
- { recoveryCount, threshold: retryStormThreshold },
1337
- 2
1338
- )
1339
- }
1340
-
1341
- if (isComplete(turn.reply)) {
1342
- currentGate = "usability_gates"
1343
- await setPhase("L2", "usability-gate-check")
1344
- const gateResult = await runUsabilityGates({
1345
- sessionId,
1346
- config: configState.config,
1347
- cwd: process.cwd(),
1348
- iteration
1349
- })
1350
- gateStatus = gateResult.gates
1351
-
1352
- if (gateResult.allPass) {
1353
- gateStatus = {
1354
- ...gateStatus,
1355
- completionMarker: { status: "pass", reason: "completion marker confirmed by gates" }
1356
- }
1357
- await setPhase("L3", "completion-marker")
1358
- await LongAgentManager.update(sessionId, {
1359
- status: "completed",
1360
- phase: currentPhase,
1361
- gateStatus,
1362
- currentGate,
1363
- recoveryCount,
1364
- lastGateFailures: [],
1365
- heartbeatAt: Date.now(),
1366
- lastMessage: "completion marker detected and usability gates passed",
1367
- noProgressCount,
1368
- progress: lastProgress
1369
- })
1370
- await markSessionStatus(sessionId, "completed")
1371
- break
1372
- }
1373
-
1374
- const failureSummary = summarizeGateFailures(gateResult.failures)
1375
- lastGateFailures = gateResult.failures.map((item) => `${item.gate}:${item.reason}`)
1376
- await emitAlert(
1377
- "gate_failed",
1378
- `usability gates failed: ${failureSummary}`,
1379
- { failures: gateResult.failures },
1380
- 1
1381
- )
1382
- await enterRecovery(`usability_gates_failed(${failureSummary || "unknown"})`)
1383
- continue
1384
- }
1385
-
1386
- if (noProgressCount >= noProgressLimit) {
1387
- await EventBus.emit({
1388
- type: EVENT_TYPES.LONGAGENT_GATE_CHECKED,
1389
- sessionId,
1390
- payload: { gate: "progress", status: "warn", noProgressCount, limit: noProgressLimit, iteration }
1391
- })
1392
- await emitAlert(
1393
- "no_progress_limit",
1394
- `no progress limit reached (${noProgressCount}/${noProgressLimit})`,
1395
- { noProgressCount, noProgressLimit }
1396
- )
1397
- await enterRecovery(`no_progress_limit(${noProgressCount}/${noProgressLimit})`)
1398
- continue
1399
- }
1400
-
1401
- if (noProgressCount >= noProgressWarning) {
1402
- if (noProgressCount === noProgressWarning || noProgressCount % noProgressWarning === 0) {
1403
- await EventBus.emit({
1404
- type: EVENT_TYPES.LONGAGENT_GATE_CHECKED,
1405
- sessionId,
1406
- payload: {
1407
- gate: "progress",
1408
- status: "warn",
1409
- noProgressCount,
1410
- warningThreshold: noProgressWarning,
1411
- iteration
1412
- }
1413
- })
1414
- }
1415
- }
1416
-
1417
- if (checkpointInterval > 0 && iteration % checkpointInterval === 0) {
1418
- await saveRuntimeCheckpoint(`cp_${iteration}`)
1419
- }
1420
-
1421
- currentGate = "execution"
1422
- currentPrompt = buildNextPrompt(prompt, turn.reply, iteration, lastProgress)
1423
- }
1424
-
1425
- const done = await LongAgentManager.get(sessionId)
1426
- const totalElapsed = Math.round((Date.now() - startTime) / 1000)
1427
- await EventBus.emit({
1428
- type: EVENT_TYPES.LONGAGENT_ALERT,
1429
- sessionId,
1430
- payload: {
1431
- kind: "summary",
1432
- message: `LongAgent ${done?.status || "unknown"} | ${iteration} iterations | ${totalElapsed}s`,
1433
- iteration,
1434
- elapsed: totalElapsed
1435
- }
1436
- })
1437
- return {
1438
- sessionId,
1439
- turnId: `turn_long_${Date.now()}`,
1440
- reply: finalReply || done?.lastMessage || "longagent stopped",
1441
- usage: aggregateUsage,
1442
- toolEvents,
1443
- iterations: iteration,
1444
- recoveryCount,
1445
- phase: done?.phase || currentPhase,
1446
- gateStatus: done?.gateStatus || gateStatus,
1447
- currentGate: done?.currentGate || currentGate,
1448
- lastGateFailures: done?.lastGateFailures || lastGateFailures,
1449
- status: done?.status || "unknown",
1450
- progress: lastProgress,
1451
- elapsed: totalElapsed
1452
- }
1453
- }
1454
-
1455
- export async function runLongAgent(args) {
1456
- const longagentConfig = args?.configState?.config?.agent?.longagent || {}
1457
- const parallelEnabled = longagentConfig.parallel?.enabled === true
1458
- if (parallelEnabled) {
1459
- return runParallelLongAgent(args)
1460
- }
1461
- return runLegacyLongAgent(args)
1462
- }
1
+ import { LongAgentManager } from "../orchestration/longagent-manager.mjs"
2
+ import { processTurnLoop } from "./loop.mjs"
3
+ import { markSessionStatus } from "./store.mjs"
4
+ import { EventBus } from "../core/events.mjs"
5
+ import { EVENT_TYPES } from "../core/constants.mjs"
6
+ import { run4StageLongAgent } from "./longagent-4stage.mjs"
7
+ import { runHybridLongAgent } from "./longagent-hybrid.mjs"
8
+ import {
9
+ isComplete,
10
+ isLikelyActionableObjective,
11
+ mergeCappedFileChanges,
12
+ stageProgressStats,
13
+ summarizeGateFailures,
14
+ LONGAGENT_FILE_CHANGES_LIMIT,
15
+ createStuckTracker
16
+ } from "./longagent-utils.mjs"
17
+ import { saveCheckpoint, loadCheckpoint, cleanupCheckpoints } from "./checkpoint.mjs"
18
+ import {
19
+ runUsabilityGates,
20
+ hasGatePreferences,
21
+ getGatePreferences,
22
+ saveGatePreferences,
23
+ buildGatePromptText,
24
+ parseGateSelection
25
+ } from "./usability-gates.mjs"
26
+ import { runIntakeDialogue, buildStagePlan } from "./longagent-plan.mjs"
27
+ import { runStageBarrier } from "../orchestration/stage-scheduler.mjs"
28
+ import { runScaffoldPhase } from "./longagent-scaffold.mjs"
29
+ import { createValidator } from "./task-validator.mjs"
30
+ import * as git from "../util/git.mjs"
31
+
32
+ async function runParallelLongAgent({
33
+ prompt,
34
+ model,
35
+ providerType,
36
+ sessionId,
37
+ configState,
38
+ baseUrl = null,
39
+ apiKeyEnv = null,
40
+ agent = null,
41
+ maxIterations: maxIterationsParam = 0,
42
+ signal = null,
43
+ output = null,
44
+ allowQuestion = true,
45
+ toolContext = {}
46
+ }) {
47
+ const longagentConfig = configState.config.agent.longagent || {}
48
+ const maxIterations = Number(longagentConfig.max_iterations || maxIterationsParam)
49
+ const plannerConfig = longagentConfig.planner || {}
50
+ const intakeConfig = plannerConfig.intake_questions || {}
51
+ const parallelConfig = longagentConfig.parallel || {}
52
+ const noProgressLimit = Number(longagentConfig.no_progress_limit || 5)
53
+ const checkpointInterval = Number(longagentConfig.checkpoint_interval || 5)
54
+ const maxGateAttempts = Number(longagentConfig.max_gate_attempts || 5)
55
+
56
+ const gitConfig = longagentConfig.git || {}
57
+ const gitEnabled = gitConfig.enabled === true || gitConfig.enabled === "ask"
58
+ const gitAsk = gitConfig.enabled === "ask"
59
+
60
+ let iteration = 0
61
+ let recoveryCount = 0
62
+ let currentPhase = "L0"
63
+ let currentGate = "intake"
64
+ let gateStatus = {}
65
+ let lastGateFailures = []
66
+ let lastProgress = { percentage: 0, currentStep: 0, totalSteps: 0 }
67
+ let finalReply = ""
68
+ let stageIndex = 0
69
+ let planFrozen = false
70
+ let stagePlan = null
71
+ let taskProgress = {}
72
+ let fileChanges = []
73
+ const fileChangesLimit = Math.max(20, Number(longagentConfig.file_changes_limit || LONGAGENT_FILE_CHANGES_LIMIT))
74
+ const aggregateUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
75
+ const toolEvents = []
76
+ const startTime = Date.now()
77
+ let completionMarkerSeen = false
78
+ let gitBranch = null
79
+ let gitBaseBranch = null
80
+ let gitActive = false
81
+
82
+ async function setPhase(nextPhase, reason = "") {
83
+ if (currentPhase === nextPhase) return
84
+ const prevPhase = currentPhase
85
+ currentPhase = nextPhase
86
+ await EventBus.emit({
87
+ type: EVENT_TYPES.LONGAGENT_PHASE_CHANGED,
88
+ sessionId,
89
+ payload: { prevPhase, nextPhase, reason, iteration }
90
+ })
91
+ }
92
+
93
+ async function syncState(patch = {}) {
94
+ const stats = stageProgressStats(taskProgress)
95
+ const stageCount = stagePlan?.stages?.length || 0
96
+ const currentStage = stagePlan?.stages?.[stageIndex] || null
97
+ await LongAgentManager.update(sessionId, {
98
+ status: patch.status || "running",
99
+ phase: currentPhase,
100
+ gateStatus,
101
+ currentGate,
102
+ recoveryCount,
103
+ lastGateFailures,
104
+ iterations: iteration,
105
+ heartbeatAt: Date.now(),
106
+ noProgressCount: 0,
107
+ progress: lastProgress,
108
+ planFrozen,
109
+ currentStageId: currentStage?.stageId || null,
110
+ stageIndex,
111
+ stageCount,
112
+ stageStatus: patch.stageStatus || null,
113
+ taskProgress,
114
+ remainingFiles: stats.remainingFiles,
115
+ remainingFilesCount: stats.remainingFilesCount,
116
+ stageProgress: {
117
+ done: stats.done,
118
+ total: stats.total
119
+ },
120
+ ...patch
121
+ })
122
+ }
123
+
124
+ await markSessionStatus(sessionId, "running-longagent")
125
+ await syncState({
126
+ status: "running",
127
+ lastMessage: "longagent parallel mode started",
128
+ stopRequested: false
129
+ })
130
+
131
+ if (!isLikelyActionableObjective(prompt)) {
132
+ const blocked = "LongAgent 需要明确的编码目标。请直接描述要实现/修复的内容、涉及文件或验收标准。"
133
+ await LongAgentManager.update(sessionId, {
134
+ status: "blocked",
135
+ phase: "L0",
136
+ currentGate: "intake",
137
+ gateStatus: {
138
+ intake: {
139
+ status: "blocked",
140
+ reason: "objective_not_actionable"
141
+ }
142
+ },
143
+ lastMessage: blocked
144
+ })
145
+ await markSessionStatus(sessionId, "active")
146
+ return {
147
+ sessionId,
148
+ turnId: `turn_long_${Date.now()}`,
149
+ reply: blocked,
150
+ usage: aggregateUsage,
151
+ toolEvents,
152
+ iterations: 0,
153
+ emittedText: false,
154
+ context: null,
155
+ status: "blocked",
156
+ phase: "L0",
157
+ gateStatus: { intake: { status: "blocked", reason: "objective_not_actionable" } },
158
+ currentGate: "intake",
159
+ lastGateFailures: [],
160
+ recoveryCount: 0,
161
+ progress: { percentage: 0, currentStep: 0, totalSteps: 0 },
162
+ elapsed: 0,
163
+ stageIndex: 0,
164
+ stageCount: 0,
165
+ currentStageId: null,
166
+ planFrozen: false,
167
+ taskProgress: {},
168
+ stageProgress: { done: 0, total: 0, remainingFiles: [], remainingFilesCount: 0 },
169
+ fileChanges: [],
170
+ remainingFilesCount: 0
171
+ }
172
+ }
173
+
174
+ await EventBus.emit({
175
+ type: EVENT_TYPES.LONGAGENT_INTAKE_STARTED,
176
+ sessionId,
177
+ payload: { objective: prompt }
178
+ })
179
+
180
+ const intakeEnabled = intakeConfig.enabled !== false
181
+ let intakeSummary = prompt
182
+ if (intakeEnabled) {
183
+ await setPhase("L0", "intake")
184
+ const intake = await runIntakeDialogue({
185
+ objective: prompt,
186
+ model,
187
+ providerType,
188
+ sessionId,
189
+ configState,
190
+ baseUrl,
191
+ apiKeyEnv,
192
+ agent,
193
+ signal,
194
+ maxRounds: Number(intakeConfig.max_rounds || 6)
195
+ })
196
+ intakeSummary = intake.summary || prompt
197
+ gateStatus.intake = {
198
+ status: "pass",
199
+ rounds: intake.transcript.length,
200
+ summary: intakeSummary.slice(0, 500)
201
+ }
202
+ await syncState({
203
+ lastMessage: `intake completed (${intake.transcript.length} qa pairs)`
204
+ })
205
+ }
206
+
207
+ // --- Git branch creation (after intake, before planning) ---
208
+ const cwd = process.cwd()
209
+ const inGitRepo = gitEnabled && await git.isGitRepo(cwd)
210
+ if (inGitRepo) {
211
+ let userWantsGit = !gitAsk
212
+ if (gitAsk && allowQuestion) {
213
+ // Ask user via a lightweight turn
214
+ const askResult = await processTurnLoop({
215
+ prompt: [
216
+ "[SYSTEM] Git 分支管理已就绪。是否为本次 LongAgent 会话创建独立分支?",
217
+ "回复 yes/是 启用,no/否 跳过。",
218
+ "启用后:自动创建特性分支 每阶段自动提交 → 完成后合并回主分支。"
219
+ ].join("\n"),
220
+ mode: "ask", model, providerType, sessionId, configState,
221
+ baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
222
+ })
223
+ const answer = String(askResult.reply || "").toLowerCase().trim()
224
+ userWantsGit = ["yes", "是", "y", "ok", "好", "确认", "开启", "启用"].some(k => answer.includes(k))
225
+ aggregateUsage.input += askResult.usage.input || 0
226
+ aggregateUsage.output += askResult.usage.output || 0
227
+ }
228
+
229
+ if (userWantsGit) {
230
+ gitBaseBranch = await git.currentBranch(cwd)
231
+ const branchName = git.generateBranchName(sessionId, prompt)
232
+ const clean = await git.isClean(cwd)
233
+ let stashed = false
234
+ if (!clean) {
235
+ const stashResult = await git.stash("kkcode-auto-stash-before-branch", cwd)
236
+ stashed = stashResult.ok
237
+ }
238
+ try {
239
+ const created = await git.createBranch(branchName, cwd)
240
+ if (created.ok) {
241
+ gitBranch = branchName
242
+ gitActive = true
243
+ gateStatus.git = { status: "pass", branch: branchName, baseBranch: gitBaseBranch }
244
+ await EventBus.emit({
245
+ type: EVENT_TYPES.LONGAGENT_GIT_BRANCH_CREATED,
246
+ sessionId,
247
+ payload: { branch: branchName, baseBranch: gitBaseBranch }
248
+ })
249
+ await syncState({ lastMessage: `git branch created: ${branchName}` })
250
+ } else {
251
+ gateStatus.git = { status: "warn", reason: created.message }
252
+ }
253
+ } finally {
254
+ if (stashed) {
255
+ await git.stashPop(cwd).catch(() => {})
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ await setPhase("L1", "plan_frozen")
262
+ currentGate = "planning"
263
+ const planResult = await buildStagePlan({
264
+ objective: prompt,
265
+ intakeSummary,
266
+ model,
267
+ providerType,
268
+ sessionId,
269
+ configState,
270
+ baseUrl,
271
+ apiKeyEnv,
272
+ agent,
273
+ signal,
274
+ defaults: {
275
+ timeoutMs: Number(parallelConfig.task_timeout_ms || 600000),
276
+ maxRetries: Number(parallelConfig.task_max_retries ?? 2)
277
+ }
278
+ })
279
+
280
+ stagePlan = planResult.plan
281
+ planFrozen = true
282
+ gateStatus.plan = {
283
+ status: planResult.errors.length ? "warn" : "pass",
284
+ errors: planResult.errors
285
+ }
286
+
287
+ await EventBus.emit({
288
+ type: EVENT_TYPES.LONGAGENT_PLAN_FROZEN,
289
+ sessionId,
290
+ payload: {
291
+ planId: stagePlan.planId,
292
+ stageCount: stagePlan.stages.length,
293
+ errors: planResult.errors
294
+ }
295
+ })
296
+
297
+ await syncState({
298
+ stagePlan,
299
+ planFrozen: true,
300
+ lastMessage: `plan frozen with ${stagePlan.stages.length} stage(s)`
301
+ })
302
+
303
+ // --- L1.5: Scaffolding Phase ---
304
+ const scaffoldEnabled = longagentConfig.scaffold?.enabled !== false
305
+ if (scaffoldEnabled && stagePlan.stages.length > 0) {
306
+ await setPhase("L1.5", "scaffolding")
307
+ currentGate = "scaffold"
308
+ await syncState({ lastMessage: "creating stub files for parallel agents" })
309
+
310
+ const scaffoldResult = await runScaffoldPhase({
311
+ objective: prompt,
312
+ stagePlan,
313
+ model,
314
+ providerType,
315
+ sessionId,
316
+ configState,
317
+ baseUrl,
318
+ apiKeyEnv,
319
+ agent,
320
+ signal,
321
+ toolContext
322
+ })
323
+
324
+ gateStatus.scaffold = {
325
+ status: scaffoldResult.scaffolded ? "pass" : "skip",
326
+ fileCount: scaffoldResult.fileCount,
327
+ files: scaffoldResult.files || []
328
+ }
329
+
330
+ if (scaffoldResult.usage) {
331
+ aggregateUsage.input += scaffoldResult.usage.input || 0
332
+ aggregateUsage.output += scaffoldResult.usage.output || 0
333
+ aggregateUsage.cacheRead += scaffoldResult.usage.cacheRead || 0
334
+ aggregateUsage.cacheWrite += scaffoldResult.usage.cacheWrite || 0
335
+ }
336
+ if (scaffoldResult.toolEvents?.length) {
337
+ toolEvents.push(...scaffoldResult.toolEvents)
338
+ }
339
+ if (scaffoldResult.files?.length) {
340
+ fileChanges = mergeCappedFileChanges(
341
+ fileChanges,
342
+ scaffoldResult.files.map((f) => ({
343
+ path: f, addedLines: 0, removedLines: 0, stageId: "scaffold", taskId: "scaffold"
344
+ })),
345
+ fileChangesLimit
346
+ )
347
+ }
348
+
349
+ await syncState({ lastMessage: `scaffolded ${scaffoldResult.fileCount} file(s)` })
350
+
351
+ await EventBus.emit({
352
+ type: EVENT_TYPES.LONGAGENT_SCAFFOLD_COMPLETE,
353
+ sessionId,
354
+ payload: { fileCount: scaffoldResult.fileCount, files: scaffoldResult.files || [] }
355
+ })
356
+ }
357
+ // --- End L1.5 ---
358
+
359
+ let priorContext = ""
360
+
361
+ while (stageIndex < stagePlan.stages.length) {
362
+ const state = await LongAgentManager.get(sessionId)
363
+ if (state?.retryStageId) {
364
+ const targetIdx = stagePlan.stages.findIndex((stage) => stage.stageId === state.retryStageId)
365
+ // Atomically clear retryStageId to prevent race with concurrent updates
366
+ await LongAgentManager.update(sessionId, { retryStageId: null })
367
+ if (targetIdx >= 0) {
368
+ stageIndex = targetIdx
369
+ // Clear progress for target stage AND all subsequent stages
370
+ for (let si = targetIdx; si < stagePlan.stages.length; si++) {
371
+ const stageTasks = new Set((stagePlan.stages[si].tasks || []).map((task) => task.taskId))
372
+ for (const taskId of Object.keys(taskProgress)) {
373
+ if (stageTasks.has(taskId)) delete taskProgress[taskId]
374
+ }
375
+ }
376
+ }
377
+ }
378
+ if (state?.stopRequested || signal?.aborted) {
379
+ await LongAgentManager.update(sessionId, {
380
+ status: "stopped",
381
+ phase: currentPhase,
382
+ currentGate,
383
+ gateStatus,
384
+ lastMessage: "stop requested by user"
385
+ })
386
+ await markSessionStatus(sessionId, "stopped")
387
+ break
388
+ }
389
+
390
+ iteration += 1
391
+ const stage = stagePlan.stages[stageIndex]
392
+ currentGate = `stage:${stage.stageId}`
393
+ await setPhase("L2", `stage_running:${stage.stageId}`)
394
+
395
+ if (maxIterations > 0 && iteration >= maxIterations && iteration % Math.max(1, maxIterations) === 0) {
396
+ await EventBus.emit({
397
+ type: EVENT_TYPES.LONGAGENT_GATE_CHECKED,
398
+ sessionId,
399
+ payload: { gate: "max_iterations", status: "warn", iteration, threshold: maxIterations }
400
+ })
401
+ }
402
+
403
+ await syncState({
404
+ stageStatus: "running",
405
+ lastMessage: `running ${stage.stageId} (${stageIndex + 1}/${stagePlan.stages.length})`
406
+ })
407
+
408
+ const seeded = Object.fromEntries(
409
+ stage.tasks
410
+ .map((task) => [task.taskId, taskProgress[task.taskId]])
411
+ .filter(([, value]) => Boolean(value))
412
+ )
413
+
414
+ const stageResult = await runStageBarrier({
415
+ stage,
416
+ sessionId,
417
+ config: configState.config,
418
+ model,
419
+ providerType,
420
+ seedTaskProgress: seeded,
421
+ objective: prompt,
422
+ stageIndex,
423
+ stageCount: stagePlan.stages.length,
424
+ priorContext
425
+ })
426
+
427
+ for (const [taskId, progress] of Object.entries(stageResult.taskProgress || {})) {
428
+ taskProgress[taskId] = {
429
+ ...taskProgress[taskId],
430
+ ...progress
431
+ }
432
+ if (String(progress.lastReply || "").toLowerCase().includes("[task_complete]")) {
433
+ completionMarkerSeen = true
434
+ }
435
+ }
436
+ if (stageResult.completionMarkerSeen) completionMarkerSeen = true
437
+ if (Array.isArray(stageResult.fileChanges) && stageResult.fileChanges.length) {
438
+ fileChanges = mergeCappedFileChanges(fileChanges, stageResult.fileChanges, fileChangesLimit)
439
+ }
440
+
441
+ gateStatus[stage.stageId] = {
442
+ status: stageResult.allSuccess ? "pass" : "fail",
443
+ successCount: stageResult.successCount,
444
+ failCount: stageResult.failCount,
445
+ retryCount: stageResult.retryCount,
446
+ remainingFiles: stageResult.remainingFiles
447
+ }
448
+
449
+ // Build inter-stage knowledge transfer summary
450
+ const taskSummaries = Object.values(stageResult.taskProgress || {})
451
+ .filter(t => t.lastReply)
452
+ .map(t => `[${t.taskId}] ${t.status}: ${t.lastReply.slice(0, 300)}`)
453
+ if (taskSummaries.length) {
454
+ priorContext += `### Stage ${stageIndex + 1}: ${stage.name || stage.stageId} (${stageResult.allSuccess ? "PASS" : "FAIL"})\n${taskSummaries.join("\n")}\n\n`
455
+ }
456
+
457
+ lastProgress = {
458
+ percentage: Math.round(((stageIndex + (stageResult.allSuccess ? 1 : 0)) / Math.max(1, stagePlan.stages.length)) * 100),
459
+ currentStep: stageIndex + (stageResult.allSuccess ? 1 : 0),
460
+ totalSteps: stagePlan.stages.length
461
+ }
462
+
463
+ await syncState({
464
+ stageStatus: stageResult.allSuccess ? "completed" : "failed",
465
+ lastMessage: stageResult.allSuccess
466
+ ? `stage ${stage.stageId} completed`
467
+ : `stage ${stage.stageId} failed (${stageResult.failCount})`
468
+ })
469
+
470
+ // --- Git: auto-commit after successful stage ---
471
+ if (gitActive && stageResult.allSuccess && gitConfig.auto_commit_stages !== false) {
472
+ const commitMsg = `[kkcode] stage ${stage.stageId} completed (${stageIndex + 1}/${stagePlan.stages.length})`
473
+ const commitResult = await git.commitAll(commitMsg, cwd)
474
+ if (commitResult.ok && !commitResult.empty) {
475
+ await EventBus.emit({
476
+ type: EVENT_TYPES.LONGAGENT_GIT_STAGE_COMMITTED,
477
+ sessionId,
478
+ payload: { stageId: stage.stageId, message: commitMsg }
479
+ })
480
+ }
481
+ }
482
+
483
+ if (!stageResult.allSuccess) {
484
+ recoveryCount += 1
485
+ // Exponential backoff before retry
486
+ const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
487
+ await new Promise(r => setTimeout(r, backoffMs))
488
+ lastGateFailures = Object.values(stageResult.taskProgress || {})
489
+ .filter((item) => item.status !== "completed")
490
+ .map((item) => `${item.taskId}:${item.lastError || item.status}`)
491
+
492
+ await EventBus.emit({
493
+ type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
494
+ sessionId,
495
+ payload: {
496
+ reason: `stage_failed:${stage.stageId}`,
497
+ stageId: stage.stageId,
498
+ recoveryCount,
499
+ iteration
500
+ }
501
+ })
502
+
503
+ await setPhase("L2.5", `stage_recover:${stage.stageId}`)
504
+ currentGate = "stage_recovery"
505
+ await syncState({
506
+ status: "recovering",
507
+ stageStatus: "recovering",
508
+ lastMessage: `recovering stage ${stage.stageId}`
509
+ })
510
+
511
+ if (recoveryCount >= noProgressLimit) {
512
+ await EventBus.emit({
513
+ type: EVENT_TYPES.LONGAGENT_ALERT,
514
+ sessionId,
515
+ payload: {
516
+ kind: "retry_storm",
517
+ message: `stage recovery count reached ${recoveryCount}`,
518
+ recoveryCount,
519
+ threshold: noProgressLimit,
520
+ iteration
521
+ }
522
+ })
523
+ }
524
+
525
+ // Circuit breaker: abort stage after max recovery attempts
526
+ const maxStageRecoveries = Number(longagentConfig.max_stage_recoveries ?? 3)
527
+ if (recoveryCount >= maxStageRecoveries) {
528
+ await setPhase("L2.5", `stage_abort:${stage.stageId}`)
529
+ await syncState({
530
+ status: "error",
531
+ stageStatus: "aborted",
532
+ lastMessage: `stage ${stage.stageId} aborted after ${recoveryCount} recovery attempts`
533
+ })
534
+ await EventBus.emit({
535
+ type: EVENT_TYPES.LONGAGENT_ALERT,
536
+ sessionId,
537
+ payload: {
538
+ kind: "stage_aborted",
539
+ message: `stage ${stage.stageId} aborted: max recoveries (${maxStageRecoveries}) exceeded`,
540
+ recoveryCount,
541
+ stageId: stage.stageId
542
+ }
543
+ })
544
+ break
545
+ }
546
+
547
+ if (longagentConfig.resume_incomplete_files !== false) {
548
+ // Reset failed tasks so runStageBarrier will re-dispatch them
549
+ for (const [taskId, tp] of Object.entries(taskProgress)) {
550
+ if (tp.status === "error") {
551
+ taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
552
+ }
553
+ }
554
+ continue
555
+ }
556
+ break
557
+ }
558
+
559
+ stageIndex += 1
560
+ // Always checkpoint after each stage for reliable recovery
561
+ await saveCheckpoint(sessionId, {
562
+ name: `stage_${stage.stageId}`,
563
+ iteration,
564
+ currentPhase,
565
+ currentGate,
566
+ recoveryCount,
567
+ gateStatus,
568
+ taskProgress,
569
+ stageIndex,
570
+ stagePlan,
571
+ planFrozen,
572
+ lastProgress
573
+ })
574
+ }
575
+
576
+ if (stagePlan && stageIndex >= stagePlan.stages.length) {
577
+ // --- Gate preference prompt (first run only) ---
578
+ const gatesConfig = longagentConfig.usability_gates || {}
579
+ const shouldPromptGates = gatesConfig.prompt_user === "first_run" || gatesConfig.prompt_user === "always"
580
+ if (shouldPromptGates && allowQuestion) {
581
+ const hasPrefs = await hasGatePreferences()
582
+ if (!hasPrefs || gatesConfig.prompt_user === "always") {
583
+ const gateAskResult = await processTurnLoop({
584
+ prompt: buildGatePromptText(),
585
+ mode: "ask", model, providerType, sessionId, configState,
586
+ baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
587
+ })
588
+ const gatePrefs = parseGateSelection(gateAskResult.reply)
589
+ await saveGatePreferences(gatePrefs)
590
+ // Apply preferences to runtime config
591
+ for (const [gate, enabled] of Object.entries(gatePrefs)) {
592
+ if (configState.config.agent.longagent.usability_gates[gate]) {
593
+ configState.config.agent.longagent.usability_gates[gate].enabled = enabled
594
+ }
595
+ }
596
+ aggregateUsage.input += gateAskResult.usage.input || 0
597
+ aggregateUsage.output += gateAskResult.usage.output || 0
598
+ } else {
599
+ // Apply saved preferences
600
+ const savedPrefs = await getGatePreferences()
601
+ if (savedPrefs) {
602
+ for (const [gate, enabled] of Object.entries(savedPrefs)) {
603
+ if (configState.config.agent.longagent.usability_gates[gate]) {
604
+ configState.config.agent.longagent.usability_gates[gate].enabled = enabled
605
+ }
606
+ }
607
+ }
608
+ }
609
+ }
610
+
611
+ // --- Structured completion verification ---
612
+ const validationLevel = longagentConfig.validation_level || "standard"
613
+ let validationReport = null
614
+ try {
615
+ const validator = await createValidator({ cwd, configState })
616
+ validationReport = await validator.validate({ todoState: toolContext?._todoState, level: validationLevel })
617
+ gateStatus.validation = {
618
+ status: validationReport.verdict === "BLOCK" ? "fail" : "pass",
619
+ verdict: validationReport.verdict,
620
+ reason: validationReport.verdict === "APPROVE"
621
+ ? "all checks passed"
622
+ : `${validationReport.results.filter(r => !r.passed).length} check(s) failed`
623
+ }
624
+ } catch (valErr) {
625
+ gateStatus.validation = { status: "warn", reason: `validation skipped: ${valErr.message}` }
626
+ }
627
+
628
+ const validationContext = validationReport
629
+ ? `\n\nVerification Report:\n${validationReport.message}`
630
+ : ""
631
+
632
+ if (!completionMarkerSeen) {
633
+ const markerTurn = await processTurnLoop({
634
+ prompt: [
635
+ `Objective: ${prompt}`,
636
+ "All planned stages are done.",
637
+ validationContext,
638
+ validationReport?.verdict === "BLOCK"
639
+ ? "Verification found critical issues. Fix them, then include [TASK_COMPLETE]."
640
+ : "Validate if the task is truly complete. If complete, include [TASK_COMPLETE] exactly once."
641
+ ].filter(Boolean).join("\n"),
642
+ mode: "agent",
643
+ model,
644
+ providerType,
645
+ sessionId,
646
+ configState,
647
+ baseUrl,
648
+ apiKeyEnv,
649
+ agent,
650
+ signal,
651
+ allowQuestion: plannerConfig.ask_user_after_plan_frozen === true && allowQuestion,
652
+ toolContext
653
+ })
654
+ finalReply = markerTurn.reply
655
+ aggregateUsage.input += markerTurn.usage.input || 0
656
+ aggregateUsage.output += markerTurn.usage.output || 0
657
+ aggregateUsage.cacheRead += markerTurn.usage.cacheRead || 0
658
+ aggregateUsage.cacheWrite += markerTurn.usage.cacheWrite || 0
659
+ toolEvents.push(...markerTurn.toolEvents)
660
+ completionMarkerSeen = isComplete(markerTurn.reply)
661
+ gateStatus.completionMarker = {
662
+ status: completionMarkerSeen ? "pass" : "warn",
663
+ reason: completionMarkerSeen ? "completion marker confirmed" : "marker missing"
664
+ }
665
+ } else {
666
+ gateStatus.completionMarker = {
667
+ status: "pass",
668
+ reason: "completion marker present in stage outputs"
669
+ }
670
+ }
671
+
672
+ let gateAttempt = 0
673
+ while (gateAttempt < maxGateAttempts) {
674
+ if (signal?.aborted) break
675
+ const preState = await LongAgentManager.get(sessionId)
676
+ if (preState?.stopRequested) break
677
+
678
+ gateAttempt += 1
679
+ currentGate = "usability_gates"
680
+ await setPhase("L3", "usability-gate-check")
681
+ const gateResult = await runUsabilityGates({
682
+ sessionId,
683
+ config: configState.config,
684
+ cwd: process.cwd(),
685
+ iteration
686
+ })
687
+ gateStatus.usability = gateResult.gates
688
+
689
+ if (gateResult.allPass && completionMarkerSeen) {
690
+ await LongAgentManager.update(sessionId, {
691
+ status: "completed",
692
+ phase: currentPhase,
693
+ currentGate,
694
+ gateStatus,
695
+ recoveryCount,
696
+ lastGateFailures: [],
697
+ iterations: iteration,
698
+ lastMessage: "parallel stages and usability gates passed"
699
+ })
700
+ await markSessionStatus(sessionId, "completed")
701
+ break
702
+ }
703
+
704
+ const failureSummary = summarizeGateFailures(gateResult.failures)
705
+ lastGateFailures = gateResult.failures.map((item) => `${item.gate}:${item.reason}`)
706
+ // Use gate-specific backoff (not shared recoveryCount) to avoid over-aggressive delays
707
+ const gateBackoffMs = Math.min(1000 * 2 ** (gateAttempt - 1), 30000)
708
+ await new Promise(r => setTimeout(r, gateBackoffMs))
709
+
710
+ await EventBus.emit({
711
+ type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
712
+ sessionId,
713
+ payload: {
714
+ reason: `usability_gates_failed:${failureSummary || "unknown"}`,
715
+ gateAttempt,
716
+ recoveryCount,
717
+ iteration
718
+ }
719
+ })
720
+
721
+ await setPhase("L2.5", "gate_recovery")
722
+ currentGate = "gate_recovery"
723
+ await syncState({
724
+ status: "recovering",
725
+ stageStatus: "gate_recovery",
726
+ lastMessage: `gate recovery #${gateAttempt}: ${failureSummary || "unknown"}`
727
+ })
728
+
729
+ // Re-run validation to give remediation agent fresh context
730
+ let remediationContext = ""
731
+ try {
732
+ const reValidator = await createValidator({ cwd, configState })
733
+ const reReport = await reValidator.validate({ todoState: toolContext?._todoState, level: validationLevel })
734
+ remediationContext = `\n\nCurrent Verification:\n${reReport.message}`
735
+ } catch { /* skip */ }
736
+
737
+ const remediation = await processTurnLoop({
738
+ prompt: [
739
+ `Objective: ${prompt}`,
740
+ "Usability gates failed.",
741
+ `Failures: ${failureSummary || "unknown"}`,
742
+ remediationContext,
743
+ "Fix ALL failing checks, then include [TASK_COMPLETE] when fully usable."
744
+ ].filter(Boolean).join("\n"),
745
+ mode: "agent",
746
+ model,
747
+ providerType,
748
+ sessionId,
749
+ configState,
750
+ baseUrl,
751
+ apiKeyEnv,
752
+ agent,
753
+ signal,
754
+ allowQuestion: false,
755
+ toolContext
756
+ })
757
+ finalReply = remediation.reply
758
+ aggregateUsage.input += remediation.usage.input || 0
759
+ aggregateUsage.output += remediation.usage.output || 0
760
+ aggregateUsage.cacheRead += remediation.usage.cacheRead || 0
761
+ aggregateUsage.cacheWrite += remediation.usage.cacheWrite || 0
762
+ toolEvents.push(...remediation.toolEvents)
763
+ if (isComplete(remediation.reply)) {
764
+ completionMarkerSeen = true
765
+ }
766
+ }
767
+
768
+ // If gate loop exhausted without success, mark as failed
769
+ const postGateState = await LongAgentManager.get(sessionId)
770
+ if (postGateState?.status !== "completed" && gateAttempt >= maxGateAttempts) {
771
+ await LongAgentManager.update(sessionId, {
772
+ status: "failed",
773
+ phase: currentPhase,
774
+ currentGate,
775
+ gateStatus,
776
+ recoveryCount,
777
+ lastGateFailures,
778
+ iterations: iteration,
779
+ lastMessage: `max gate recovery attempts (${maxGateAttempts}) exceeded`
780
+ })
781
+ await markSessionStatus(sessionId, "failed")
782
+ }
783
+ }
784
+
785
+ // --- Git: final commit + merge back to base branch ---
786
+ if (gitActive && gitBaseBranch && gitBranch) {
787
+ try {
788
+ await git.commitAll(`[kkcode] longagent session ${sessionId} completed`, cwd)
789
+ if (gitConfig.auto_merge !== false) {
790
+ // Hold state lock during read-status merge to prevent TOCTOU race
791
+ await LongAgentManager.withLock(async () => {
792
+ const doneState = await LongAgentManager.get(sessionId)
793
+ if (doneState?.status !== "completed") return
794
+ await git.checkoutBranch(gitBaseBranch, cwd)
795
+ const mergeResult = await git.mergeBranch(gitBranch, cwd)
796
+ if (mergeResult.ok) {
797
+ await git.deleteBranch(gitBranch, cwd)
798
+ gateStatus.git = { ...gateStatus.git, merged: true, mergeMessage: mergeResult.message }
799
+ await EventBus.emit({
800
+ type: EVENT_TYPES.LONGAGENT_GIT_MERGED,
801
+ sessionId,
802
+ payload: { branch: gitBranch, baseBranch: gitBaseBranch, merged: true }
803
+ })
804
+ } else {
805
+ gateStatus.git = { ...gateStatus.git, merged: false, mergeError: mergeResult.message }
806
+ await EventBus.emit({
807
+ type: EVENT_TYPES.LONGAGENT_ALERT,
808
+ sessionId,
809
+ payload: {
810
+ kind: "git_merge_failed",
811
+ message: `Git merge failed: ${mergeResult.message}. Staying on branch "${gitBranch}" — resolve conflicts manually.`
812
+ }
813
+ })
814
+ const rollback = await git.checkoutBranch(gitBranch, cwd)
815
+ if (!rollback.ok) {
816
+ gateStatus.git = { ...gateStatus.git, rollbackFailed: true, rollbackError: rollback.message }
817
+ }
818
+ }
819
+ }, cwd)
820
+ }
821
+ } catch (gitErr) {
822
+ gateStatus.git = { ...gateStatus.git, error: gitErr.message }
823
+ // Best-effort: try to return to feature branch
824
+ try { await git.checkoutBranch(gitBranch, cwd) } catch { /* already on it or unrecoverable */ }
825
+ }
826
+ }
827
+
828
+ // Checkpoint cleanup (same as hybrid mode)
829
+ try {
830
+ const cleanResult = await cleanupCheckpoints(sessionId, {
831
+ maxKeep: 10,
832
+ keepStageCheckpoints: true
833
+ })
834
+ } catch (cleanupErr) {
835
+ console.warn(`[kkcode] checkpoint cleanup failed for session ${sessionId}: ${cleanupErr.message}`)
836
+ }
837
+
838
+ const done = await LongAgentManager.get(sessionId)
839
+ const totalElapsed = Math.round((Date.now() - startTime) / 1000)
840
+ const stats = stageProgressStats(taskProgress)
841
+
842
+ return {
843
+ sessionId,
844
+ turnId: `turn_long_${Date.now()}`,
845
+ reply: finalReply || done?.lastMessage || "longagent stopped",
846
+ usage: aggregateUsage,
847
+ toolEvents,
848
+ iterations: iteration,
849
+ recoveryCount,
850
+ phase: done?.phase || currentPhase,
851
+ gateStatus: done?.gateStatus || gateStatus,
852
+ currentGate: done?.currentGate || currentGate,
853
+ lastGateFailures: done?.lastGateFailures || lastGateFailures,
854
+ status: done?.status || "unknown",
855
+ progress: lastProgress,
856
+ elapsed: totalElapsed,
857
+ stageIndex,
858
+ stageCount: stagePlan?.stages?.length || 0,
859
+ currentStageId: stagePlan?.stages?.[Math.min(stageIndex, (stagePlan?.stages?.length || 1) - 1)]?.stageId || null,
860
+ planFrozen,
861
+ taskProgress,
862
+ fileChanges,
863
+ stageProgress: {
864
+ done: stats.done,
865
+ total: stats.total
866
+ },
867
+ remainingFilesCount: stats.remainingFilesCount
868
+ }
869
+ }
870
+
871
+
872
+ export async function runLongAgent(args) {
873
+ const longagentConfig = args?.configState?.config?.agent?.longagent || {}
874
+ // Hybrid mode (default): Preview → Blueprint → Git → Scaffold → Coding(并行) → Debugging(回滚) → Gates → GitMerge
875
+ if (longagentConfig.hybrid?.enabled !== false) {
876
+ return runHybridLongAgent(args)
877
+ }
878
+ // 4-stage mode: Preview → Blueprint → Coding → Debugging (Mark 研究用)
879
+ if (longagentConfig.four_stage?.enabled === true) {
880
+ return run4StageLongAgent(args)
881
+ }
882
+ // Parallel mode: 降级策略
883
+ return runParallelLongAgent(args)
884
+ }