@kkelly-offical/kkcode 0.1.3 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +110 -172
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +220 -170
  4. package/src/agent/prompt/bug-hunter.txt +90 -0
  5. package/src/agent/prompt/frontend-designer.txt +58 -0
  6. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  7. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  8. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  9. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  10. package/src/config/defaults.mjs +260 -195
  11. package/src/config/schema.mjs +71 -6
  12. package/src/core/constants.mjs +91 -46
  13. package/src/index.mjs +1 -1
  14. package/src/knowledge/frontend-aesthetics.txt +39 -0
  15. package/src/knowledge/loader.mjs +2 -1
  16. package/src/knowledge/tailwind.txt +12 -3
  17. package/src/mcp/client-http.mjs +141 -157
  18. package/src/mcp/client-sse.mjs +288 -286
  19. package/src/mcp/client-stdio.mjs +533 -451
  20. package/src/mcp/constants.mjs +2 -0
  21. package/src/mcp/registry.mjs +479 -394
  22. package/src/mcp/stdio-framing.mjs +133 -127
  23. package/src/mcp/tool-result.mjs +24 -0
  24. package/src/observability/index.mjs +42 -0
  25. package/src/observability/metrics.mjs +137 -0
  26. package/src/observability/tracer.mjs +137 -0
  27. package/src/orchestration/background-manager.mjs +372 -358
  28. package/src/orchestration/background-worker.mjs +305 -245
  29. package/src/orchestration/longagent-manager.mjs +171 -116
  30. package/src/orchestration/stage-scheduler.mjs +728 -489
  31. package/src/permission/exec-policy.mjs +9 -11
  32. package/src/provider/anthropic.mjs +1 -0
  33. package/src/provider/openai.mjs +340 -339
  34. package/src/provider/retry-policy.mjs +68 -68
  35. package/src/provider/router.mjs +241 -228
  36. package/src/provider/sse.mjs +104 -91
  37. package/src/repl.mjs +59 -7
  38. package/src/session/checkpoint.mjs +66 -3
  39. package/src/session/compaction.mjs +298 -276
  40. package/src/session/engine.mjs +232 -225
  41. package/src/session/longagent-4stage.mjs +460 -0
  42. package/src/session/longagent-hybrid.mjs +1097 -0
  43. package/src/session/longagent-plan.mjs +365 -329
  44. package/src/session/longagent-project-memory.mjs +53 -0
  45. package/src/session/longagent-scaffold.mjs +291 -100
  46. package/src/session/longagent-task-bus.mjs +54 -0
  47. package/src/session/longagent-utils.mjs +472 -0
  48. package/src/session/longagent.mjs +900 -1462
  49. package/src/session/loop.mjs +65 -40
  50. package/src/session/project-context.mjs +30 -0
  51. package/src/session/prompt/agent.txt +25 -0
  52. package/src/session/prompt/plan.txt +31 -9
  53. package/src/session/rollback.mjs +196 -0
  54. package/src/session/store.mjs +519 -503
  55. package/src/session/system-prompt.mjs +273 -260
  56. package/src/session/task-validator.mjs +4 -3
  57. package/src/skill/builtin/design.mjs +76 -0
  58. package/src/skill/builtin/frontend.mjs +8 -0
  59. package/src/skill/registry.mjs +390 -336
  60. package/src/storage/ghost-commit-store.mjs +18 -8
  61. package/src/tool/executor.mjs +11 -0
  62. package/src/tool/git-auto.mjs +0 -19
  63. package/src/tool/question-prompt.mjs +93 -86
  64. package/src/tool/registry.mjs +71 -37
  65. package/src/ui/activity-renderer.mjs +664 -410
  66. package/src/util/git.mjs +23 -0
@@ -1,1462 +1,900 @@
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
+ const seenFilePaths = new Set() // #3 去重:跨阶段文件路径去重,避免 priorContext 重复提及
361
+
362
+ while (stageIndex < stagePlan.stages.length) {
363
+ const state = await LongAgentManager.get(sessionId)
364
+ if (state?.retryStageId) {
365
+ const targetIdx = stagePlan.stages.findIndex((stage) => stage.stageId === state.retryStageId)
366
+ // Atomically clear retryStageId to prevent race with concurrent updates
367
+ await LongAgentManager.update(sessionId, { retryStageId: null })
368
+ if (targetIdx >= 0) {
369
+ stageIndex = targetIdx
370
+ // Clear progress for target stage AND all subsequent stages
371
+ for (let si = targetIdx; si < stagePlan.stages.length; si++) {
372
+ const stageTasks = new Set((stagePlan.stages[si].tasks || []).map((task) => task.taskId))
373
+ for (const taskId of Object.keys(taskProgress)) {
374
+ if (stageTasks.has(taskId)) delete taskProgress[taskId]
375
+ }
376
+ }
377
+ }
378
+ }
379
+ if (state?.stopRequested || signal?.aborted) {
380
+ await LongAgentManager.update(sessionId, {
381
+ status: "stopped",
382
+ phase: currentPhase,
383
+ currentGate,
384
+ gateStatus,
385
+ lastMessage: "stop requested by user"
386
+ })
387
+ await markSessionStatus(sessionId, "stopped")
388
+ break
389
+ }
390
+
391
+ iteration += 1
392
+ const stage = stagePlan.stages[stageIndex]
393
+ currentGate = `stage:${stage.stageId}`
394
+ await setPhase("L2", `stage_running:${stage.stageId}`)
395
+
396
+ if (maxIterations > 0 && iteration >= maxIterations && iteration % Math.max(1, maxIterations) === 0) {
397
+ await EventBus.emit({
398
+ type: EVENT_TYPES.LONGAGENT_GATE_CHECKED,
399
+ sessionId,
400
+ payload: { gate: "max_iterations", status: "warn", iteration, threshold: maxIterations }
401
+ })
402
+ }
403
+
404
+ await syncState({
405
+ stageStatus: "running",
406
+ lastMessage: `running ${stage.stageId} (${stageIndex + 1}/${stagePlan.stages.length})`
407
+ })
408
+
409
+ const seeded = Object.fromEntries(
410
+ stage.tasks
411
+ .map((task) => [task.taskId, taskProgress[task.taskId]])
412
+ .filter(([, value]) => Boolean(value))
413
+ )
414
+
415
+ // #4 计划锚点 — 每个阶段执行前重建,确保模型始终看到完整计划和当前进度
416
+ const stageStatuses = stagePlan.stages.map((s, i) => {
417
+ const marker = i < stageIndex ? "✓" : i === stageIndex ? "→" : " "
418
+ return `[${marker}] 阶段${i + 1}: ${s.name || s.stageId}`
419
+ }).join("\n")
420
+ const planAnchor = `## 计划锚点\n目标: ${stagePlan.objective || prompt}\n进度: ${stageIndex + 1}/${stagePlan.stages.length}\n${stageStatuses}\n\n`
421
+
422
+ const stageResult = await runStageBarrier({
423
+ stage,
424
+ sessionId,
425
+ config: configState.config,
426
+ model,
427
+ providerType,
428
+ seedTaskProgress: seeded,
429
+ objective: prompt,
430
+ stageIndex,
431
+ stageCount: stagePlan.stages.length,
432
+ priorContext: planAnchor + priorContext
433
+ })
434
+
435
+ for (const [taskId, progress] of Object.entries(stageResult.taskProgress || {})) {
436
+ taskProgress[taskId] = {
437
+ ...taskProgress[taskId],
438
+ ...progress
439
+ }
440
+ if (String(progress.lastReply || "").toLowerCase().includes("[task_complete]")) {
441
+ completionMarkerSeen = true
442
+ }
443
+ }
444
+ if (stageResult.completionMarkerSeen) completionMarkerSeen = true
445
+ if (Array.isArray(stageResult.fileChanges) && stageResult.fileChanges.length) {
446
+ fileChanges = mergeCappedFileChanges(fileChanges, stageResult.fileChanges, fileChangesLimit)
447
+ }
448
+
449
+ gateStatus[stage.stageId] = {
450
+ status: stageResult.allSuccess ? "pass" : "fail",
451
+ successCount: stageResult.successCount,
452
+ failCount: stageResult.failCount,
453
+ retryCount: stageResult.retryCount,
454
+ remainingFiles: stageResult.remainingFiles
455
+ }
456
+
457
+ // #1 阶段级压缩 + #3 去重 — 结构化阶段摘要,文件路径跨阶段去重
458
+ const taskSummaries = Object.values(stageResult.taskProgress || {})
459
+ .filter(t => t.lastReply)
460
+ .map(t => ` - [${t.taskId}] ${t.status}: ${t.lastReply.slice(0, 250)}`)
461
+ const stageFiles = (stageResult.fileChanges || [])
462
+ .map(f => (typeof f === "string" ? f : (f.path || f.file || "")))
463
+ .filter(Boolean)
464
+ const newFiles = stageFiles.filter(f => !seenFilePaths.has(f))
465
+ newFiles.forEach(f => seenFilePaths.add(f))
466
+ if (taskSummaries.length || newFiles.length) {
467
+ const fileNote = newFiles.length ? `\n 新增/修改文件: ${newFiles.join(", ")}` : ""
468
+ const failNote = !stageResult.allSuccess ? `\n 失败任务数: ${stageResult.failCount}` : ""
469
+ priorContext += `### 阶段${stageIndex + 1}: ${stage.name || stage.stageId} (${stageResult.allSuccess ? "PASS" : "FAIL"})${failNote}\n${taskSummaries.join("\n")}${fileNote}\n\n`
470
+ }
471
+
472
+ lastProgress = {
473
+ percentage: Math.round(((stageIndex + (stageResult.allSuccess ? 1 : 0)) / Math.max(1, stagePlan.stages.length)) * 100),
474
+ currentStep: stageIndex + (stageResult.allSuccess ? 1 : 0),
475
+ totalSteps: stagePlan.stages.length
476
+ }
477
+
478
+ await syncState({
479
+ stageStatus: stageResult.allSuccess ? "completed" : "failed",
480
+ lastMessage: stageResult.allSuccess
481
+ ? `stage ${stage.stageId} completed`
482
+ : `stage ${stage.stageId} failed (${stageResult.failCount})`
483
+ })
484
+
485
+ // --- Git: auto-commit after successful stage ---
486
+ if (gitActive && stageResult.allSuccess && gitConfig.auto_commit_stages !== false) {
487
+ const commitMsg = `[kkcode] stage ${stage.stageId} completed (${stageIndex + 1}/${stagePlan.stages.length})`
488
+ const commitResult = await git.commitAll(commitMsg, cwd)
489
+ if (commitResult.ok && !commitResult.empty) {
490
+ await EventBus.emit({
491
+ type: EVENT_TYPES.LONGAGENT_GIT_STAGE_COMMITTED,
492
+ sessionId,
493
+ payload: { stageId: stage.stageId, message: commitMsg }
494
+ })
495
+ }
496
+ }
497
+
498
+ if (!stageResult.allSuccess) {
499
+ recoveryCount += 1
500
+ // Exponential backoff before retry
501
+ const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
502
+ await new Promise(r => setTimeout(r, backoffMs))
503
+ lastGateFailures = Object.values(stageResult.taskProgress || {})
504
+ .filter((item) => item.status !== "completed")
505
+ .map((item) => `${item.taskId}:${item.lastError || item.status}`)
506
+
507
+ await EventBus.emit({
508
+ type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
509
+ sessionId,
510
+ payload: {
511
+ reason: `stage_failed:${stage.stageId}`,
512
+ stageId: stage.stageId,
513
+ recoveryCount,
514
+ iteration
515
+ }
516
+ })
517
+
518
+ await setPhase("L2.5", `stage_recover:${stage.stageId}`)
519
+ currentGate = "stage_recovery"
520
+ await syncState({
521
+ status: "recovering",
522
+ stageStatus: "recovering",
523
+ lastMessage: `recovering stage ${stage.stageId}`
524
+ })
525
+
526
+ if (recoveryCount >= noProgressLimit) {
527
+ await EventBus.emit({
528
+ type: EVENT_TYPES.LONGAGENT_ALERT,
529
+ sessionId,
530
+ payload: {
531
+ kind: "retry_storm",
532
+ message: `stage recovery count reached ${recoveryCount}`,
533
+ recoveryCount,
534
+ threshold: noProgressLimit,
535
+ iteration
536
+ }
537
+ })
538
+ }
539
+
540
+ // Circuit breaker: abort stage after max recovery attempts
541
+ const maxStageRecoveries = Number(longagentConfig.max_stage_recoveries ?? 3)
542
+ if (recoveryCount >= maxStageRecoveries) {
543
+ await setPhase("L2.5", `stage_abort:${stage.stageId}`)
544
+ await syncState({
545
+ status: "error",
546
+ stageStatus: "aborted",
547
+ lastMessage: `stage ${stage.stageId} aborted after ${recoveryCount} recovery attempts`
548
+ })
549
+ await EventBus.emit({
550
+ type: EVENT_TYPES.LONGAGENT_ALERT,
551
+ sessionId,
552
+ payload: {
553
+ kind: "stage_aborted",
554
+ message: `stage ${stage.stageId} aborted: max recoveries (${maxStageRecoveries}) exceeded`,
555
+ recoveryCount,
556
+ stageId: stage.stageId
557
+ }
558
+ })
559
+ break
560
+ }
561
+
562
+ if (longagentConfig.resume_incomplete_files !== false) {
563
+ // Reset failed tasks so runStageBarrier will re-dispatch them
564
+ for (const [taskId, tp] of Object.entries(taskProgress)) {
565
+ if (tp.status === "error") {
566
+ taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
567
+ }
568
+ }
569
+ continue
570
+ }
571
+ break
572
+ }
573
+
574
+ stageIndex += 1
575
+ recoveryCount = 0 // reset per-stage recovery counter after successful stage
576
+ // Always checkpoint after each stage for reliable recovery
577
+ await saveCheckpoint(sessionId, {
578
+ name: `stage_${stage.stageId}`,
579
+ iteration,
580
+ currentPhase,
581
+ currentGate,
582
+ recoveryCount,
583
+ gateStatus,
584
+ taskProgress,
585
+ stageIndex,
586
+ stagePlan,
587
+ planFrozen,
588
+ lastProgress
589
+ })
590
+ }
591
+
592
+ if (stagePlan && stageIndex >= stagePlan.stages.length) {
593
+ // --- Gate preference prompt (first run only) ---
594
+ const gatesConfig = longagentConfig.usability_gates || {}
595
+ const shouldPromptGates = gatesConfig.prompt_user === "first_run" || gatesConfig.prompt_user === "always"
596
+ if (shouldPromptGates && allowQuestion) {
597
+ const hasPrefs = await hasGatePreferences()
598
+ if (!hasPrefs || gatesConfig.prompt_user === "always") {
599
+ const gateAskResult = await processTurnLoop({
600
+ prompt: buildGatePromptText(),
601
+ mode: "ask", model, providerType, sessionId, configState,
602
+ baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
603
+ })
604
+ const gatePrefs = parseGateSelection(gateAskResult.reply)
605
+ await saveGatePreferences(gatePrefs)
606
+ // Apply preferences to runtime config
607
+ for (const [gate, enabled] of Object.entries(gatePrefs)) {
608
+ if (configState.config.agent.longagent.usability_gates[gate]) {
609
+ configState.config.agent.longagent.usability_gates[gate].enabled = enabled
610
+ }
611
+ }
612
+ aggregateUsage.input += gateAskResult.usage.input || 0
613
+ aggregateUsage.output += gateAskResult.usage.output || 0
614
+ } else {
615
+ // Apply saved preferences
616
+ const savedPrefs = await getGatePreferences()
617
+ if (savedPrefs) {
618
+ for (const [gate, enabled] of Object.entries(savedPrefs)) {
619
+ if (configState.config.agent.longagent.usability_gates[gate]) {
620
+ configState.config.agent.longagent.usability_gates[gate].enabled = enabled
621
+ }
622
+ }
623
+ }
624
+ }
625
+ }
626
+
627
+ // --- Structured completion verification ---
628
+ const validationLevel = longagentConfig.validation_level || "standard"
629
+ let validationReport = null
630
+ try {
631
+ const validator = await createValidator({ cwd, configState })
632
+ validationReport = await validator.validate({ todoState: toolContext?._todoState, level: validationLevel })
633
+ gateStatus.validation = {
634
+ status: validationReport.verdict === "BLOCK" ? "fail" : "pass",
635
+ verdict: validationReport.verdict,
636
+ reason: validationReport.verdict === "APPROVE"
637
+ ? "all checks passed"
638
+ : `${validationReport.results.filter(r => !r.passed).length} check(s) failed`
639
+ }
640
+ } catch (valErr) {
641
+ gateStatus.validation = { status: "warn", reason: `validation skipped: ${valErr.message}` }
642
+ }
643
+
644
+ const validationContext = validationReport
645
+ ? `\n\nVerification Report:\n${validationReport.message}`
646
+ : ""
647
+
648
+ if (!completionMarkerSeen) {
649
+ const markerTurn = await processTurnLoop({
650
+ prompt: [
651
+ `Objective: ${prompt}`,
652
+ "All planned stages are done.",
653
+ validationContext,
654
+ validationReport?.verdict === "BLOCK"
655
+ ? "Verification found critical issues. Fix them, then include [TASK_COMPLETE]."
656
+ : "Validate if the task is truly complete. If complete, include [TASK_COMPLETE] exactly once."
657
+ ].filter(Boolean).join("\n"),
658
+ mode: "agent",
659
+ model,
660
+ providerType,
661
+ sessionId,
662
+ configState,
663
+ baseUrl,
664
+ apiKeyEnv,
665
+ agent,
666
+ signal,
667
+ allowQuestion: plannerConfig.ask_user_after_plan_frozen === true && allowQuestion,
668
+ toolContext
669
+ })
670
+ finalReply = markerTurn.reply
671
+ aggregateUsage.input += markerTurn.usage.input || 0
672
+ aggregateUsage.output += markerTurn.usage.output || 0
673
+ aggregateUsage.cacheRead += markerTurn.usage.cacheRead || 0
674
+ aggregateUsage.cacheWrite += markerTurn.usage.cacheWrite || 0
675
+ toolEvents.push(...markerTurn.toolEvents)
676
+ completionMarkerSeen = isComplete(markerTurn.reply)
677
+ gateStatus.completionMarker = {
678
+ status: completionMarkerSeen ? "pass" : "warn",
679
+ reason: completionMarkerSeen ? "completion marker confirmed" : "marker missing"
680
+ }
681
+ } else {
682
+ gateStatus.completionMarker = {
683
+ status: "pass",
684
+ reason: "completion marker present in stage outputs"
685
+ }
686
+ }
687
+
688
+ let gateAttempt = 0
689
+ while (gateAttempt < maxGateAttempts) {
690
+ if (signal?.aborted) break
691
+ const preState = await LongAgentManager.get(sessionId)
692
+ if (preState?.stopRequested) break
693
+
694
+ gateAttempt += 1
695
+ currentGate = "usability_gates"
696
+ await setPhase("L3", "usability-gate-check")
697
+ const gateResult = await runUsabilityGates({
698
+ sessionId,
699
+ config: configState.config,
700
+ cwd: process.cwd(),
701
+ iteration
702
+ })
703
+ gateStatus.usability = gateResult.gates
704
+
705
+ if (gateResult.allPass && completionMarkerSeen) {
706
+ await LongAgentManager.update(sessionId, {
707
+ status: "completed",
708
+ phase: currentPhase,
709
+ currentGate,
710
+ gateStatus,
711
+ recoveryCount,
712
+ lastGateFailures: [],
713
+ iterations: iteration,
714
+ lastMessage: "parallel stages and usability gates passed"
715
+ })
716
+ await markSessionStatus(sessionId, "completed")
717
+ break
718
+ }
719
+
720
+ const failureSummary = summarizeGateFailures(gateResult.failures)
721
+ lastGateFailures = gateResult.failures.map((item) => `${item.gate}:${item.reason}`)
722
+ // Use gate-specific backoff (not shared recoveryCount) to avoid over-aggressive delays
723
+ const gateBackoffMs = Math.min(1000 * 2 ** (gateAttempt - 1), 30000)
724
+ await new Promise(r => setTimeout(r, gateBackoffMs))
725
+
726
+ await EventBus.emit({
727
+ type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
728
+ sessionId,
729
+ payload: {
730
+ reason: `usability_gates_failed:${failureSummary || "unknown"}`,
731
+ gateAttempt,
732
+ recoveryCount,
733
+ iteration
734
+ }
735
+ })
736
+
737
+ await setPhase("L2.5", "gate_recovery")
738
+ currentGate = "gate_recovery"
739
+ await syncState({
740
+ status: "recovering",
741
+ stageStatus: "gate_recovery",
742
+ lastMessage: `gate recovery #${gateAttempt}: ${failureSummary || "unknown"}`
743
+ })
744
+
745
+ // Re-run validation to give remediation agent fresh context
746
+ let remediationContext = ""
747
+ try {
748
+ const reValidator = await createValidator({ cwd, configState })
749
+ const reReport = await reValidator.validate({ todoState: toolContext?._todoState, level: validationLevel })
750
+ remediationContext = `\n\nCurrent Verification:\n${reReport.message}`
751
+ } catch { /* skip */ }
752
+
753
+ const remediation = await processTurnLoop({
754
+ prompt: [
755
+ `Objective: ${prompt}`,
756
+ "Usability gates failed.",
757
+ `Failures: ${failureSummary || "unknown"}`,
758
+ remediationContext,
759
+ "Fix ALL failing checks, then include [TASK_COMPLETE] when fully usable."
760
+ ].filter(Boolean).join("\n"),
761
+ mode: "agent",
762
+ model,
763
+ providerType,
764
+ sessionId,
765
+ configState,
766
+ baseUrl,
767
+ apiKeyEnv,
768
+ agent,
769
+ signal,
770
+ allowQuestion: false,
771
+ toolContext
772
+ })
773
+ finalReply = remediation.reply
774
+ aggregateUsage.input += remediation.usage.input || 0
775
+ aggregateUsage.output += remediation.usage.output || 0
776
+ aggregateUsage.cacheRead += remediation.usage.cacheRead || 0
777
+ aggregateUsage.cacheWrite += remediation.usage.cacheWrite || 0
778
+ toolEvents.push(...remediation.toolEvents)
779
+ if (isComplete(remediation.reply)) {
780
+ completionMarkerSeen = true
781
+ }
782
+ }
783
+
784
+ // If gate loop exhausted without success, mark as failed
785
+ const postGateState = await LongAgentManager.get(sessionId)
786
+ if (postGateState?.status !== "completed" && gateAttempt >= maxGateAttempts) {
787
+ await LongAgentManager.update(sessionId, {
788
+ status: "failed",
789
+ phase: currentPhase,
790
+ currentGate,
791
+ gateStatus,
792
+ recoveryCount,
793
+ lastGateFailures,
794
+ iterations: iteration,
795
+ lastMessage: `max gate recovery attempts (${maxGateAttempts}) exceeded`
796
+ })
797
+ await markSessionStatus(sessionId, "failed")
798
+ }
799
+ }
800
+
801
+ // --- Git: final commit + merge back to base branch ---
802
+ if (gitActive && gitBaseBranch && gitBranch) {
803
+ try {
804
+ await git.commitAll(`[kkcode] longagent session ${sessionId} completed`, cwd)
805
+ if (gitConfig.auto_merge !== false) {
806
+ // Hold state lock during read-status → merge to prevent TOCTOU race
807
+ await LongAgentManager.withLock(async () => {
808
+ const doneState = await LongAgentManager.get(sessionId)
809
+ if (doneState?.status !== "completed") return
810
+ await git.checkoutBranch(gitBaseBranch, cwd)
811
+ const mergeResult = await git.mergeBranch(gitBranch, cwd)
812
+ if (mergeResult.ok) {
813
+ await git.deleteBranch(gitBranch, cwd)
814
+ gateStatus.git = { ...gateStatus.git, merged: true, mergeMessage: mergeResult.message }
815
+ await EventBus.emit({
816
+ type: EVENT_TYPES.LONGAGENT_GIT_MERGED,
817
+ sessionId,
818
+ payload: { branch: gitBranch, baseBranch: gitBaseBranch, merged: true }
819
+ })
820
+ } else {
821
+ gateStatus.git = { ...gateStatus.git, merged: false, mergeError: mergeResult.message }
822
+ await EventBus.emit({
823
+ type: EVENT_TYPES.LONGAGENT_ALERT,
824
+ sessionId,
825
+ payload: {
826
+ kind: "git_merge_failed",
827
+ message: `Git merge failed: ${mergeResult.message}. Staying on branch "${gitBranch}" — resolve conflicts manually.`
828
+ }
829
+ })
830
+ const rollback = await git.checkoutBranch(gitBranch, cwd)
831
+ if (!rollback.ok) {
832
+ gateStatus.git = { ...gateStatus.git, rollbackFailed: true, rollbackError: rollback.message }
833
+ }
834
+ }
835
+ }, cwd)
836
+ }
837
+ } catch (gitErr) {
838
+ gateStatus.git = { ...gateStatus.git, error: gitErr.message }
839
+ // Best-effort: try to return to feature branch
840
+ try { await git.checkoutBranch(gitBranch, cwd) } catch { /* already on it or unrecoverable */ }
841
+ }
842
+ }
843
+
844
+ // Checkpoint cleanup (same as hybrid mode)
845
+ try {
846
+ const cleanResult = await cleanupCheckpoints(sessionId, {
847
+ maxKeep: 10,
848
+ keepStageCheckpoints: true
849
+ })
850
+ } catch (cleanupErr) {
851
+ console.warn(`[kkcode] checkpoint cleanup failed for session ${sessionId}: ${cleanupErr.message}`)
852
+ }
853
+
854
+ const done = await LongAgentManager.get(sessionId)
855
+ const totalElapsed = Math.round((Date.now() - startTime) / 1000)
856
+ const stats = stageProgressStats(taskProgress)
857
+
858
+ return {
859
+ sessionId,
860
+ turnId: `turn_long_${Date.now()}`,
861
+ reply: finalReply || done?.lastMessage || "longagent stopped",
862
+ usage: aggregateUsage,
863
+ toolEvents,
864
+ iterations: iteration,
865
+ recoveryCount,
866
+ phase: done?.phase || currentPhase,
867
+ gateStatus: done?.gateStatus || gateStatus,
868
+ currentGate: done?.currentGate || currentGate,
869
+ lastGateFailures: done?.lastGateFailures || lastGateFailures,
870
+ status: done?.status || "unknown",
871
+ progress: lastProgress,
872
+ elapsed: totalElapsed,
873
+ stageIndex,
874
+ stageCount: stagePlan?.stages?.length || 0,
875
+ currentStageId: stagePlan?.stages?.[Math.min(stageIndex, (stagePlan?.stages?.length || 1) - 1)]?.stageId || null,
876
+ planFrozen,
877
+ taskProgress,
878
+ fileChanges,
879
+ stageProgress: {
880
+ done: stats.done,
881
+ total: stats.total
882
+ },
883
+ remainingFilesCount: stats.remainingFilesCount
884
+ }
885
+ }
886
+
887
+
888
+ export async function runLongAgent(args) {
889
+ const longagentConfig = args?.configState?.config?.agent?.longagent || {}
890
+ // Hybrid mode (default): Preview → Blueprint → Git → Scaffold → Coding(并行) → Debugging(回滚) → Gates → GitMerge
891
+ if (longagentConfig.hybrid?.enabled !== false) {
892
+ return runHybridLongAgent(args)
893
+ }
894
+ // 4-stage mode: Preview → Blueprint → Coding → Debugging (Mark 研究用)
895
+ if (longagentConfig.four_stage?.enabled === true) {
896
+ return run4StageLongAgent(args)
897
+ }
898
+ // Parallel mode: 降级策略
899
+ return runParallelLongAgent(args)
900
+ }