@kkelly-offical/kkcode 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +452 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/config/defaults.mjs +280 -260
  29. package/src/config/import-config.mjs +1 -1
  30. package/src/config/load-config.mjs +61 -4
  31. package/src/config/schema.mjs +591 -574
  32. package/src/context.mjs +4 -1
  33. package/src/core/constants.mjs +97 -91
  34. package/src/core/types.mjs +1 -1
  35. package/src/github/api.mjs +78 -78
  36. package/src/github/auth.mjs +294 -286
  37. package/src/github/flow.mjs +298 -298
  38. package/src/github/workspace.mjs +225 -212
  39. package/src/index.mjs +84 -82
  40. package/src/knowledge/frontend-aesthetics.txt +38 -38
  41. package/src/mcp/client-http.mjs +139 -141
  42. package/src/mcp/client-sse.mjs +297 -288
  43. package/src/mcp/client-stdio.mjs +534 -533
  44. package/src/mcp/constants.mjs +2 -2
  45. package/src/mcp/registry.mjs +498 -479
  46. package/src/mcp/stdio-framing.mjs +135 -133
  47. package/src/mcp/tool-result.mjs +24 -24
  48. package/src/observability/edit-diagnostics.mjs +449 -0
  49. package/src/observability/index.mjs +42 -42
  50. package/src/observability/metrics.mjs +165 -137
  51. package/src/observability/tracer.mjs +137 -137
  52. package/src/onboarding.mjs +209 -0
  53. package/src/orchestration/background-manager.mjs +567 -372
  54. package/src/orchestration/background-worker.mjs +419 -305
  55. package/src/orchestration/interruption-reason.mjs +21 -0
  56. package/src/orchestration/longagent-manager.mjs +197 -171
  57. package/src/orchestration/stage-scheduler.mjs +733 -728
  58. package/src/orchestration/subagent-router.mjs +7 -1
  59. package/src/orchestration/task-scheduler.mjs +219 -7
  60. package/src/permission/engine.mjs +1 -1
  61. package/src/permission/exec-policy.mjs +370 -370
  62. package/src/permission/file-edit-policy.mjs +108 -0
  63. package/src/permission/prompt.mjs +1 -1
  64. package/src/permission/rules.mjs +116 -7
  65. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  66. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  67. package/src/plugin/hook-bus.mjs +19 -5
  68. package/src/plugin/manifest-loader.mjs +222 -0
  69. package/src/provider/anthropic.mjs +396 -390
  70. package/src/provider/ollama.mjs +7 -1
  71. package/src/provider/openai.mjs +382 -340
  72. package/src/provider/retry-policy.mjs +74 -68
  73. package/src/provider/router.mjs +242 -241
  74. package/src/provider/sse.mjs +104 -104
  75. package/src/provider/wizard.mjs +556 -0
  76. package/src/repl/capability-facade.mjs +30 -0
  77. package/src/repl/command-surface.mjs +23 -0
  78. package/src/repl/controller-entry.mjs +40 -0
  79. package/src/repl/core-shell.mjs +208 -0
  80. package/src/repl/dialog-router.mjs +87 -0
  81. package/src/repl/input-engine.mjs +76 -0
  82. package/src/repl/keymap.mjs +7 -0
  83. package/src/repl/operator-surface.mjs +15 -0
  84. package/src/repl/permission-flow.mjs +49 -0
  85. package/src/repl/runtime-facade.mjs +36 -0
  86. package/src/repl/slash-router.mjs +62 -0
  87. package/src/repl/state-store.mjs +29 -0
  88. package/src/repl/turn-controller.mjs +58 -0
  89. package/src/repl/verification.mjs +23 -0
  90. package/src/repl.mjs +3368 -2981
  91. package/src/rules/load-rules.mjs +3 -3
  92. package/src/runtime.mjs +1 -1
  93. package/src/session/agent-transaction.mjs +86 -0
  94. package/src/session/checkpoint.mjs +302 -302
  95. package/src/session/compaction.mjs +298 -298
  96. package/src/session/engine.mjs +417 -232
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1097
  99. package/src/session/longagent-plan.mjs +376 -365
  100. package/src/session/longagent-project-memory.mjs +53 -53
  101. package/src/session/longagent-scaffold.mjs +291 -291
  102. package/src/session/longagent-task-bus.mjs +138 -54
  103. package/src/session/longagent-utils.mjs +828 -472
  104. package/src/session/longagent.mjs +911 -900
  105. package/src/session/loop.mjs +1005 -930
  106. package/src/session/prompt/agent.txt +25 -25
  107. package/src/session/prompt/anthropic.txt +150 -150
  108. package/src/session/prompt/beast.txt +1 -1
  109. package/src/session/prompt/plan.txt +31 -31
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +196 -195
  113. package/src/session/routing-observability.mjs +72 -0
  114. package/src/session/runtime-state.mjs +47 -0
  115. package/src/session/store.mjs +523 -519
  116. package/src/session/system-prompt.mjs +308 -273
  117. package/src/session/task-validator.mjs +267 -267
  118. package/src/session/usability-gates.mjs +2 -2
  119. package/src/skill/builtin/commit.mjs +64 -64
  120. package/src/skill/builtin/design.mjs +76 -76
  121. package/src/skill/generator.mjs +18 -2
  122. package/src/skill/registry.mjs +642 -390
  123. package/src/storage/audit-store.mjs +18 -11
  124. package/src/storage/event-log.mjs +7 -1
  125. package/src/storage/ghost-commit-store.mjs +243 -245
  126. package/src/storage/paths.mjs +13 -0
  127. package/src/theme/default-theme.mjs +1 -1
  128. package/src/theme/markdown.mjs +4 -0
  129. package/src/theme/schema.mjs +1 -1
  130. package/src/theme/status-bar.mjs +162 -158
  131. package/src/tool/audit-wrapper.mjs +18 -2
  132. package/src/tool/edit-transaction.mjs +23 -0
  133. package/src/tool/executor.mjs +26 -1
  134. package/src/tool/file-read-state.mjs +65 -0
  135. package/src/tool/git-auto.mjs +526 -526
  136. package/src/tool/git-full-auto.mjs +487 -478
  137. package/src/tool/mutation-guard.mjs +54 -0
  138. package/src/tool/prompt/edit.txt +3 -3
  139. package/src/tool/prompt/multiedit.txt +1 -0
  140. package/src/tool/prompt/notebookedit.txt +2 -1
  141. package/src/tool/prompt/patch.txt +25 -24
  142. package/src/tool/prompt/read.txt +3 -3
  143. package/src/tool/prompt/sysinfo.txt +29 -0
  144. package/src/tool/prompt/task.txt +66 -4
  145. package/src/tool/prompt/write.txt +2 -2
  146. package/src/tool/question-prompt.mjs +99 -93
  147. package/src/tool/registry.mjs +1701 -1343
  148. package/src/tool/task-tool.mjs +14 -6
  149. package/src/ui/activity-renderer.mjs +667 -664
  150. package/src/ui/repl-background-panel.mjs +7 -0
  151. package/src/ui/repl-capability-panel.mjs +9 -0
  152. package/src/ui/repl-dashboard.mjs +54 -4
  153. package/src/ui/repl-help.mjs +110 -0
  154. package/src/ui/repl-operator-panel.mjs +12 -0
  155. package/src/ui/repl-route-feedback.mjs +35 -0
  156. package/src/ui/repl-status-view.mjs +76 -0
  157. package/src/ui/repl-task-panel.mjs +5 -0
  158. package/src/ui/repl-transcript-panel.mjs +56 -0
  159. package/src/ui/repl-turn-summary.mjs +135 -0
  160. package/src/usage/pricing.mjs +122 -121
  161. package/src/usage/usage-meter.mjs +1 -0
  162. package/src/util/git.mjs +562 -519
  163. package/src/util/template.mjs +6 -1
@@ -1,900 +1,911 @@
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
- }
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
+ // Confirm via a lightweight turn
214
+ const askResult = await processTurnLoop({
215
+ prompt: [
216
+ "[SYSTEM] Git 分支管理已就绪。是否为本次 LongAgent 会话创建独立分支?",
217
+ "回复 yes/是 启用,no/否 跳过。",
218
+ "启用后:自动创建特性分支 → 每阶段自动提交 → 完成后合并回主分支。"
219
+ ].join("\n"),
220
+ mode: "assistant", 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 gateAssistantResult = await processTurnLoop({
600
+ prompt: buildGatePromptText(),
601
+ mode: "assistant", model, providerType, sessionId, configState,
602
+ baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
603
+ })
604
+ const gatePrefs = parseGateSelection(gateAssistantResult.reply)
605
+ await saveGatePreferences(gatePrefs)
606
+ // Apply preferences to a shallow copy to avoid mutating shared configState
607
+ const gatesCopy = { ...configState.config.agent.longagent.usability_gates }
608
+ for (const [gate, enabled] of Object.entries(gatePrefs)) {
609
+ if (gatesCopy[gate]) {
610
+ gatesCopy[gate] = { ...gatesCopy[gate], enabled }
611
+ }
612
+ }
613
+ configState.config.agent.longagent = { ...configState.config.agent.longagent, usability_gates: gatesCopy }
614
+ aggregateUsage.input += gateAssistantResult.usage.input || 0
615
+ aggregateUsage.output += gateAssistantResult.usage.output || 0
616
+ } else {
617
+ // Apply saved preferences
618
+ const savedPrefs = await getGatePreferences()
619
+ if (savedPrefs) {
620
+ const gatesCopy = { ...configState.config.agent.longagent.usability_gates }
621
+ for (const [gate, enabled] of Object.entries(savedPrefs)) {
622
+ if (gatesCopy[gate]) {
623
+ gatesCopy[gate] = { ...gatesCopy[gate], enabled }
624
+ }
625
+ }
626
+ configState.config.agent.longagent = { ...configState.config.agent.longagent, usability_gates: gatesCopy }
627
+ }
628
+ }
629
+ }
630
+
631
+ // --- Structured completion verification ---
632
+ const validationLevel = longagentConfig.validation_level || "standard"
633
+ let validationReport = null
634
+ try {
635
+ const validator = await createValidator({ cwd, configState })
636
+ validationReport = await validator.validate({ todoState: toolContext?._todoState, level: validationLevel })
637
+ gateStatus.validation = {
638
+ status: validationReport.verdict === "BLOCK" ? "fail" : "pass",
639
+ verdict: validationReport.verdict,
640
+ reason: validationReport.verdict === "APPROVE"
641
+ ? "all checks passed"
642
+ : `${validationReport.results.filter(r => !r.passed).length} check(s) failed`
643
+ }
644
+ } catch (valErr) {
645
+ gateStatus.validation = { status: "warn", reason: `validation skipped: ${valErr.message}` }
646
+ }
647
+
648
+ const validationContext = validationReport
649
+ ? `\n\nVerification Report:\n${validationReport.message}`
650
+ : ""
651
+
652
+ if (!completionMarkerSeen) {
653
+ const markerTurn = await processTurnLoop({
654
+ prompt: [
655
+ `Objective: ${prompt}`,
656
+ "All planned stages are done.",
657
+ validationContext,
658
+ validationReport?.verdict === "BLOCK"
659
+ ? "Verification found critical issues. Fix them, then include [TASK_COMPLETE]."
660
+ : "Validate if the task is truly complete. If complete, include [TASK_COMPLETE] exactly once."
661
+ ].filter(Boolean).join("\n"),
662
+ mode: "agent",
663
+ model,
664
+ providerType,
665
+ sessionId,
666
+ configState,
667
+ baseUrl,
668
+ apiKeyEnv,
669
+ agent,
670
+ signal,
671
+ allowQuestion: plannerConfig.ask_user_after_plan_frozen === true && allowQuestion,
672
+ toolContext
673
+ })
674
+ finalReply = markerTurn.reply
675
+ aggregateUsage.input += markerTurn.usage.input || 0
676
+ aggregateUsage.output += markerTurn.usage.output || 0
677
+ aggregateUsage.cacheRead += markerTurn.usage.cacheRead || 0
678
+ aggregateUsage.cacheWrite += markerTurn.usage.cacheWrite || 0
679
+ toolEvents.push(...markerTurn.toolEvents)
680
+ completionMarkerSeen = isComplete(markerTurn.reply)
681
+ gateStatus.completionMarker = {
682
+ status: completionMarkerSeen ? "pass" : "warn",
683
+ reason: completionMarkerSeen ? "completion marker confirmed" : "marker missing"
684
+ }
685
+ } else {
686
+ gateStatus.completionMarker = {
687
+ status: "pass",
688
+ reason: "completion marker present in stage outputs"
689
+ }
690
+ }
691
+
692
+ let gateAttempt = 0
693
+ while (gateAttempt < maxGateAttempts) {
694
+ if (signal?.aborted) break
695
+ const preState = await LongAgentManager.get(sessionId)
696
+ if (preState?.stopRequested) break
697
+
698
+ gateAttempt += 1
699
+ currentGate = "usability_gates"
700
+ await setPhase("L3", "usability-gate-check")
701
+ const gateResult = await runUsabilityGates({
702
+ sessionId,
703
+ config: configState.config,
704
+ cwd: process.cwd(),
705
+ iteration
706
+ })
707
+ gateStatus.usability = gateResult.gates
708
+
709
+ if (gateResult.allPass && completionMarkerSeen) {
710
+ await LongAgentManager.update(sessionId, {
711
+ status: "completed",
712
+ phase: currentPhase,
713
+ currentGate,
714
+ gateStatus,
715
+ recoveryCount,
716
+ lastGateFailures: [],
717
+ iterations: iteration,
718
+ lastMessage: "parallel stages and usability gates passed"
719
+ })
720
+ await markSessionStatus(sessionId, "completed")
721
+ break
722
+ }
723
+
724
+ const failureSummary = summarizeGateFailures(gateResult.failures)
725
+ lastGateFailures = gateResult.failures.map((item) => `${item.gate}:${item.reason}`)
726
+ // Use gate-specific backoff (not shared recoveryCount) to avoid over-aggressive delays
727
+ const gateBackoffMs = Math.min(1000 * 2 ** (gateAttempt - 1), 30000)
728
+ await new Promise(r => setTimeout(r, gateBackoffMs))
729
+
730
+ await EventBus.emit({
731
+ type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
732
+ sessionId,
733
+ payload: {
734
+ reason: `usability_gates_failed:${failureSummary || "unknown"}`,
735
+ gateAttempt,
736
+ recoveryCount,
737
+ iteration
738
+ }
739
+ })
740
+
741
+ await setPhase("L2.5", "gate_recovery")
742
+ currentGate = "gate_recovery"
743
+ await syncState({
744
+ status: "recovering",
745
+ stageStatus: "gate_recovery",
746
+ lastMessage: `gate recovery #${gateAttempt}: ${failureSummary || "unknown"}`
747
+ })
748
+
749
+ // Re-run validation to give remediation agent fresh context
750
+ let remediationContext = ""
751
+ try {
752
+ const reValidator = await createValidator({ cwd, configState })
753
+ const reReport = await reValidator.validate({ todoState: toolContext?._todoState, level: validationLevel })
754
+ remediationContext = `\n\nCurrent Verification:\n${reReport.message}`
755
+ } catch { /* skip */ }
756
+
757
+ const remediation = await processTurnLoop({
758
+ prompt: [
759
+ `Objective: ${prompt}`,
760
+ "Usability gates failed.",
761
+ `Failures: ${failureSummary || "unknown"}`,
762
+ remediationContext,
763
+ "Fix ALL failing checks, then include [TASK_COMPLETE] when fully usable."
764
+ ].filter(Boolean).join("\n"),
765
+ mode: "agent",
766
+ model,
767
+ providerType,
768
+ sessionId,
769
+ configState,
770
+ baseUrl,
771
+ apiKeyEnv,
772
+ agent,
773
+ signal,
774
+ allowQuestion: false,
775
+ toolContext
776
+ })
777
+ finalReply = remediation.reply
778
+ aggregateUsage.input += remediation.usage.input || 0
779
+ aggregateUsage.output += remediation.usage.output || 0
780
+ aggregateUsage.cacheRead += remediation.usage.cacheRead || 0
781
+ aggregateUsage.cacheWrite += remediation.usage.cacheWrite || 0
782
+ toolEvents.push(...remediation.toolEvents)
783
+ if (isComplete(remediation.reply)) {
784
+ completionMarkerSeen = true
785
+ }
786
+ }
787
+
788
+ // If gate loop exhausted without success, mark as failed
789
+ const postGateState = await LongAgentManager.get(sessionId)
790
+ if (postGateState?.status !== "completed" && gateAttempt >= maxGateAttempts) {
791
+ await LongAgentManager.update(sessionId, {
792
+ status: "failed",
793
+ phase: currentPhase,
794
+ currentGate,
795
+ gateStatus,
796
+ recoveryCount,
797
+ lastGateFailures,
798
+ iterations: iteration,
799
+ lastMessage: `max gate recovery attempts (${maxGateAttempts}) exceeded`
800
+ })
801
+ await markSessionStatus(sessionId, "failed")
802
+ }
803
+ }
804
+
805
+ // --- Git: final commit + merge back to base branch ---
806
+ if (gitActive && gitBaseBranch && gitBranch) {
807
+ try {
808
+ await git.commitAll(`[kkcode] longagent session ${sessionId} completed`, cwd)
809
+ if (gitConfig.auto_merge !== false) {
810
+ // Hold state lock during read-status → merge to prevent TOCTOU race
811
+ await LongAgentManager.withLock(async () => {
812
+ const doneState = await LongAgentManager.get(sessionId)
813
+ if (doneState?.status !== "completed") return
814
+ await git.checkoutBranch(gitBaseBranch, cwd)
815
+ const mergeResult = await git.mergeBranch(gitBranch, cwd)
816
+ if (mergeResult.ok) {
817
+ await git.deleteBranch(gitBranch, cwd)
818
+ gateStatus.git = { ...gateStatus.git, merged: true, mergeMessage: mergeResult.message }
819
+ await EventBus.emit({
820
+ type: EVENT_TYPES.LONGAGENT_GIT_MERGED,
821
+ sessionId,
822
+ payload: { branch: gitBranch, baseBranch: gitBaseBranch, merged: true }
823
+ })
824
+ } else {
825
+ gateStatus.git = { ...gateStatus.git, merged: false, mergeError: mergeResult.message }
826
+ await EventBus.emit({
827
+ type: EVENT_TYPES.LONGAGENT_ALERT,
828
+ sessionId,
829
+ payload: {
830
+ kind: "git_merge_failed",
831
+ message: `Git merge failed: ${mergeResult.message}. Staying on branch "${gitBranch}" — resolve conflicts manually.`
832
+ }
833
+ })
834
+ const rollback = await git.checkoutBranch(gitBranch, cwd)
835
+ if (!rollback.ok) {
836
+ gateStatus.git = { ...gateStatus.git, rollbackFailed: true, rollbackError: rollback.message }
837
+ }
838
+ }
839
+ }, cwd)
840
+ }
841
+ } catch (gitErr) {
842
+ gateStatus.git = { ...gateStatus.git, error: gitErr.message }
843
+ // Best-effort: try to return to feature branch
844
+ try { await git.checkoutBranch(gitBranch, cwd) } catch { /* already on it or unrecoverable */ }
845
+ }
846
+ }
847
+
848
+ // Checkpoint cleanup (same as hybrid mode)
849
+ try {
850
+ const cleanResult = await cleanupCheckpoints(sessionId, {
851
+ maxKeep: 10,
852
+ keepStageCheckpoints: true
853
+ })
854
+ } catch (cleanupErr) {
855
+ console.warn(`[kkcode] checkpoint cleanup failed for session ${sessionId}: ${cleanupErr.message}`)
856
+ }
857
+
858
+ const done = await LongAgentManager.get(sessionId)
859
+ const totalElapsed = Math.round((Date.now() - startTime) / 1000)
860
+ const stats = stageProgressStats(taskProgress)
861
+
862
+ return {
863
+ sessionId,
864
+ turnId: `turn_long_${Date.now()}`,
865
+ reply: finalReply || done?.lastMessage || "longagent stopped",
866
+ usage: aggregateUsage,
867
+ toolEvents,
868
+ iterations: iteration,
869
+ recoveryCount,
870
+ phase: done?.phase || currentPhase,
871
+ gateStatus: done?.gateStatus || gateStatus,
872
+ currentGate: done?.currentGate || currentGate,
873
+ lastGateFailures: done?.lastGateFailures || lastGateFailures,
874
+ status: done?.status || "unknown",
875
+ progress: lastProgress,
876
+ elapsed: totalElapsed,
877
+ stageIndex,
878
+ stageCount: stagePlan?.stages?.length || 0,
879
+ currentStageId: stagePlan?.stages?.[Math.min(stageIndex, (stagePlan?.stages?.length || 1) - 1)]?.stageId || null,
880
+ planFrozen,
881
+ taskProgress,
882
+ fileChanges,
883
+ stageProgress: {
884
+ done: stats.done,
885
+ total: stats.total
886
+ },
887
+ remainingFilesCount: stats.remainingFilesCount
888
+ }
889
+ }
890
+
891
+
892
+ export async function runLongAgent(args) {
893
+ const longagentConfig = args?.configState?.config?.agent?.longagent || {}
894
+ // Runtime impl override (set via /longagent 4stage or /longagent hybrid)
895
+ if (args?.longagentImpl === "4stage") {
896
+ return run4StageLongAgent(args)
897
+ }
898
+ if (args?.longagentImpl === "hybrid") {
899
+ return runHybridLongAgent(args)
900
+ }
901
+ // Hybrid mode (default): Preview → Blueprint → Git → Scaffold → Coding(并行) → Debugging(回滚) → Gates → GitMerge
902
+ if (longagentConfig.hybrid?.enabled !== false) {
903
+ return runHybridLongAgent(args)
904
+ }
905
+ // 4-stage mode: Preview → Blueprint → Coding → Debugging (Mark 研究用)
906
+ if (longagentConfig.four_stage?.enabled === true) {
907
+ return run4StageLongAgent(args)
908
+ }
909
+ // Parallel mode: 降级策略
910
+ return runParallelLongAgent(args)
911
+ }