@jcjeon/integration-cli 0.2.0

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 (264) hide show
  1. package/.gitignore +23 -0
  2. package/.npmignore +21 -0
  3. package/.prettierignore +6 -0
  4. package/.prettierrc +26 -0
  5. package/AGENTS.md +10 -0
  6. package/CLAUDE.md +10 -0
  7. package/README.md +384 -0
  8. package/apps/server/README.md +294 -0
  9. package/apps/server/eslint.config.mjs +20 -0
  10. package/apps/server/nest-cli.json +8 -0
  11. package/apps/server/package.json +89 -0
  12. package/apps/server/scripts/postinstall.js +53 -0
  13. package/apps/server/src/__mocks__/glob.js +6 -0
  14. package/apps/server/src/__mocks__/uuid.js +5 -0
  15. package/apps/server/src/app.controller.spec.ts +24 -0
  16. package/apps/server/src/app.controller.ts +13 -0
  17. package/apps/server/src/app.module.ts +18 -0
  18. package/apps/server/src/app.service.ts +8 -0
  19. package/apps/server/src/common/ji-paths.ts +41 -0
  20. package/apps/server/src/database/database.module.ts +27 -0
  21. package/apps/server/src/database/entities/agent-changelog.entity.ts +39 -0
  22. package/apps/server/src/database/entities/agent-session.entity.ts +29 -0
  23. package/apps/server/src/database/entities/conversation.entity.ts +41 -0
  24. package/apps/server/src/database/entities/session.entity.ts +16 -0
  25. package/apps/server/src/database/entities/task-agent-run.entity.ts +40 -0
  26. package/apps/server/src/database/entities/task-agent.entity.ts +42 -0
  27. package/apps/server/src/database/entities/task-requirement.entity.ts +27 -0
  28. package/apps/server/src/database/entities/task-run.entity.ts +41 -0
  29. package/apps/server/src/database/entities/task.entity.ts +44 -0
  30. package/apps/server/src/main.ts +65 -0
  31. package/apps/server/src/modules/agents/agent-model-settings.spec.ts +80 -0
  32. package/apps/server/src/modules/agents/agents.module.ts +11 -0
  33. package/apps/server/src/modules/agents/claude/claude-auth.manager.ts +83 -0
  34. package/apps/server/src/modules/agents/claude/claude-pty.manager.ts +380 -0
  35. package/apps/server/src/modules/agents/claude/claude.controller.ts +85 -0
  36. package/apps/server/src/modules/agents/claude/claude.gateway.ts +158 -0
  37. package/apps/server/src/modules/agents/claude/claude.module.ts +18 -0
  38. package/apps/server/src/modules/agents/claude/claude.service.ts +67 -0
  39. package/apps/server/src/modules/agents/claude/dto/create-session.dto.ts +24 -0
  40. package/apps/server/src/modules/agents/claude/dto/resize-session.dto.ts +13 -0
  41. package/apps/server/src/modules/agents/claude/dto/send-input.dto.ts +9 -0
  42. package/apps/server/src/modules/agents/claude/interfaces/claude-session.interface.ts +26 -0
  43. package/apps/server/src/modules/agents/claude/interfaces/pty-event.interface.ts +10 -0
  44. package/apps/server/src/modules/agents/claude/interfaces/stream-event.interface.ts +61 -0
  45. package/apps/server/src/modules/agents/codex/codex-auth.manager.ts +107 -0
  46. package/apps/server/src/modules/agents/codex/codex-session.manager.ts +357 -0
  47. package/apps/server/src/modules/agents/codex/codex.controller.ts +64 -0
  48. package/apps/server/src/modules/agents/codex/codex.gateway.ts +97 -0
  49. package/apps/server/src/modules/agents/codex/codex.module.ts +17 -0
  50. package/apps/server/src/modules/agents/codex/dto/configure-auth.dto.ts +7 -0
  51. package/apps/server/src/modules/agents/gemini/dto/configure-auth.dto.ts +15 -0
  52. package/apps/server/src/modules/agents/gemini/dto/create-session.dto.ts +9 -0
  53. package/apps/server/src/modules/agents/gemini/dto/send-input.dto.ts +9 -0
  54. package/apps/server/src/modules/agents/gemini/gemini-auth.manager.ts +157 -0
  55. package/apps/server/src/modules/agents/gemini/gemini-session.manager.ts +287 -0
  56. package/apps/server/src/modules/agents/gemini/gemini.controller.ts +93 -0
  57. package/apps/server/src/modules/agents/gemini/gemini.gateway.ts +149 -0
  58. package/apps/server/src/modules/agents/gemini/gemini.module.ts +17 -0
  59. package/apps/server/src/modules/agents/gemini/interfaces/gemini-session.interface.ts +18 -0
  60. package/apps/server/src/modules/agents/gemini/interfaces/stream-event.interface.ts +14 -0
  61. package/apps/server/src/modules/agents/session-termination.spec.ts +103 -0
  62. package/apps/server/src/modules/changelog/changelog.controller.ts +20 -0
  63. package/apps/server/src/modules/changelog/changelog.module.ts +14 -0
  64. package/apps/server/src/modules/changelog/changelog.service.spec.ts +531 -0
  65. package/apps/server/src/modules/changelog/changelog.service.ts +690 -0
  66. package/apps/server/src/modules/conversations/conversation.controller.spec.ts +106 -0
  67. package/apps/server/src/modules/conversations/conversation.controller.ts +60 -0
  68. package/apps/server/src/modules/conversations/conversation.module.ts +14 -0
  69. package/apps/server/src/modules/conversations/conversation.service.spec.ts +176 -0
  70. package/apps/server/src/modules/conversations/conversation.service.ts +54 -0
  71. package/apps/server/src/modules/conversations/dto/create-conversation.dto.ts +37 -0
  72. package/apps/server/src/modules/conversations/enums/conversation.enum.ts +13 -0
  73. package/apps/server/src/modules/fs/fs.controller.ts +29 -0
  74. package/apps/server/src/modules/fs/fs.module.ts +8 -0
  75. package/apps/server/src/modules/harness/dto/save-harness.dto.ts +9 -0
  76. package/apps/server/src/modules/harness/harness.controller.spec.ts +95 -0
  77. package/apps/server/src/modules/harness/harness.controller.ts +35 -0
  78. package/apps/server/src/modules/harness/harness.module.ts +11 -0
  79. package/apps/server/src/modules/harness/harness.service.spec.ts +217 -0
  80. package/apps/server/src/modules/harness/harness.service.ts +112 -0
  81. package/apps/server/src/modules/sessions/session.controller.spec.ts +68 -0
  82. package/apps/server/src/modules/sessions/session.controller.ts +43 -0
  83. package/apps/server/src/modules/sessions/session.module.ts +14 -0
  84. package/apps/server/src/modules/sessions/session.service.spec.ts +106 -0
  85. package/apps/server/src/modules/sessions/session.service.ts +35 -0
  86. package/apps/server/src/modules/tasks/dto/create-task.dto.ts +54 -0
  87. package/apps/server/src/modules/tasks/dto/execute-task.dto.ts +22 -0
  88. package/apps/server/src/modules/tasks/dto/merge-file.dto.ts +7 -0
  89. package/apps/server/src/modules/tasks/dto/rerun-task.dto.ts +14 -0
  90. package/apps/server/src/modules/tasks/dto/update-task.dto.ts +55 -0
  91. package/apps/server/src/modules/tasks/task-execution.service.ts +978 -0
  92. package/apps/server/src/modules/tasks/task.gateway.ts +140 -0
  93. package/apps/server/src/modules/tasks/tasks.controller.spec.ts +210 -0
  94. package/apps/server/src/modules/tasks/tasks.controller.ts +139 -0
  95. package/apps/server/src/modules/tasks/tasks.module.ts +30 -0
  96. package/apps/server/src/modules/tasks/tasks.service.spec.ts +552 -0
  97. package/apps/server/src/modules/tasks/tasks.service.ts +333 -0
  98. package/apps/server/test/app.e2e-spec.ts +28 -0
  99. package/apps/server/test/jest-e2e.json +9 -0
  100. package/apps/server/tsconfig.build.json +4 -0
  101. package/apps/server/tsconfig.json +13 -0
  102. package/apps/web/AGENTS.md +7 -0
  103. package/apps/web/CLAUDE.md +1 -0
  104. package/apps/web/README.md +36 -0
  105. package/apps/web/eslint.config.mjs +21 -0
  106. package/apps/web/next-env.d.ts +6 -0
  107. package/apps/web/next.config.ts +7 -0
  108. package/apps/web/package.json +49 -0
  109. package/apps/web/postcss.config.mjs +7 -0
  110. package/apps/web/public/file.svg +1 -0
  111. package/apps/web/public/globe.svg +1 -0
  112. package/apps/web/public/next.svg +1 -0
  113. package/apps/web/public/vercel.svg +1 -0
  114. package/apps/web/public/window.svg +1 -0
  115. package/apps/web/src/app/claude/page.tsx +5 -0
  116. package/apps/web/src/app/codex/page.tsx +126 -0
  117. package/apps/web/src/app/favicon.ico +0 -0
  118. package/apps/web/src/app/gemini/page.tsx +130 -0
  119. package/apps/web/src/app/globals.css +149 -0
  120. package/apps/web/src/app/layout.tsx +40 -0
  121. package/apps/web/src/app/login/page.tsx +67 -0
  122. package/apps/web/src/app/page.tsx +497 -0
  123. package/apps/web/src/app/task/[id]/page.tsx +11 -0
  124. package/apps/web/src/app/test/page.tsx +298 -0
  125. package/apps/web/src/components/ui/Modal.tsx +78 -0
  126. package/apps/web/src/components/ui/WorkingDirPicker.tsx +195 -0
  127. package/apps/web/src/components/ui/__tests__/Modal.test.tsx +68 -0
  128. package/apps/web/src/features/auth/api/__tests__/auth.api.test.ts +83 -0
  129. package/apps/web/src/features/auth/api/auth.api.ts +81 -0
  130. package/apps/web/src/features/auth/hooks/__tests__/useClaudeAuth.test.ts +166 -0
  131. package/apps/web/src/features/auth/hooks/__tests__/useCodexAuth.test.ts +127 -0
  132. package/apps/web/src/features/auth/hooks/__tests__/useGeminiAuth.test.ts +120 -0
  133. package/apps/web/src/features/auth/hooks/useClaudeAuth.ts +88 -0
  134. package/apps/web/src/features/auth/hooks/useCodexAuth.ts +149 -0
  135. package/apps/web/src/features/auth/hooks/useGeminiAuth.ts +125 -0
  136. package/apps/web/src/features/auth/ui/CodexLoginPanel.tsx +302 -0
  137. package/apps/web/src/features/auth/ui/GeminiLoginPanel.tsx +316 -0
  138. package/apps/web/src/features/auth/ui/LoginForm.tsx +190 -0
  139. package/apps/web/src/features/auth/ui/LoginPanel.tsx +114 -0
  140. package/apps/web/src/features/auth/ui/__tests__/LoginPanel.test.tsx +105 -0
  141. package/apps/web/src/features/chat/api/__tests__/sessions.api.test.ts +187 -0
  142. package/apps/web/src/features/chat/api/sessions.api.ts +161 -0
  143. package/apps/web/src/features/chat/container/ClaudePageContainer.tsx +152 -0
  144. package/apps/web/src/features/chat/hooks/__tests__/useCodexSessions.test.ts +131 -0
  145. package/apps/web/src/features/chat/hooks/__tests__/useGeminiSessions.test.ts +130 -0
  146. package/apps/web/src/features/chat/hooks/useAgentModelSettings.ts +54 -0
  147. package/apps/web/src/features/chat/hooks/useClaudeSessions.ts +323 -0
  148. package/apps/web/src/features/chat/hooks/useCodexSessions.ts +275 -0
  149. package/apps/web/src/features/chat/hooks/useGeminiSessions.ts +255 -0
  150. package/apps/web/src/features/chat/hooks/useSessionCommand.ts +66 -0
  151. package/apps/web/src/features/chat/hooks/useSessionRename.ts +61 -0
  152. package/apps/web/src/features/chat/hooks/useSessionWorkingDirectories.ts +34 -0
  153. package/apps/web/src/features/chat/hooks/useUnifiedSessions.ts +156 -0
  154. package/apps/web/src/features/chat/lib/agentModelOptions.ts +72 -0
  155. package/apps/web/src/features/chat/ui/AgentModelPicker.tsx +134 -0
  156. package/apps/web/src/features/chat/ui/AgentSelectModal.tsx +236 -0
  157. package/apps/web/src/features/chat/ui/ChatInput.tsx +162 -0
  158. package/apps/web/src/features/chat/ui/ChatMessage.tsx +204 -0
  159. package/apps/web/src/features/chat/ui/ChatWorkspace.tsx +207 -0
  160. package/apps/web/src/features/chat/ui/CheckingSkeleton.tsx +44 -0
  161. package/apps/web/src/features/chat/ui/ClaudeLoginView.tsx +44 -0
  162. package/apps/web/src/features/chat/ui/PermissionCard.tsx +37 -0
  163. package/apps/web/src/features/chat/ui/SessionSidebar.tsx +280 -0
  164. package/apps/web/src/features/chat/ui/__tests__/AgentSelectModal.test.tsx +58 -0
  165. package/apps/web/src/features/chat/ui/__tests__/ChatInput.test.tsx +134 -0
  166. package/apps/web/src/features/chat/ui/__tests__/ChatMessage.test.tsx +106 -0
  167. package/apps/web/src/features/chat/ui/__tests__/ChatWorkspace.test.tsx +66 -0
  168. package/apps/web/src/features/diff/ui/DiffFileRow.tsx +73 -0
  169. package/apps/web/src/features/diff/ui/DiffHunk.tsx +61 -0
  170. package/apps/web/src/features/diff/ui/FileChangeBadge.tsx +23 -0
  171. package/apps/web/src/features/diff/ui/__tests__/DiffFileRow.test.tsx +40 -0
  172. package/apps/web/src/features/diff/ui/__tests__/DiffHunk.test.tsx +24 -0
  173. package/apps/web/src/features/diff/ui/__tests__/FileChangeBadge.test.tsx +16 -0
  174. package/apps/web/src/features/fs/api/fs.api.ts +14 -0
  175. package/apps/web/src/features/fs/hooks/useDirBrowser.ts +50 -0
  176. package/apps/web/src/features/harness/api/__tests__/harness.api.test.ts +73 -0
  177. package/apps/web/src/features/harness/api/harness.api.ts +46 -0
  178. package/apps/web/src/features/harness/hooks/__tests__/useHarness.test.ts +65 -0
  179. package/apps/web/src/features/harness/hooks/useHarness.ts +66 -0
  180. package/apps/web/src/features/harness/ui/HarnessModal.tsx +171 -0
  181. package/apps/web/src/features/harness/ui/__tests__/HarnessModal.test.tsx +46 -0
  182. package/apps/web/src/features/status/ui/AgentStatusModal.tsx +267 -0
  183. package/apps/web/src/features/status/ui/__tests__/AgentStatusModal.test.tsx +71 -0
  184. package/apps/web/src/features/tasks/api/__tests__/changelog.api.test.ts +89 -0
  185. package/apps/web/src/features/tasks/api/__tests__/tasks.api.test.ts +282 -0
  186. package/apps/web/src/features/tasks/api/changelog.api.ts +52 -0
  187. package/apps/web/src/features/tasks/api/tasks.api.ts +175 -0
  188. package/apps/web/src/features/tasks/container/TaskDetailPageContainer.tsx +69 -0
  189. package/apps/web/src/features/tasks/hooks/__tests__/useChangelogCodeCopy.test.ts +48 -0
  190. package/apps/web/src/features/tasks/hooks/__tests__/useTaskChangelog.test.ts +48 -0
  191. package/apps/web/src/features/tasks/hooks/__tests__/useTaskCreate.test.ts +217 -0
  192. package/apps/web/src/features/tasks/hooks/__tests__/useTaskEdit.test.ts +152 -0
  193. package/apps/web/src/features/tasks/hooks/__tests__/useTaskExecution.test.ts +143 -0
  194. package/apps/web/src/features/tasks/hooks/__tests__/useTaskList.test.ts +168 -0
  195. package/apps/web/src/features/tasks/hooks/__tests__/useTaskNotification.test.ts +125 -0
  196. package/apps/web/src/features/tasks/hooks/__tests__/useTaskRuns.test.ts +51 -0
  197. package/apps/web/src/features/tasks/hooks/useChangelogCodeCopy.ts +52 -0
  198. package/apps/web/src/features/tasks/hooks/useCopyToClipboard.ts +47 -0
  199. package/apps/web/src/features/tasks/hooks/useTaskChangelog.ts +32 -0
  200. package/apps/web/src/features/tasks/hooks/useTaskCreate.ts +137 -0
  201. package/apps/web/src/features/tasks/hooks/useTaskDetail.ts +217 -0
  202. package/apps/web/src/features/tasks/hooks/useTaskEdit.ts +130 -0
  203. package/apps/web/src/features/tasks/hooks/useTaskExecution.ts +137 -0
  204. package/apps/web/src/features/tasks/hooks/useTaskList.ts +159 -0
  205. package/apps/web/src/features/tasks/hooks/useTaskNotification.ts +80 -0
  206. package/apps/web/src/features/tasks/hooks/useTaskRuns.ts +32 -0
  207. package/apps/web/src/features/tasks/ui/AgentOutputPanel.tsx +203 -0
  208. package/apps/web/src/features/tasks/ui/AgentRoleSelect.tsx +97 -0
  209. package/apps/web/src/features/tasks/ui/ChangelogPanel.tsx +321 -0
  210. package/apps/web/src/features/tasks/ui/RunHistoryPanel.tsx +193 -0
  211. package/apps/web/src/features/tasks/ui/TaskCreateModal.tsx +205 -0
  212. package/apps/web/src/features/tasks/ui/TaskDetailView.tsx +413 -0
  213. package/apps/web/src/features/tasks/ui/TaskEditModal.tsx +165 -0
  214. package/apps/web/src/features/tasks/ui/TaskListModal.tsx +591 -0
  215. package/apps/web/src/features/tasks/ui/__tests__/AgentRoleSelect.test.tsx +91 -0
  216. package/apps/web/src/features/tasks/ui/__tests__/ChangelogPanel.test.tsx +94 -0
  217. package/apps/web/src/features/tasks/ui/__tests__/RunHistoryPanel.test.tsx +71 -0
  218. package/apps/web/src/features/tasks/ui/__tests__/TaskCreateModal.test.tsx +153 -0
  219. package/apps/web/src/features/tasks/ui/__tests__/TaskEditModal.test.tsx +75 -0
  220. package/apps/web/src/features/tasks/ui/__tests__/TaskListModal.test.tsx +243 -0
  221. package/apps/web/src/hooks/useWorkingDir.ts +28 -0
  222. package/apps/web/src/lib/__tests__/ansi.test.ts +88 -0
  223. package/apps/web/src/lib/ansi.ts +105 -0
  224. package/apps/web/src/lib/constants.ts +4 -0
  225. package/apps/web/src/lib/quota.ts +22 -0
  226. package/apps/web/src/lib/theme.tsx +78 -0
  227. package/apps/web/src/lib/toast.tsx +175 -0
  228. package/apps/web/src/store/agentStatusStore.ts +38 -0
  229. package/apps/web/tsconfig.json +18 -0
  230. package/apps/web/vitest.config.ts +25 -0
  231. package/apps/web/vitest.setup.ts +10 -0
  232. package/package.json +85 -0
  233. package/packages/cli/dist/commands/check.d.ts +1 -0
  234. package/packages/cli/dist/commands/check.js +89 -0
  235. package/packages/cli/dist/commands/init.d.ts +5 -0
  236. package/packages/cli/dist/commands/init.js +183 -0
  237. package/packages/cli/dist/commands/start.d.ts +4 -0
  238. package/packages/cli/dist/commands/start.js +188 -0
  239. package/packages/cli/dist/index.d.ts +2 -0
  240. package/packages/cli/dist/index.js +71 -0
  241. package/packages/cli/dist/utils/agent-tools.d.ts +28 -0
  242. package/packages/cli/dist/utils/agent-tools.js +193 -0
  243. package/packages/cli/dist/utils/project-init.d.ts +12 -0
  244. package/packages/cli/dist/utils/project-init.js +258 -0
  245. package/packages/cli/dist/utils/proxy.d.ts +8 -0
  246. package/packages/cli/dist/utils/proxy.js +138 -0
  247. package/packages/cli/package.json +30 -0
  248. package/packages/cli/src/commands/check.ts +77 -0
  249. package/packages/cli/src/commands/init.ts +209 -0
  250. package/packages/cli/src/commands/start.ts +183 -0
  251. package/packages/cli/src/index.ts +91 -0
  252. package/packages/cli/src/utils/agent-tools.ts +201 -0
  253. package/packages/cli/src/utils/project-init.ts +252 -0
  254. package/packages/cli/src/utils/proxy.ts +123 -0
  255. package/packages/cli/tsconfig.json +14 -0
  256. package/packages/eslint-config/base.mjs +31 -0
  257. package/packages/eslint-config/nest.mjs +55 -0
  258. package/packages/eslint-config/next.mjs +23 -0
  259. package/packages/eslint-config/package.json +20 -0
  260. package/packages/typescript-config/base.json +16 -0
  261. package/packages/typescript-config/nestjs.json +17 -0
  262. package/packages/typescript-config/nextjs.json +15 -0
  263. package/packages/typescript-config/package.json +11 -0
  264. package/turbo.json +28 -0
@@ -0,0 +1,978 @@
1
+ import { execSync, spawn, type ChildProcess } from 'child_process';
2
+
3
+ const IS_WIN = process.platform === 'win32';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { EventEmitter } from 'events';
6
+ import * as fs from 'fs';
7
+
8
+ import { BadRequestException, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
9
+ import { InjectRepository } from '@nestjs/typeorm';
10
+ import { In, Repository } from 'typeorm';
11
+
12
+ import { TaskAgentEntity } from '../../database/entities/task-agent.entity';
13
+ import { TaskAgentRunEntity } from '../../database/entities/task-agent-run.entity';
14
+ import { TaskEntity } from '../../database/entities/task.entity';
15
+ import { TaskRunEntity } from '../../database/entities/task-run.entity';
16
+ import { GeminiAuthManager } from '../agents/gemini/gemini-auth.manager';
17
+ import { ConversationService } from '../conversations/conversation.service';
18
+ import { AgentModel, ConversationType } from '../conversations/enums/conversation.enum';
19
+ import { GitChangelogService, type DirectorySnapshot } from '../changelog/changelog.service';
20
+ import { HarnessService } from '../harness/harness.service';
21
+ import type { HarnessRole } from '../harness/harness.service';
22
+ import type {
23
+ ClaudeAssistantEvent,
24
+ ClaudeResultEvent,
25
+ ClaudeStreamEvent,
26
+ } from '../agents/claude/interfaces/stream-event.interface';
27
+
28
+ // ─── 이벤트 페이로드 ─────────────────────────────────────────────────────────
29
+
30
+ export interface AgentOutputEvent { taskId: string; agentId: number; text: string }
31
+ export interface AgentToolEvent { taskId: string; agentId: number; tool: string; input: Record<string, unknown> }
32
+ export interface AgentDoneEvent { taskId: string; agentId: number; result: string; isError: boolean; durationMs: number; costUsd: number }
33
+ export interface AgentErrorEvent { taskId: string; agentId: number; message: string }
34
+ export interface TaskStatusEvent { taskId: string; status: string; title?: string }
35
+
36
+ export interface TaskSpawnOptions {
37
+ testCodeAgentIds?: number[];
38
+ }
39
+
40
+ // ─── 버퍼 엔트리 (늦은 구독자 리플레이용) ───────────────────────────────────
41
+
42
+ export interface BufferedAgentLog {
43
+ agentId: number;
44
+ status: string;
45
+ output: string;
46
+ durationMs?: number;
47
+ costUsd?: number;
48
+ errorMessage?: string;
49
+ }
50
+
51
+ // ─── Service ─────────────────────────────────────────────────────────────────
52
+
53
+ @Injectable()
54
+ export class TaskExecutionService extends EventEmitter implements OnModuleInit, OnModuleDestroy {
55
+ private readonly logger = new Logger(TaskExecutionService.name);
56
+
57
+ private claudeBin = 'claude';
58
+ private geminiBin = 'gemini';
59
+ private codexBin = 'codex';
60
+
61
+ /** taskId → agentId → 로그 버퍼 */
62
+ private readonly logBuffer = new Map<string, Map<number, BufferedAgentLog>>();
63
+ /** taskId → pendingAgentId 집합 */
64
+ private readonly pendingMap = new Map<string, Set<number>>();
65
+ /** `${taskId}-${agentId}` → promptId */
66
+ private readonly promptIdMap = new Map<string, string>();
67
+ /** `${taskId}-${agentId}` → { worktreePath, branchName, mainRepoDir } */
68
+ private readonly worktreeMap = new Map<string, { worktreePath: string; branchName: string; mainRepoDir: string }>();
69
+ /** `${taskId}-${agentId}` → 일반 디렉토리 변경 추적용 스냅샷 */
70
+ private readonly directorySnapshotMap = new Map<string, { workingDir: string; snapshot: DirectorySnapshot }>();
71
+ /** result 이벤트를 수신한 에이전트 키 (`${taskId}-${agentId}`) 집합 */
72
+ private readonly resultReceivedSet = new Set<string>();
73
+ /** `${taskId}-${agentId}` → runId */
74
+ private readonly runIdMap = new Map<string, number>();
75
+ /** taskId → runId (태스크 단위 run 추적) */
76
+ private readonly taskRunIdMap = new Map<string, number>();
77
+ /** `${taskId}-${agentId}` → 실행 중인 child process */
78
+ private readonly processMap = new Map<string, ChildProcess>();
79
+ /** 사용자가 중지를 요청한 에이전트 키 */
80
+ private readonly stoppingAgentSet = new Set<string>();
81
+ private isShuttingDown = false;
82
+
83
+ constructor(
84
+ @InjectRepository(TaskAgentEntity)
85
+ private readonly agentRepo: Repository<TaskAgentEntity>,
86
+ @InjectRepository(TaskAgentRunEntity)
87
+ private readonly agentRunRepo: Repository<TaskAgentRunEntity>,
88
+ @InjectRepository(TaskEntity)
89
+ private readonly taskRepo: Repository<TaskEntity>,
90
+ @InjectRepository(TaskRunEntity)
91
+ private readonly runRepo: Repository<TaskRunEntity>,
92
+ private readonly geminiAuthManager: GeminiAuthManager,
93
+ private readonly conversationService: ConversationService,
94
+ private readonly gitChangelogService: GitChangelogService,
95
+ private readonly harnessService: HarnessService,
96
+ ) {
97
+ super();
98
+ }
99
+
100
+ async onModuleInit(): Promise<void> {
101
+ this.claudeBin = this.resolveClaude();
102
+ this.logger.log(`claude 경로: ${this.claudeBin}`);
103
+ this.geminiBin = this.resolveGemini();
104
+ this.logger.log(`gemini 경로: ${this.geminiBin}`);
105
+ this.codexBin = this.resolveCodex();
106
+ this.logger.log(`codex 경로: ${this.codexBin}`);
107
+ await this.recoverStuckTasks();
108
+ }
109
+
110
+ /** 서버 재시작 시 running 상태로 남은 task/agent를 stopped으로 복구 */
111
+ private async recoverStuckTasks(): Promise<void> {
112
+ const [stuckAgents, stuckTasks, stuckRuns] = await Promise.all([
113
+ this.agentRepo.find({ where: { status: 'running' } }),
114
+ this.taskRepo.find({ where: { status: 'running' } }),
115
+ this.runRepo.find({ where: { status: 'running' } }),
116
+ ]);
117
+
118
+ if (stuckAgents.length === 0 && stuckTasks.length === 0 && stuckRuns.length === 0) return;
119
+
120
+ const taskIds = new Set<string>([
121
+ ...stuckAgents.map((a) => a.taskId),
122
+ ...stuckTasks.map((t) => t.id),
123
+ ...stuckRuns.map((r) => r.taskId),
124
+ ]);
125
+ const now = new Date();
126
+
127
+ if (stuckAgents.length > 0) {
128
+ await this.agentRepo.update(
129
+ stuckAgents.map((a) => a.id),
130
+ { status: 'stopped' },
131
+ );
132
+ }
133
+
134
+ // 연결된 task_agent_runs도 stopped 처리
135
+ const stuckAgentIds = stuckAgents.map((a) => a.id);
136
+ if (stuckAgentIds.length > 0) {
137
+ const stuckAgentRuns = await this.agentRunRepo.find({
138
+ where: stuckAgentIds.map((id) => ({ agentId: id, status: 'running' })),
139
+ });
140
+ if (stuckAgentRuns.length > 0) {
141
+ await this.agentRunRepo.update(
142
+ stuckAgentRuns.map((r) => r.id),
143
+ { status: 'stopped' },
144
+ );
145
+ }
146
+ }
147
+
148
+ if (taskIds.size > 0) {
149
+ const ids = Array.from(taskIds);
150
+ await Promise.all([
151
+ this.taskRepo.update({ id: In(ids), status: 'running' }, { status: 'stopped' }),
152
+ this.runRepo.update(
153
+ { taskId: In(ids), status: 'running' },
154
+ { status: 'stopped', completedAt: now },
155
+ ),
156
+ ]);
157
+ }
158
+
159
+ this.logger.warn(
160
+ `서버 재시작 복구: ${stuckAgents.length}개 에이전트, ${taskIds.size}개 작업을 stopped으로 변경`,
161
+ );
162
+ }
163
+
164
+ onModuleDestroy(): void {
165
+ this.isShuttingDown = true;
166
+ for (const [key, proc] of this.processMap.entries()) {
167
+ this.stoppingAgentSet.add(key);
168
+ try {
169
+ proc.kill('SIGTERM');
170
+ } catch (err) {
171
+ this.logger.warn(`프로세스 종료 실패 (${key}): ${err instanceof Error ? err.message : String(err)}`);
172
+ }
173
+ }
174
+ this.logBuffer.clear();
175
+ this.pendingMap.clear();
176
+ this.promptIdMap.clear();
177
+ this.worktreeMap.clear();
178
+ this.directorySnapshotMap.clear();
179
+ this.resultReceivedSet.clear();
180
+ this.runIdMap.clear();
181
+ this.taskRunIdMap.clear();
182
+ this.processMap.clear();
183
+ this.stoppingAgentSet.clear();
184
+ }
185
+
186
+ // ─── 공개 API ─────────────────────────────────────────────────────────
187
+
188
+ async spawnTask(task: TaskEntity, supplementNote?: string, runId?: number, options: TaskSpawnOptions = {}): Promise<void> {
189
+ if (!task.agents.length) {
190
+ throw new Error('에이전트가 없습니다. 최소 하나의 에이전트를 추가하세요.');
191
+ }
192
+
193
+ const workingDir = this.resolveWorkingDir(task.workingDir);
194
+ const isGitRepo = this.gitChangelogService.isGitRepo(workingDir);
195
+
196
+ const reqList = [...task.requirements]
197
+ .sort((a, b) => a.orderIndex - b.orderIndex)
198
+ .map((r, i) => `${i + 1}. ${r.content}`)
199
+ .join('\n');
200
+
201
+ this.logBuffer.set(task.id, new Map());
202
+ this.pendingMap.set(task.id, new Set(task.agents.map((a) => a.id)));
203
+
204
+ if (runId != null) {
205
+ this.taskRunIdMap.set(task.id, runId);
206
+ }
207
+
208
+ const testCodeAgentIds = new Set(options.testCodeAgentIds ?? []);
209
+
210
+ for (const agent of task.agents) {
211
+ const roleLabel = agent.role === 'other' && agent.customRole ? agent.customRole : agent.role;
212
+ const shouldWriteTestCode = testCodeAgentIds.has(agent.id);
213
+
214
+ // 공통 하네스 + 역할별 하네스 로드
215
+ const commonHarness = this.harnessService.findOne('common');
216
+ const roleHarness = this.harnessService.findOne(agent.role as HarnessRole);
217
+ const harnessSection = [
218
+ commonHarness?.content?.trim() ? `[공통 하네스]\n${commonHarness.content.trim()}` : '',
219
+ roleHarness?.content?.trim() ? `[${roleLabel} 하네스]\n${roleHarness.content.trim()}` : '',
220
+ ].filter(Boolean).join('\n\n');
221
+
222
+ const prompt = [
223
+ harnessSection ? `${harnessSection}\n\n` : '',
224
+ `당신은 ${roleLabel} 역할의 AI 에이전트입니다.`,
225
+ `\n\n[작업 목표]\n${task.title}`,
226
+ reqList ? `\n\n[요구사항]\n${reqList}` : '',
227
+ supplementNote ? `\n\n[이전 결과 보완 사항]\n${supplementNote}` : '',
228
+ shouldWriteTestCode
229
+ ? '\n\n[테스트 코드 작성 지시]\n이번 실행에서는 구현 변경뿐 아니라 관련 테스트 코드를 반드시 작성하세요. 프로젝트의 기존 테스트 프레임워크, 파일 위치, 네이밍 패턴을 따르고 가능한 경우 테스트를 실행해 결과를 남기세요.'
230
+ : '',
231
+ `\n\n위 작업을 수행해주세요.`,
232
+ ].join('');
233
+
234
+ await this.agentRepo.update(agent.id, { status: 'running' });
235
+ this.initAgentBuffer(task.id, agent.id);
236
+
237
+ // runId 맵 등록
238
+ if (runId != null) {
239
+ this.runIdMap.set(`${task.id}-${agent.id}`, runId);
240
+
241
+ // TaskAgentRunEntity 생성
242
+ const agentRun = this.agentRunRepo.create({
243
+ runId,
244
+ agentId: agent.id,
245
+ status: 'running',
246
+ });
247
+ const savedAgentRun = await this.agentRunRepo.save(agentRun);
248
+
249
+ // agentRunId도 맵에 저장 (완료 시 업데이트 용)
250
+ this.runIdMap.set(`agentRunId-${task.id}-${agent.id}`, savedAgentRun.id);
251
+ }
252
+
253
+ const promptId = uuidv4();
254
+ this.promptIdMap.set(`${task.id}-${agent.id}`, promptId);
255
+ await this.conversationService.create({
256
+ sessionId: task.id,
257
+ promptId,
258
+ content: prompt,
259
+ agentModel: this.getAgentModel(agent.agentType),
260
+ type: ConversationType.USER_MESSAGE,
261
+ agentId: agent.id,
262
+ runId: runId ?? null,
263
+ });
264
+
265
+ // git repo인 경우 에이전트별 worktree 생성
266
+ let agentWorkDir = workingDir;
267
+ const agentKey = this.getAgentKey(task.id, agent.id);
268
+ this.directorySnapshotMap.set(agentKey, {
269
+ workingDir,
270
+ snapshot: this.gitChangelogService.createDirectorySnapshot(workingDir),
271
+ });
272
+
273
+ if (isGitRepo) {
274
+ try {
275
+ const { worktreePath, branchName, agentWorkDir: worktreeAgentDir } = this.gitChangelogService.createWorktree(workingDir, agent.agentType);
276
+ const startCommitHash = this.gitChangelogService.getCurrentHead(workingDir);
277
+ await this.agentRepo.update(agent.id, { worktreePath, startCommitHash });
278
+
279
+ // TaskAgentRunEntity에도 worktree 정보 저장
280
+ if (runId != null) {
281
+ const agentRunId = this.runIdMap.get(`agentRunId-${task.id}-${agent.id}`);
282
+ if (agentRunId != null) {
283
+ await this.agentRunRepo.update(agentRunId as number, { worktreePath, startCommitHash });
284
+ }
285
+ }
286
+
287
+ this.worktreeMap.set(agentKey, { worktreePath, branchName, mainRepoDir: workingDir });
288
+ agentWorkDir = worktreeAgentDir;
289
+ } catch (err) {
290
+ this.logger.warn(`Agent ${agent.id} worktree 생성 실패, 원본 디렉토리 사용: ${err}`);
291
+ }
292
+ }
293
+
294
+ if (agent.agentType === 'gemini') {
295
+ this.spawnGeminiAgent(task.id, task.title, agent.id, agentWorkDir, prompt);
296
+ } else if (agent.agentType === 'codex') {
297
+ this.spawnCodexAgent(task.id, task.title, agent.id, agentWorkDir, prompt);
298
+ } else {
299
+ this.spawnClaudeAgent(task.id, task.title, agent.id, agentWorkDir, prompt);
300
+ }
301
+ }
302
+
303
+ await this.taskRepo.update(task.id, { status: 'running' });
304
+ this.emit('task:status', { taskId: task.id, status: 'running', title: task.title } as TaskStatusEvent);
305
+ }
306
+
307
+ async stopTask(task: TaskEntity): Promise<void> {
308
+ const pending = this.pendingMap.get(task.id);
309
+ if (pending) {
310
+ pending.clear();
311
+ this.pendingMap.delete(task.id);
312
+ }
313
+
314
+ for (const agent of task.agents) {
315
+ const isRunning = agent.status === 'running' || this.processMap.has(this.getAgentKey(task.id, agent.id));
316
+ if (isRunning) {
317
+ this.requestAgentStop(task.id, agent.id);
318
+ await this.agentRepo.update(agent.id, { status: 'stopped' });
319
+ this.updateAgentBuffer(task.id, agent.id, { status: 'stopped' });
320
+
321
+ // TaskAgentRunEntity 상태 업데이트
322
+ await this.updateAgentRunStatus(task.id, agent.id, 'stopped');
323
+
324
+ const ev: AgentErrorEvent = { taskId: task.id, agentId: agent.id, message: '수동으로 중지됐습니다.' };
325
+ this.emit('agent:error', ev);
326
+ }
327
+ }
328
+
329
+ await this.taskRepo.update(task.id, { status: 'stopped' });
330
+
331
+ const taskRunId = this.taskRunIdMap.get(task.id);
332
+ if (taskRunId != null) {
333
+ await this.runRepo.update(taskRunId, { status: 'stopped', completedAt: new Date() });
334
+ this.taskRunIdMap.delete(task.id);
335
+ }
336
+
337
+ this.emit('task:status', { taskId: task.id, status: 'stopped', title: task.title } as TaskStatusEvent);
338
+ }
339
+
340
+ getBufferedLogs(taskId: string): BufferedAgentLog[] {
341
+ const agentMap = this.logBuffer.get(taskId);
342
+ if (!agentMap) return [];
343
+ return Array.from(agentMap.values());
344
+ }
345
+
346
+ // ─── Claude 스폰 ──────────────────────────────────────────────────────
347
+
348
+ private spawnClaudeAgent(
349
+ taskId: string,
350
+ taskTitle: string,
351
+ agentId: number,
352
+ workingDir: string,
353
+ prompt: string,
354
+ ): void {
355
+ const args = [
356
+ '--output-format', 'stream-json',
357
+ '--verbose',
358
+ '--print',
359
+ '--dangerously-skip-permissions',
360
+ '-p', prompt,
361
+ ];
362
+
363
+ this.logger.log(`[Task:${taskTitle}] Claude Agent ${agentId} 실행 — cwd: ${workingDir}`);
364
+
365
+ const proc = spawn(this.claudeBin, args, {
366
+ cwd: workingDir,
367
+ env: process.env,
368
+ stdio: ['ignore', 'pipe', 'pipe'],
369
+ });
370
+ this.registerProcess(taskId, agentId, proc);
371
+
372
+ let buffer = '';
373
+
374
+ proc.stdout.on('data', (chunk: Buffer) => {
375
+ if (this.isAgentStopping(taskId, agentId)) return;
376
+ buffer += chunk.toString();
377
+ const lines = buffer.split('\n');
378
+ buffer = lines.pop() ?? '';
379
+ for (const line of lines) {
380
+ const trimmed = line.trim();
381
+ if (trimmed) this.handleClaudeLine(taskId, agentId, trimmed);
382
+ }
383
+ });
384
+
385
+ proc.stderr.on('data', (chunk: Buffer) => {
386
+ if (this.isAgentStopping(taskId, agentId)) return;
387
+ this.logger.warn(`[Task:${taskId}] Claude Agent ${agentId} stderr: ${chunk.toString().slice(0, 200)}`);
388
+ });
389
+
390
+ proc.on('close', (exitCode) => {
391
+ if (this.isAgentStopping(taskId, agentId)) {
392
+ this.cleanupAgentRuntime(taskId, agentId);
393
+ return;
394
+ }
395
+
396
+ if (buffer.trim()) this.handleClaudeLine(taskId, agentId, buffer.trim());
397
+
398
+ const key = `${taskId}-${agentId}`;
399
+ const hadResult = this.resultReceivedSet.delete(key);
400
+
401
+ if (!hadResult) {
402
+ this.logger.warn(`[Task:${taskId}] Claude Agent ${agentId} exited (code: ${exitCode}) without result`);
403
+ void this.agentRepo.update(agentId, { status: 'error' });
404
+ this.updateAgentBuffer(taskId, agentId, { status: 'error', errorMessage: `종료 코드: ${exitCode}` });
405
+ void this.updateAgentRunStatus(taskId, agentId, 'error');
406
+ this.emit('agent:error', { taskId, agentId, message: `프로세스가 코드 ${exitCode}로 종료됐습니다.` } as AgentErrorEvent);
407
+ }
408
+
409
+ void this.finalizeAgent(taskId, agentId);
410
+ });
411
+
412
+ proc.on('error', (err) => {
413
+ if (this.isAgentStopping(taskId, agentId)) return;
414
+ this.logger.error(`[Task:${taskId}] Claude Agent ${agentId} spawn error: ${err.message}`);
415
+ this.resultReceivedSet.delete(`${taskId}-${agentId}`);
416
+ void this.agentRepo.update(agentId, { status: 'error' });
417
+ this.updateAgentBuffer(taskId, agentId, { status: 'error', errorMessage: err.message });
418
+ void this.updateAgentRunStatus(taskId, agentId, 'error');
419
+ this.emit('agent:error', { taskId, agentId, message: err.message } as AgentErrorEvent);
420
+ void this.finalizeAgent(taskId, agentId);
421
+ });
422
+ }
423
+
424
+ // ─── Codex 스폰 ───────────────────────────────────────────────────────
425
+
426
+ private spawnCodexAgent(
427
+ taskId: string,
428
+ taskTitle: string,
429
+ agentId: number,
430
+ workingDir: string,
431
+ prompt: string,
432
+ ): void {
433
+ this.logger.log(`[Task:${taskTitle}] Codex Agent ${agentId} 실행 — cwd: ${workingDir}`);
434
+
435
+ const codexArgs = ['exec', '-c', 'approval_policy=never', '-c', 'sandbox_mode=danger-full-access', prompt];
436
+ const [codexCmd, codexSpawnArgs] = IS_WIN
437
+ ? ['cmd.exe', ['/c', 'codex', ...codexArgs]]
438
+ : [this.codexBin, codexArgs];
439
+
440
+ const proc = spawn(codexCmd, codexSpawnArgs, {
441
+ cwd: workingDir,
442
+ env: process.env,
443
+ stdio: ['ignore', 'pipe', 'pipe'],
444
+ });
445
+ this.registerProcess(taskId, agentId, proc);
446
+
447
+ let output = '';
448
+
449
+ const handleChunk = (chunk: Buffer): void => {
450
+ if (this.isAgentStopping(taskId, agentId)) return;
451
+ const raw = chunk.toString();
452
+ const text = raw
453
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
454
+ .replace(/\x1b\][^\x07]*\x07/g, '')
455
+ .replace(/\r/g, '');
456
+ if (!text.trim()) return;
457
+ output += text;
458
+ const prev = this.getAgentBuffer(taskId, agentId);
459
+ this.updateAgentBuffer(taskId, agentId, { output: prev.output + text });
460
+ this.emit('agent:output', { taskId, agentId, text } as AgentOutputEvent);
461
+ };
462
+
463
+ proc.stdout.on('data', handleChunk);
464
+ proc.stderr.on('data', handleChunk);
465
+
466
+ proc.on('close', (exitCode) => {
467
+ if (this.isAgentStopping(taskId, agentId)) {
468
+ this.cleanupAgentRuntime(taskId, agentId);
469
+ return;
470
+ }
471
+
472
+ const isError = (exitCode ?? 0) !== 0;
473
+ const status = isError ? 'error' : 'completed';
474
+
475
+ void this.agentRepo.update(agentId, { status });
476
+ this.updateAgentBuffer(taskId, agentId, { status });
477
+ void this.updateAgentRunStatus(taskId, agentId, status, 0, 0);
478
+
479
+ if (isError) {
480
+ this.updateAgentBuffer(taskId, agentId, { errorMessage: `종료 코드: ${exitCode}` });
481
+ }
482
+
483
+ this.emit('agent:done', {
484
+ taskId,
485
+ agentId,
486
+ result: output.trim(),
487
+ isError,
488
+ durationMs: 0,
489
+ costUsd: 0,
490
+ } as AgentDoneEvent);
491
+ void this.finalizeAgent(taskId, agentId);
492
+ });
493
+
494
+ proc.on('error', (err) => {
495
+ if (this.isAgentStopping(taskId, agentId)) return;
496
+ this.logger.error(`[Task:${taskId}] Codex Agent ${agentId} spawn error: ${err.message}`);
497
+ void this.agentRepo.update(agentId, { status: 'error' });
498
+ this.updateAgentBuffer(taskId, agentId, { status: 'error', errorMessage: err.message });
499
+ void this.updateAgentRunStatus(taskId, agentId, 'error');
500
+ this.emit('agent:error', { taskId, agentId, message: err.message } as AgentErrorEvent);
501
+ void this.finalizeAgent(taskId, agentId);
502
+ });
503
+ }
504
+
505
+ // ─── Gemini 스폰 ──────────────────────────────────────────────────────
506
+
507
+ private spawnGeminiAgent(
508
+ taskId: string,
509
+ taskTitle: string,
510
+ agentId: number,
511
+ workingDir: string,
512
+ prompt: string,
513
+ ): void {
514
+ this.logger.log(`[Task:${taskTitle}] Gemini Agent ${agentId} 실행 — bin: ${this.geminiBin}, cwd: ${workingDir}`);
515
+
516
+ const proc = spawn(this.geminiBin, ['-y', '-p', prompt], {
517
+ cwd: workingDir,
518
+ env: this.geminiAuthManager.getEnvForGemini(),
519
+ stdio: ['ignore', 'pipe', 'pipe'],
520
+ });
521
+ this.registerProcess(taskId, agentId, proc);
522
+
523
+ let output = '';
524
+
525
+ const handleChunk = (chunk: Buffer): void => {
526
+ if (this.isAgentStopping(taskId, agentId)) return;
527
+ const raw = chunk.toString();
528
+ const text = raw
529
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
530
+ .replace(/\x1b\][^\x07]*\x07/g, '')
531
+ .replace(/\r/g, '');
532
+ if (!text.trim()) return;
533
+ output += text;
534
+ const prev = this.getAgentBuffer(taskId, agentId);
535
+ this.updateAgentBuffer(taskId, agentId, { output: prev.output + text });
536
+ this.emit('agent:output', { taskId, agentId, text } as AgentOutputEvent);
537
+ };
538
+
539
+ proc.stdout.on('data', handleChunk);
540
+ proc.stderr.on('data', handleChunk);
541
+
542
+ proc.on('close', (exitCode) => {
543
+ if (this.isAgentStopping(taskId, agentId)) {
544
+ this.cleanupAgentRuntime(taskId, agentId);
545
+ return;
546
+ }
547
+
548
+ const isError = (exitCode ?? 0) !== 0;
549
+ const status = isError ? 'error' : 'completed';
550
+
551
+ void this.agentRepo.update(agentId, { status });
552
+ this.updateAgentBuffer(taskId, agentId, { status });
553
+ void this.updateAgentRunStatus(taskId, agentId, status, 0, 0);
554
+
555
+ if (isError) {
556
+ this.updateAgentBuffer(taskId, agentId, { errorMessage: `종료 코드: ${exitCode}` });
557
+ }
558
+
559
+ this.emit('agent:done', {
560
+ taskId,
561
+ agentId,
562
+ result: output.trim(),
563
+ isError,
564
+ durationMs: 0,
565
+ costUsd: 0,
566
+ } as AgentDoneEvent);
567
+ void this.finalizeAgent(taskId, agentId);
568
+ });
569
+
570
+ proc.on('error', (err) => {
571
+ if (this.isAgentStopping(taskId, agentId)) return;
572
+ this.logger.error(`[Task:${taskId}] Gemini Agent ${agentId} spawn error: ${err.message}`);
573
+ void this.agentRepo.update(agentId, { status: 'error' });
574
+ this.updateAgentBuffer(taskId, agentId, { status: 'error', errorMessage: err.message });
575
+ void this.updateAgentRunStatus(taskId, agentId, 'error');
576
+ this.emit('agent:error', { taskId, agentId, message: err.message } as AgentErrorEvent);
577
+ void this.finalizeAgent(taskId, agentId);
578
+ });
579
+ }
580
+
581
+ // ─── Stream-JSON 파싱 ─────────────────────────────────────────────────
582
+
583
+ private handleClaudeLine(taskId: string, agentId: number, line: string): void {
584
+ let event: ClaudeStreamEvent;
585
+ try { event = JSON.parse(line) as ClaudeStreamEvent; }
586
+ catch { return; }
587
+
588
+ switch (event.type) {
589
+ case 'assistant': {
590
+ const e = event as ClaudeAssistantEvent;
591
+ for (const block of e.message.content) {
592
+ if (block.type === 'text' && block.text.trim()) {
593
+ const prev = this.getAgentBuffer(taskId, agentId);
594
+ this.updateAgentBuffer(taskId, agentId, { output: prev.output + block.text });
595
+ this.emit('agent:output', { taskId, agentId, text: block.text } as AgentOutputEvent);
596
+ }
597
+ if (block.type === 'tool_use') {
598
+ const toolLine = `\n⚙ ${block.name}(${JSON.stringify(block.input).slice(0, 120)})\n`;
599
+ const prev = this.getAgentBuffer(taskId, agentId);
600
+ this.updateAgentBuffer(taskId, agentId, { output: prev.output + toolLine });
601
+ this.emit('agent:tool', { taskId, agentId, tool: block.name, input: block.input } as AgentToolEvent);
602
+ }
603
+ }
604
+ break;
605
+ }
606
+
607
+ case 'result': {
608
+ const e = event as ClaudeResultEvent;
609
+ const status = e.is_error ? 'error' : 'completed';
610
+
611
+ this.resultReceivedSet.add(`${taskId}-${agentId}`);
612
+
613
+ void this.agentRepo.update(agentId, { status });
614
+ this.updateAgentBuffer(taskId, agentId, {
615
+ status,
616
+ durationMs: e.duration_ms,
617
+ costUsd: e.total_cost_usd,
618
+ });
619
+ void this.updateAgentRunStatus(taskId, agentId, status, e.duration_ms, e.total_cost_usd);
620
+
621
+ this.emit('agent:done', {
622
+ taskId,
623
+ agentId,
624
+ result: e.result,
625
+ isError: e.is_error,
626
+ durationMs: e.duration_ms,
627
+ costUsd: e.total_cost_usd,
628
+ } as AgentDoneEvent);
629
+ break;
630
+ }
631
+ }
632
+ }
633
+
634
+ // ─── 에이전트 완료 후처리 ─────────────────────────────────────────────
635
+
636
+ private async finalizeAgent(taskId: string, agentId: number): Promise<void> {
637
+ try {
638
+ const worktreeKey = this.getAgentKey(taskId, agentId);
639
+ const worktreeInfo = this.worktreeMap.get(worktreeKey);
640
+ const directorySnapshotInfo = this.directorySnapshotMap.get(worktreeKey);
641
+ this.worktreeMap.delete(worktreeKey);
642
+ this.directorySnapshotMap.delete(worktreeKey);
643
+
644
+ if (worktreeInfo) {
645
+ const { worktreePath } = worktreeInfo;
646
+
647
+ const [agent, task] = await Promise.all([
648
+ this.agentRepo.findOne({ where: { id: agentId } }),
649
+ this.taskRepo.findOne({ where: { id: taskId } }),
650
+ ]);
651
+
652
+ if (agent?.startCommitHash) {
653
+ const roleLabel = agent.role === 'other' && agent.customRole
654
+ ? agent.customRole
655
+ : agent.role;
656
+ const commitMessage = `feat(${agent.agentType}/${roleLabel}): ${task?.title ?? taskId}`;
657
+
658
+ const runId = this.runIdMap.get(`${taskId}-${agentId}`);
659
+ await this.gitChangelogService.captureAndSave(
660
+ taskId,
661
+ agentId,
662
+ worktreePath,
663
+ agent.startCommitHash,
664
+ commitMessage,
665
+ runId,
666
+ );
667
+ }
668
+ }
669
+
670
+ if (directorySnapshotInfo) {
671
+ const runId = this.runIdMap.get(`${taskId}-${agentId}`);
672
+ await this.gitChangelogService.captureDirectoryAndSave(
673
+ taskId,
674
+ agentId,
675
+ directorySnapshotInfo.workingDir,
676
+ directorySnapshotInfo.snapshot,
677
+ runId,
678
+ );
679
+ }
680
+
681
+ await this.checkTaskCompletion(taskId, agentId);
682
+ } finally {
683
+ this.cleanupAgentRuntime(taskId, agentId);
684
+ }
685
+ }
686
+
687
+ // ─── 완료 감지 ────────────────────────────────────────────────────────
688
+
689
+ private async checkTaskCompletion(taskId: string, agentId: number): Promise<void> {
690
+ const pending = this.pendingMap.get(taskId);
691
+ if (!pending) return;
692
+ pending.delete(agentId);
693
+
694
+ await this.saveAgentMessage(taskId, agentId);
695
+
696
+ if (pending.size > 0) return;
697
+ this.pendingMap.delete(taskId);
698
+
699
+ const [agents, taskEntity] = await Promise.all([
700
+ this.agentRepo.find({ where: { taskId } }),
701
+ this.taskRepo.findOne({ where: { id: taskId } }),
702
+ ]);
703
+ const hasError = agents.some((a) => a.status === 'error');
704
+ const newStatus = hasError ? 'error' : 'completed';
705
+
706
+ await this.taskRepo.update(taskId, { status: newStatus });
707
+
708
+ // TaskRunEntity 완료 처리
709
+ const taskRunId = this.taskRunIdMap.get(taskId);
710
+ if (taskRunId != null) {
711
+ await this.runRepo.update(taskRunId, { status: newStatus, completedAt: new Date() });
712
+ this.taskRunIdMap.delete(taskId);
713
+ }
714
+
715
+ this.emit('task:status', { taskId, status: newStatus, title: taskEntity?.title } as TaskStatusEvent);
716
+ this.logger.log(`Task ${taskId} → ${newStatus}`);
717
+ }
718
+
719
+ private async saveAgentMessage(taskId: string, agentId: number): Promise<void> {
720
+ const promptId = this.promptIdMap.get(`${taskId}-${agentId}`);
721
+ if (!promptId) return;
722
+ this.promptIdMap.delete(`${taskId}-${agentId}`);
723
+
724
+ const buf = this.getAgentBuffer(taskId, agentId);
725
+ if (!buf.output.trim()) return;
726
+
727
+ const agent = await this.agentRepo.findOne({ where: { id: agentId } });
728
+ const agentModel = this.getAgentModel(agent?.agentType ?? 'claude');
729
+
730
+ const runId = this.runIdMap.get(`${taskId}-${agentId}`);
731
+
732
+ try {
733
+ await this.conversationService.create({
734
+ sessionId: taskId,
735
+ promptId,
736
+ content: buf.output.trim(),
737
+ agentModel,
738
+ type: ConversationType.AGENT_MESSAGE,
739
+ agentId,
740
+ runId: runId ?? null,
741
+ });
742
+ } catch (err) {
743
+ this.logger.error(`Agent ${agentId} 메시지 저장 실패: ${err}`);
744
+ }
745
+ }
746
+
747
+ /** 서버 재시작 후 버퍼가 비어있을 때 DB에서 로그 복원 — 최신 run 기준 */
748
+ async getLogsFromDb(taskId: string): Promise<BufferedAgentLog[]> {
749
+ const agents = await this.agentRepo.find({ where: { taskId } });
750
+ if (!agents.length) return [];
751
+
752
+ // 최신 run의 conversations만 조회
753
+ const latestRun = await this.agentRunRepo.findOne({
754
+ where: { agentId: agents[0].id },
755
+ order: { runId: 'DESC' },
756
+ });
757
+
758
+ let conversations;
759
+ if (latestRun?.runId != null) {
760
+ conversations = await this.conversationService.findByRun(latestRun.runId);
761
+ } else {
762
+ // runId가 없는 레거시 데이터: sessionId로 조회
763
+ conversations = await this.conversationService.findBySession(taskId);
764
+ }
765
+
766
+ const statusMap = new Map(agents.map((a) => [a.id, a.status]));
767
+
768
+ const agentMessages = conversations.filter(
769
+ (c) => c.type === ConversationType.AGENT_MESSAGE && c.agentId != null,
770
+ );
771
+
772
+ const byAgent = new Map<number, string>();
773
+ for (const msg of agentMessages) {
774
+ const id = Number(msg.agentId!);
775
+ byAgent.set(id, (byAgent.get(id) ?? '') + msg.content);
776
+ }
777
+
778
+ return agents.map((agent) => ({
779
+ agentId: agent.id,
780
+ status: statusMap.get(agent.id) ?? 'stopped',
781
+ output: byAgent.get(agent.id) ?? '',
782
+ }));
783
+ }
784
+
785
+ // ─── TaskAgentRun 상태 업데이트 헬퍼 ─────────────────────────────────
786
+
787
+ private async updateAgentRunStatus(
788
+ taskId: string,
789
+ agentId: number,
790
+ status: string,
791
+ durationMs?: number,
792
+ costUsd?: number,
793
+ ): Promise<void> {
794
+ const agentRunId = this.runIdMap.get(`agentRunId-${taskId}-${agentId}`);
795
+ if (agentRunId == null) return;
796
+ await this.agentRunRepo.update(agentRunId as number, {
797
+ status,
798
+ ...(durationMs != null ? { durationMs } : {}),
799
+ ...(costUsd != null ? { costUsd } : {}),
800
+ });
801
+ }
802
+
803
+ // ─── 버퍼 헬퍼 ────────────────────────────────────────────────────────
804
+
805
+ private initAgentBuffer(taskId: string, agentId: number): void {
806
+ if (!this.logBuffer.has(taskId)) this.logBuffer.set(taskId, new Map());
807
+ this.logBuffer.get(taskId)!.set(agentId, { agentId, status: 'running', output: '' });
808
+ }
809
+
810
+ private getAgentBuffer(taskId: string, agentId: number): BufferedAgentLog {
811
+ return this.logBuffer.get(taskId)?.get(agentId) ?? { agentId, status: 'running', output: '' };
812
+ }
813
+
814
+ private updateAgentBuffer(taskId: string, agentId: number, patch: Partial<BufferedAgentLog>): void {
815
+ const agentMap = this.logBuffer.get(taskId);
816
+ if (!agentMap) return;
817
+ const cur = agentMap.get(agentId) ?? { agentId, status: 'running', output: '' };
818
+ agentMap.set(agentId, { ...cur, ...patch });
819
+ }
820
+
821
+ private getAgentKey(taskId: string, agentId: number): string {
822
+ return `${taskId}-${agentId}`;
823
+ }
824
+
825
+ private getAgentModel(agentType: TaskAgentEntity['agentType']): AgentModel {
826
+ if (agentType === 'gemini') return AgentModel.GEMINI;
827
+ if (agentType === 'codex') return AgentModel.CODEX;
828
+ if (agentType === 'opencode') return AgentModel.OPENCODE;
829
+ return AgentModel.CLAUDE;
830
+ }
831
+
832
+ private registerProcess(taskId: string, agentId: number, proc: ChildProcess): void {
833
+ this.processMap.set(this.getAgentKey(taskId, agentId), proc);
834
+ }
835
+
836
+ private isAgentStopping(taskId: string, agentId: number): boolean {
837
+ return this.isShuttingDown || this.stoppingAgentSet.has(this.getAgentKey(taskId, agentId));
838
+ }
839
+
840
+ private requestAgentStop(taskId: string, agentId: number): void {
841
+ const key = this.getAgentKey(taskId, agentId);
842
+ const proc = this.processMap.get(key);
843
+ if (!proc) return;
844
+
845
+ this.stoppingAgentSet.add(key);
846
+ try {
847
+ proc.kill('SIGTERM');
848
+ const forceKill = setTimeout(() => {
849
+ if (!this.processMap.has(key)) return;
850
+ try {
851
+ proc.kill('SIGKILL');
852
+ } catch (err) {
853
+ this.logger.warn(`프로세스 강제 종료 실패 (${key}): ${err instanceof Error ? err.message : String(err)}`);
854
+ }
855
+ }, 5000);
856
+ forceKill.unref?.();
857
+ } catch (err) {
858
+ this.logger.warn(`프로세스 종료 실패 (${key}): ${err instanceof Error ? err.message : String(err)}`);
859
+ }
860
+ }
861
+
862
+ private cleanupAgentRuntime(taskId: string, agentId: number): void {
863
+ const key = this.getAgentKey(taskId, agentId);
864
+ this.processMap.delete(key);
865
+ this.stoppingAgentSet.delete(key);
866
+ this.resultReceivedSet.delete(key);
867
+ this.promptIdMap.delete(key);
868
+ this.worktreeMap.delete(key);
869
+ this.directorySnapshotMap.delete(key);
870
+ this.runIdMap.delete(key);
871
+ this.runIdMap.delete(`agentRunId-${key}`);
872
+ }
873
+
874
+ // ─── 환경 헬퍼 ────────────────────────────────────────────────────────
875
+
876
+ private resolveClaude(): string {
877
+ const candidates = [
878
+ (): string => execSync('which claude', { encoding: 'utf8', timeout: 2000 }).trim(),
879
+ (): string => {
880
+ const home = process.env.HOME ?? '';
881
+ const nvmDefault = `${home}/.nvm/versions/node/${process.version}/bin/claude`;
882
+ if (fs.existsSync(nvmDefault)) return nvmDefault;
883
+ throw new Error('not found');
884
+ },
885
+ (): string => {
886
+ const npmBin = execSync('npm bin -g', { encoding: 'utf8', timeout: 2000 }).trim();
887
+ const p = `${npmBin}/claude`;
888
+ if (fs.existsSync(p)) return p;
889
+ throw new Error('not found');
890
+ },
891
+ ];
892
+
893
+ for (const fn of candidates) {
894
+ try {
895
+ const p = fn();
896
+ if (p) return p;
897
+ } catch {}
898
+ }
899
+
900
+ this.logger.warn('claude 바이너리 경로를 자동 탐지하지 못했습니다. "claude"로 폴백합니다.');
901
+ return 'claude';
902
+ }
903
+
904
+ private resolveGemini(): string {
905
+ const candidates = [
906
+ (): string => execSync('which gemini', { encoding: 'utf8', timeout: 2000 }).trim(),
907
+ (): string => {
908
+ const home = process.env.HOME ?? '';
909
+ const nvmDefault = `${home}/.nvm/versions/node/${process.version}/bin/gemini`;
910
+ if (fs.existsSync(nvmDefault)) return nvmDefault;
911
+ throw new Error('not found');
912
+ },
913
+ (): string => {
914
+ const npmBin = execSync('npm bin -g', { encoding: 'utf8', timeout: 2000 }).trim();
915
+ const p = `${npmBin}/gemini`;
916
+ if (fs.existsSync(p)) return p;
917
+ throw new Error('not found');
918
+ },
919
+ ];
920
+
921
+ for (const fn of candidates) {
922
+ try {
923
+ const p = fn();
924
+ if (p) return p;
925
+ } catch {}
926
+ }
927
+
928
+ this.logger.warn('gemini 바이너리 경로를 자동 탐지하지 못했습니다. "gemini"로 폴백합니다.');
929
+ return 'gemini';
930
+ }
931
+
932
+ private resolveCodex(): string {
933
+ const candidates = [
934
+ (): string => execSync('which codex', { encoding: 'utf8', timeout: 2000 }).trim(),
935
+ (): string => {
936
+ const home = process.env.HOME ?? '';
937
+ const nvmDefault = `${home}/.nvm/versions/node/${process.version}/bin/codex`;
938
+ if (fs.existsSync(nvmDefault)) return nvmDefault;
939
+ throw new Error('not found');
940
+ },
941
+ (): string => {
942
+ const npmBin = execSync('npm bin -g', { encoding: 'utf8', timeout: 2000 }).trim();
943
+ const p = `${npmBin}/codex`;
944
+ if (fs.existsSync(p)) return p;
945
+ throw new Error('not found');
946
+ },
947
+ ];
948
+
949
+ for (const fn of candidates) {
950
+ try {
951
+ const p = fn();
952
+ if (p) return p;
953
+ } catch {}
954
+ }
955
+
956
+ this.logger.warn('codex 바이너리 경로를 자동 탐지하지 못했습니다. "codex"로 폴백합니다.');
957
+ return 'codex';
958
+ }
959
+
960
+ private resolveWorkingDir(workingDir: string | null): string {
961
+ try {
962
+ if (!workingDir) {
963
+ return process.cwd();
964
+ }
965
+ if (!fs.existsSync(workingDir)) {
966
+ throw new BadRequestException(`작업 디렉토리가 존재하지 않습니다: ${workingDir}`);
967
+ }
968
+ if (!fs.statSync(workingDir).isDirectory()) {
969
+ throw new BadRequestException(`작업 디렉토리가 폴더가 아닙니다: ${workingDir}`);
970
+ }
971
+ return workingDir;
972
+ } catch (err) {
973
+ if (err instanceof BadRequestException) throw err;
974
+ throw new BadRequestException(`작업 디렉토리를 확인할 수 없습니다: ${workingDir}`);
975
+ }
976
+ }
977
+
978
+ }