@kkelly-offical/kkcode 0.1.6 → 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 +19 -2
  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 +90 -0
  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 -2929
  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 +36 -14
  96. package/src/session/engine.mjs +417 -227
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1081
  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 -884
  105. package/src/session/loop.mjs +1005 -905
  106. package/src/session/prompt/agent.txt +25 -0
  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 +28 -6
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +197 -0
  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 -510
  116. package/src/session/system-prompt.mjs +56 -8
  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 +17 -4
  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,884 +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
-
361
- while (stageIndex < stagePlan.stages.length) {
362
- const state = await LongAgentManager.get(sessionId)
363
- if (state?.retryStageId) {
364
- const targetIdx = stagePlan.stages.findIndex((stage) => stage.stageId === state.retryStageId)
365
- // Atomically clear retryStageId to prevent race with concurrent updates
366
- await LongAgentManager.update(sessionId, { retryStageId: null })
367
- if (targetIdx >= 0) {
368
- stageIndex = targetIdx
369
- // Clear progress for target stage AND all subsequent stages
370
- for (let si = targetIdx; si < stagePlan.stages.length; si++) {
371
- const stageTasks = new Set((stagePlan.stages[si].tasks || []).map((task) => task.taskId))
372
- for (const taskId of Object.keys(taskProgress)) {
373
- if (stageTasks.has(taskId)) delete taskProgress[taskId]
374
- }
375
- }
376
- }
377
- }
378
- if (state?.stopRequested || signal?.aborted) {
379
- await LongAgentManager.update(sessionId, {
380
- status: "stopped",
381
- phase: currentPhase,
382
- currentGate,
383
- gateStatus,
384
- lastMessage: "stop requested by user"
385
- })
386
- await markSessionStatus(sessionId, "stopped")
387
- break
388
- }
389
-
390
- iteration += 1
391
- const stage = stagePlan.stages[stageIndex]
392
- currentGate = `stage:${stage.stageId}`
393
- await setPhase("L2", `stage_running:${stage.stageId}`)
394
-
395
- if (maxIterations > 0 && iteration >= maxIterations && iteration % Math.max(1, maxIterations) === 0) {
396
- await EventBus.emit({
397
- type: EVENT_TYPES.LONGAGENT_GATE_CHECKED,
398
- sessionId,
399
- payload: { gate: "max_iterations", status: "warn", iteration, threshold: maxIterations }
400
- })
401
- }
402
-
403
- await syncState({
404
- stageStatus: "running",
405
- lastMessage: `running ${stage.stageId} (${stageIndex + 1}/${stagePlan.stages.length})`
406
- })
407
-
408
- const seeded = Object.fromEntries(
409
- stage.tasks
410
- .map((task) => [task.taskId, taskProgress[task.taskId]])
411
- .filter(([, value]) => Boolean(value))
412
- )
413
-
414
- const stageResult = await runStageBarrier({
415
- stage,
416
- sessionId,
417
- config: configState.config,
418
- model,
419
- providerType,
420
- seedTaskProgress: seeded,
421
- objective: prompt,
422
- stageIndex,
423
- stageCount: stagePlan.stages.length,
424
- priorContext
425
- })
426
-
427
- for (const [taskId, progress] of Object.entries(stageResult.taskProgress || {})) {
428
- taskProgress[taskId] = {
429
- ...taskProgress[taskId],
430
- ...progress
431
- }
432
- if (String(progress.lastReply || "").toLowerCase().includes("[task_complete]")) {
433
- completionMarkerSeen = true
434
- }
435
- }
436
- if (stageResult.completionMarkerSeen) completionMarkerSeen = true
437
- if (Array.isArray(stageResult.fileChanges) && stageResult.fileChanges.length) {
438
- fileChanges = mergeCappedFileChanges(fileChanges, stageResult.fileChanges, fileChangesLimit)
439
- }
440
-
441
- gateStatus[stage.stageId] = {
442
- status: stageResult.allSuccess ? "pass" : "fail",
443
- successCount: stageResult.successCount,
444
- failCount: stageResult.failCount,
445
- retryCount: stageResult.retryCount,
446
- remainingFiles: stageResult.remainingFiles
447
- }
448
-
449
- // Build inter-stage knowledge transfer summary
450
- const taskSummaries = Object.values(stageResult.taskProgress || {})
451
- .filter(t => t.lastReply)
452
- .map(t => `[${t.taskId}] ${t.status}: ${t.lastReply.slice(0, 300)}`)
453
- if (taskSummaries.length) {
454
- priorContext += `### Stage ${stageIndex + 1}: ${stage.name || stage.stageId} (${stageResult.allSuccess ? "PASS" : "FAIL"})\n${taskSummaries.join("\n")}\n\n`
455
- }
456
-
457
- lastProgress = {
458
- percentage: Math.round(((stageIndex + (stageResult.allSuccess ? 1 : 0)) / Math.max(1, stagePlan.stages.length)) * 100),
459
- currentStep: stageIndex + (stageResult.allSuccess ? 1 : 0),
460
- totalSteps: stagePlan.stages.length
461
- }
462
-
463
- await syncState({
464
- stageStatus: stageResult.allSuccess ? "completed" : "failed",
465
- lastMessage: stageResult.allSuccess
466
- ? `stage ${stage.stageId} completed`
467
- : `stage ${stage.stageId} failed (${stageResult.failCount})`
468
- })
469
-
470
- // --- Git: auto-commit after successful stage ---
471
- if (gitActive && stageResult.allSuccess && gitConfig.auto_commit_stages !== false) {
472
- const commitMsg = `[kkcode] stage ${stage.stageId} completed (${stageIndex + 1}/${stagePlan.stages.length})`
473
- const commitResult = await git.commitAll(commitMsg, cwd)
474
- if (commitResult.ok && !commitResult.empty) {
475
- await EventBus.emit({
476
- type: EVENT_TYPES.LONGAGENT_GIT_STAGE_COMMITTED,
477
- sessionId,
478
- payload: { stageId: stage.stageId, message: commitMsg }
479
- })
480
- }
481
- }
482
-
483
- if (!stageResult.allSuccess) {
484
- recoveryCount += 1
485
- // Exponential backoff before retry
486
- const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
487
- await new Promise(r => setTimeout(r, backoffMs))
488
- lastGateFailures = Object.values(stageResult.taskProgress || {})
489
- .filter((item) => item.status !== "completed")
490
- .map((item) => `${item.taskId}:${item.lastError || item.status}`)
491
-
492
- await EventBus.emit({
493
- type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
494
- sessionId,
495
- payload: {
496
- reason: `stage_failed:${stage.stageId}`,
497
- stageId: stage.stageId,
498
- recoveryCount,
499
- iteration
500
- }
501
- })
502
-
503
- await setPhase("L2.5", `stage_recover:${stage.stageId}`)
504
- currentGate = "stage_recovery"
505
- await syncState({
506
- status: "recovering",
507
- stageStatus: "recovering",
508
- lastMessage: `recovering stage ${stage.stageId}`
509
- })
510
-
511
- if (recoveryCount >= noProgressLimit) {
512
- await EventBus.emit({
513
- type: EVENT_TYPES.LONGAGENT_ALERT,
514
- sessionId,
515
- payload: {
516
- kind: "retry_storm",
517
- message: `stage recovery count reached ${recoveryCount}`,
518
- recoveryCount,
519
- threshold: noProgressLimit,
520
- iteration
521
- }
522
- })
523
- }
524
-
525
- // Circuit breaker: abort stage after max recovery attempts
526
- const maxStageRecoveries = Number(longagentConfig.max_stage_recoveries ?? 3)
527
- if (recoveryCount >= maxStageRecoveries) {
528
- await setPhase("L2.5", `stage_abort:${stage.stageId}`)
529
- await syncState({
530
- status: "error",
531
- stageStatus: "aborted",
532
- lastMessage: `stage ${stage.stageId} aborted after ${recoveryCount} recovery attempts`
533
- })
534
- await EventBus.emit({
535
- type: EVENT_TYPES.LONGAGENT_ALERT,
536
- sessionId,
537
- payload: {
538
- kind: "stage_aborted",
539
- message: `stage ${stage.stageId} aborted: max recoveries (${maxStageRecoveries}) exceeded`,
540
- recoveryCount,
541
- stageId: stage.stageId
542
- }
543
- })
544
- break
545
- }
546
-
547
- if (longagentConfig.resume_incomplete_files !== false) {
548
- // Reset failed tasks so runStageBarrier will re-dispatch them
549
- for (const [taskId, tp] of Object.entries(taskProgress)) {
550
- if (tp.status === "error") {
551
- taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
552
- }
553
- }
554
- continue
555
- }
556
- break
557
- }
558
-
559
- stageIndex += 1
560
- // Always checkpoint after each stage for reliable recovery
561
- await saveCheckpoint(sessionId, {
562
- name: `stage_${stage.stageId}`,
563
- iteration,
564
- currentPhase,
565
- currentGate,
566
- recoveryCount,
567
- gateStatus,
568
- taskProgress,
569
- stageIndex,
570
- stagePlan,
571
- planFrozen,
572
- lastProgress
573
- })
574
- }
575
-
576
- if (stagePlan && stageIndex >= stagePlan.stages.length) {
577
- // --- Gate preference prompt (first run only) ---
578
- const gatesConfig = longagentConfig.usability_gates || {}
579
- const shouldPromptGates = gatesConfig.prompt_user === "first_run" || gatesConfig.prompt_user === "always"
580
- if (shouldPromptGates && allowQuestion) {
581
- const hasPrefs = await hasGatePreferences()
582
- if (!hasPrefs || gatesConfig.prompt_user === "always") {
583
- const gateAskResult = await processTurnLoop({
584
- prompt: buildGatePromptText(),
585
- mode: "ask", model, providerType, sessionId, configState,
586
- baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
587
- })
588
- const gatePrefs = parseGateSelection(gateAskResult.reply)
589
- await saveGatePreferences(gatePrefs)
590
- // Apply preferences to runtime config
591
- for (const [gate, enabled] of Object.entries(gatePrefs)) {
592
- if (configState.config.agent.longagent.usability_gates[gate]) {
593
- configState.config.agent.longagent.usability_gates[gate].enabled = enabled
594
- }
595
- }
596
- aggregateUsage.input += gateAskResult.usage.input || 0
597
- aggregateUsage.output += gateAskResult.usage.output || 0
598
- } else {
599
- // Apply saved preferences
600
- const savedPrefs = await getGatePreferences()
601
- if (savedPrefs) {
602
- for (const [gate, enabled] of Object.entries(savedPrefs)) {
603
- if (configState.config.agent.longagent.usability_gates[gate]) {
604
- configState.config.agent.longagent.usability_gates[gate].enabled = enabled
605
- }
606
- }
607
- }
608
- }
609
- }
610
-
611
- // --- Structured completion verification ---
612
- const validationLevel = longagentConfig.validation_level || "standard"
613
- let validationReport = null
614
- try {
615
- const validator = await createValidator({ cwd, configState })
616
- validationReport = await validator.validate({ todoState: toolContext?._todoState, level: validationLevel })
617
- gateStatus.validation = {
618
- status: validationReport.verdict === "BLOCK" ? "fail" : "pass",
619
- verdict: validationReport.verdict,
620
- reason: validationReport.verdict === "APPROVE"
621
- ? "all checks passed"
622
- : `${validationReport.results.filter(r => !r.passed).length} check(s) failed`
623
- }
624
- } catch (valErr) {
625
- gateStatus.validation = { status: "warn", reason: `validation skipped: ${valErr.message}` }
626
- }
627
-
628
- const validationContext = validationReport
629
- ? `\n\nVerification Report:\n${validationReport.message}`
630
- : ""
631
-
632
- if (!completionMarkerSeen) {
633
- const markerTurn = await processTurnLoop({
634
- prompt: [
635
- `Objective: ${prompt}`,
636
- "All planned stages are done.",
637
- validationContext,
638
- validationReport?.verdict === "BLOCK"
639
- ? "Verification found critical issues. Fix them, then include [TASK_COMPLETE]."
640
- : "Validate if the task is truly complete. If complete, include [TASK_COMPLETE] exactly once."
641
- ].filter(Boolean).join("\n"),
642
- mode: "agent",
643
- model,
644
- providerType,
645
- sessionId,
646
- configState,
647
- baseUrl,
648
- apiKeyEnv,
649
- agent,
650
- signal,
651
- allowQuestion: plannerConfig.ask_user_after_plan_frozen === true && allowQuestion,
652
- toolContext
653
- })
654
- finalReply = markerTurn.reply
655
- aggregateUsage.input += markerTurn.usage.input || 0
656
- aggregateUsage.output += markerTurn.usage.output || 0
657
- aggregateUsage.cacheRead += markerTurn.usage.cacheRead || 0
658
- aggregateUsage.cacheWrite += markerTurn.usage.cacheWrite || 0
659
- toolEvents.push(...markerTurn.toolEvents)
660
- completionMarkerSeen = isComplete(markerTurn.reply)
661
- gateStatus.completionMarker = {
662
- status: completionMarkerSeen ? "pass" : "warn",
663
- reason: completionMarkerSeen ? "completion marker confirmed" : "marker missing"
664
- }
665
- } else {
666
- gateStatus.completionMarker = {
667
- status: "pass",
668
- reason: "completion marker present in stage outputs"
669
- }
670
- }
671
-
672
- let gateAttempt = 0
673
- while (gateAttempt < maxGateAttempts) {
674
- if (signal?.aborted) break
675
- const preState = await LongAgentManager.get(sessionId)
676
- if (preState?.stopRequested) break
677
-
678
- gateAttempt += 1
679
- currentGate = "usability_gates"
680
- await setPhase("L3", "usability-gate-check")
681
- const gateResult = await runUsabilityGates({
682
- sessionId,
683
- config: configState.config,
684
- cwd: process.cwd(),
685
- iteration
686
- })
687
- gateStatus.usability = gateResult.gates
688
-
689
- if (gateResult.allPass && completionMarkerSeen) {
690
- await LongAgentManager.update(sessionId, {
691
- status: "completed",
692
- phase: currentPhase,
693
- currentGate,
694
- gateStatus,
695
- recoveryCount,
696
- lastGateFailures: [],
697
- iterations: iteration,
698
- lastMessage: "parallel stages and usability gates passed"
699
- })
700
- await markSessionStatus(sessionId, "completed")
701
- break
702
- }
703
-
704
- const failureSummary = summarizeGateFailures(gateResult.failures)
705
- lastGateFailures = gateResult.failures.map((item) => `${item.gate}:${item.reason}`)
706
- // Use gate-specific backoff (not shared recoveryCount) to avoid over-aggressive delays
707
- const gateBackoffMs = Math.min(1000 * 2 ** (gateAttempt - 1), 30000)
708
- await new Promise(r => setTimeout(r, gateBackoffMs))
709
-
710
- await EventBus.emit({
711
- type: EVENT_TYPES.LONGAGENT_RECOVERY_ENTERED,
712
- sessionId,
713
- payload: {
714
- reason: `usability_gates_failed:${failureSummary || "unknown"}`,
715
- gateAttempt,
716
- recoveryCount,
717
- iteration
718
- }
719
- })
720
-
721
- await setPhase("L2.5", "gate_recovery")
722
- currentGate = "gate_recovery"
723
- await syncState({
724
- status: "recovering",
725
- stageStatus: "gate_recovery",
726
- lastMessage: `gate recovery #${gateAttempt}: ${failureSummary || "unknown"}`
727
- })
728
-
729
- // Re-run validation to give remediation agent fresh context
730
- let remediationContext = ""
731
- try {
732
- const reValidator = await createValidator({ cwd, configState })
733
- const reReport = await reValidator.validate({ todoState: toolContext?._todoState, level: validationLevel })
734
- remediationContext = `\n\nCurrent Verification:\n${reReport.message}`
735
- } catch { /* skip */ }
736
-
737
- const remediation = await processTurnLoop({
738
- prompt: [
739
- `Objective: ${prompt}`,
740
- "Usability gates failed.",
741
- `Failures: ${failureSummary || "unknown"}`,
742
- remediationContext,
743
- "Fix ALL failing checks, then include [TASK_COMPLETE] when fully usable."
744
- ].filter(Boolean).join("\n"),
745
- mode: "agent",
746
- model,
747
- providerType,
748
- sessionId,
749
- configState,
750
- baseUrl,
751
- apiKeyEnv,
752
- agent,
753
- signal,
754
- allowQuestion: false,
755
- toolContext
756
- })
757
- finalReply = remediation.reply
758
- aggregateUsage.input += remediation.usage.input || 0
759
- aggregateUsage.output += remediation.usage.output || 0
760
- aggregateUsage.cacheRead += remediation.usage.cacheRead || 0
761
- aggregateUsage.cacheWrite += remediation.usage.cacheWrite || 0
762
- toolEvents.push(...remediation.toolEvents)
763
- if (isComplete(remediation.reply)) {
764
- completionMarkerSeen = true
765
- }
766
- }
767
-
768
- // If gate loop exhausted without success, mark as failed
769
- const postGateState = await LongAgentManager.get(sessionId)
770
- if (postGateState?.status !== "completed" && gateAttempt >= maxGateAttempts) {
771
- await LongAgentManager.update(sessionId, {
772
- status: "failed",
773
- phase: currentPhase,
774
- currentGate,
775
- gateStatus,
776
- recoveryCount,
777
- lastGateFailures,
778
- iterations: iteration,
779
- lastMessage: `max gate recovery attempts (${maxGateAttempts}) exceeded`
780
- })
781
- await markSessionStatus(sessionId, "failed")
782
- }
783
- }
784
-
785
- // --- Git: final commit + merge back to base branch ---
786
- if (gitActive && gitBaseBranch && gitBranch) {
787
- try {
788
- await git.commitAll(`[kkcode] longagent session ${sessionId} completed`, cwd)
789
- if (gitConfig.auto_merge !== false) {
790
- // Hold state lock during read-status merge to prevent TOCTOU race
791
- await LongAgentManager.withLock(async () => {
792
- const doneState = await LongAgentManager.get(sessionId)
793
- if (doneState?.status !== "completed") return
794
- await git.checkoutBranch(gitBaseBranch, cwd)
795
- const mergeResult = await git.mergeBranch(gitBranch, cwd)
796
- if (mergeResult.ok) {
797
- await git.deleteBranch(gitBranch, cwd)
798
- gateStatus.git = { ...gateStatus.git, merged: true, mergeMessage: mergeResult.message }
799
- await EventBus.emit({
800
- type: EVENT_TYPES.LONGAGENT_GIT_MERGED,
801
- sessionId,
802
- payload: { branch: gitBranch, baseBranch: gitBaseBranch, merged: true }
803
- })
804
- } else {
805
- gateStatus.git = { ...gateStatus.git, merged: false, mergeError: mergeResult.message }
806
- await EventBus.emit({
807
- type: EVENT_TYPES.LONGAGENT_ALERT,
808
- sessionId,
809
- payload: {
810
- kind: "git_merge_failed",
811
- message: `Git merge failed: ${mergeResult.message}. Staying on branch "${gitBranch}" — resolve conflicts manually.`
812
- }
813
- })
814
- const rollback = await git.checkoutBranch(gitBranch, cwd)
815
- if (!rollback.ok) {
816
- gateStatus.git = { ...gateStatus.git, rollbackFailed: true, rollbackError: rollback.message }
817
- }
818
- }
819
- }, cwd)
820
- }
821
- } catch (gitErr) {
822
- gateStatus.git = { ...gateStatus.git, error: gitErr.message }
823
- // Best-effort: try to return to feature branch
824
- try { await git.checkoutBranch(gitBranch, cwd) } catch { /* already on it or unrecoverable */ }
825
- }
826
- }
827
-
828
- // Checkpoint cleanup (same as hybrid mode)
829
- try {
830
- const cleanResult = await cleanupCheckpoints(sessionId, {
831
- maxKeep: 10,
832
- keepStageCheckpoints: true
833
- })
834
- } catch (cleanupErr) {
835
- console.warn(`[kkcode] checkpoint cleanup failed for session ${sessionId}: ${cleanupErr.message}`)
836
- }
837
-
838
- const done = await LongAgentManager.get(sessionId)
839
- const totalElapsed = Math.round((Date.now() - startTime) / 1000)
840
- const stats = stageProgressStats(taskProgress)
841
-
842
- return {
843
- sessionId,
844
- turnId: `turn_long_${Date.now()}`,
845
- reply: finalReply || done?.lastMessage || "longagent stopped",
846
- usage: aggregateUsage,
847
- toolEvents,
848
- iterations: iteration,
849
- recoveryCount,
850
- phase: done?.phase || currentPhase,
851
- gateStatus: done?.gateStatus || gateStatus,
852
- currentGate: done?.currentGate || currentGate,
853
- lastGateFailures: done?.lastGateFailures || lastGateFailures,
854
- status: done?.status || "unknown",
855
- progress: lastProgress,
856
- elapsed: totalElapsed,
857
- stageIndex,
858
- stageCount: stagePlan?.stages?.length || 0,
859
- currentStageId: stagePlan?.stages?.[Math.min(stageIndex, (stagePlan?.stages?.length || 1) - 1)]?.stageId || null,
860
- planFrozen,
861
- taskProgress,
862
- fileChanges,
863
- stageProgress: {
864
- done: stats.done,
865
- total: stats.total
866
- },
867
- remainingFilesCount: stats.remainingFilesCount
868
- }
869
- }
870
-
871
-
872
- export async function runLongAgent(args) {
873
- const longagentConfig = args?.configState?.config?.agent?.longagent || {}
874
- // Hybrid mode (default): Preview Blueprint → Git → Scaffold → Coding(并行) → Debugging(回滚) → Gates → GitMerge
875
- if (longagentConfig.hybrid?.enabled !== false) {
876
- return runHybridLongAgent(args)
877
- }
878
- // 4-stage mode: Preview Blueprint → Coding → Debugging (Mark 研究用)
879
- if (longagentConfig.four_stage?.enabled === true) {
880
- return run4StageLongAgent(args)
881
- }
882
- // Parallel mode: 降级策略
883
- return runParallelLongAgent(args)
884
- }
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
+ }