@silbaram/artifact-driven-agent 0.1.6 → 0.1.9

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 (80) hide show
  1. package/README.md +709 -516
  2. package/ai-dev-team/.ada-status.json +10 -0
  3. package/ai-dev-team/.ada-version +6 -0
  4. package/ai-dev-team/.current-template +1 -0
  5. package/ai-dev-team/.sessions/logs/20260124-014551-00f04724.log +5 -0
  6. package/ai-dev-team/.sessions/logs/20260124-014623-cb2b1d44.log +5 -0
  7. package/ai-dev-team/ada.config.json +15 -0
  8. package/ai-dev-team/artifacts/api.md +212 -0
  9. package/ai-dev-team/artifacts/decision.md +72 -0
  10. package/ai-dev-team/artifacts/improvement-reports/IMP-0000-template.md +57 -0
  11. package/ai-dev-team/artifacts/plan.md +187 -0
  12. package/ai-dev-team/artifacts/project.md +193 -0
  13. package/ai-dev-team/artifacts/sprints/_template/docs/release-notes.md +37 -0
  14. package/ai-dev-team/artifacts/sprints/_template/meta.md +54 -0
  15. package/ai-dev-team/artifacts/sprints/_template/retrospective.md +50 -0
  16. package/ai-dev-team/artifacts/sprints/_template/review-reports/review-template.md +49 -0
  17. package/ai-dev-team/artifacts/sprints/_template/tasks/task-template.md +43 -0
  18. package/ai-dev-team/artifacts/ui.md +104 -0
  19. package/ai-dev-team/roles/analyzer.md +265 -0
  20. package/ai-dev-team/roles/developer.md +222 -0
  21. package/ai-dev-team/roles/documenter.md +715 -0
  22. package/ai-dev-team/roles/improver.md +461 -0
  23. package/ai-dev-team/roles/manager.md +544 -0
  24. package/ai-dev-team/roles/planner.md +398 -0
  25. package/ai-dev-team/roles/reviewer.md +294 -0
  26. package/ai-dev-team/rules/api-change.md +198 -0
  27. package/ai-dev-team/rules/document-priority.md +199 -0
  28. package/ai-dev-team/rules/escalation.md +172 -0
  29. package/ai-dev-team/rules/iteration.md +236 -0
  30. package/ai-dev-team/rules/rfc.md +31 -0
  31. package/ai-dev-team/rules/rollback.md +218 -0
  32. package/bin/cli.js +49 -5
  33. package/core/artifacts/sprints/_template/meta.md +4 -4
  34. package/core/docs-templates/mkdocs/docs/architecture/overview.md +29 -0
  35. package/core/docs-templates/mkdocs/docs/changelog.md +36 -0
  36. package/core/docs-templates/mkdocs/docs/contributing/contributing.md +60 -0
  37. package/core/docs-templates/mkdocs/docs/getting-started/configuration.md +51 -0
  38. package/core/docs-templates/mkdocs/docs/getting-started/installation.md +41 -0
  39. package/core/docs-templates/mkdocs/docs/getting-started/quick-start.md +56 -0
  40. package/core/docs-templates/mkdocs/docs/guides/api-reference.md +83 -0
  41. package/core/docs-templates/mkdocs/docs/index.md +32 -0
  42. package/core/docs-templates/mkdocs/mkdocs.yml +86 -0
  43. package/core/roles/analyzer.md +32 -10
  44. package/core/roles/developer.md +222 -223
  45. package/core/roles/documenter.md +592 -170
  46. package/core/roles/improver.md +461 -0
  47. package/core/roles/manager.md +4 -1
  48. package/core/roles/planner.md +160 -10
  49. package/core/roles/reviewer.md +31 -3
  50. package/core/rules/document-priority.md +2 -1
  51. package/core/rules/rollback.md +3 -3
  52. package/package.json +1 -1
  53. package/src/commands/config.js +371 -0
  54. package/src/commands/docs.js +502 -0
  55. package/src/commands/interactive.js +324 -33
  56. package/src/commands/monitor.js +236 -0
  57. package/src/commands/run.js +360 -122
  58. package/src/commands/sessions.js +270 -70
  59. package/src/commands/setup.js +22 -1
  60. package/src/commands/sprint.js +295 -54
  61. package/src/commands/status.js +34 -1
  62. package/src/commands/upgrade.js +416 -0
  63. package/src/commands/validate.js +4 -3
  64. package/src/index.js +1 -0
  65. package/src/ui/dashboard.js +518 -0
  66. package/src/ui/keyHandler.js +147 -0
  67. package/src/ui/quickActions.js +111 -0
  68. package/src/utils/config.js +74 -0
  69. package/src/utils/files.js +70 -3
  70. package/src/utils/sessionState.js +472 -328
  71. package/src/utils/sessionState.process.test.js +101 -0
  72. package/src/utils/sessionState.test.js +183 -0
  73. package/src/utils/sprintUtils.js +134 -0
  74. package/src/utils/taskParser.js +134 -0
  75. package/src/utils/taskParser.test.js +76 -0
  76. package/ai-dev-team/artifacts/features/_template/qa.md +0 -16
  77. package/examples/todo-app/README.md +0 -23
  78. package/examples/todo-app/artifacts/backlog.md +0 -23
  79. package/examples/todo-app/artifacts/plan.md +0 -23
  80. package/examples/todo-app/artifacts/project.md +0 -23
@@ -1,369 +1,513 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
- import { getWorkspaceDir } from './files.js';
4
-
5
- /**
6
- * .ada-status.json 파일 경로 반환
7
- */
8
- export function getStatusFilePath() {
9
- return path.join(getWorkspaceDir(), '.ada-status.json');
10
- }
11
-
12
- /**
13
- * 상태 파일 템플릿
14
- */
15
- function getEmptyStatus() {
16
- return {
17
- version: '1.0',
18
- updatedAt: new Date().toISOString(),
19
- currentPhase: 'planning',
20
- activeSessions: [],
21
- pendingQuestions: [],
22
- taskProgress: {},
23
- notifications: [],
24
- locks: {}
25
- };
26
- }
27
-
28
- /**
29
- * 상태 파일 읽기 (없으면 초기화)
30
- * 동시 접근 시 재시도 로직 포함
31
- */
32
- export function readStatus(retries = 3) {
33
- const statusFile = getStatusFilePath();
34
-
35
- for (let i = 0; i < retries; i++) {
36
- try {
37
- if (!fs.existsSync(statusFile)) {
38
- const initialStatus = getEmptyStatus();
39
- fs.writeFileSync(statusFile, JSON.stringify(initialStatus, null, 2));
40
- return initialStatus;
41
- }
42
-
43
- let content = fs.readFileSync(statusFile, 'utf-8');
44
- try {
45
- const status = JSON.parse(content);
46
- // 스키마 검증 및 누락된 필드 추가
47
- const emptyStatus = getEmptyStatus();
48
- Object.keys(emptyStatus).forEach(key => {
49
- if (status[key] === undefined) {
50
- status[key] = emptyStatus[key];
51
- }
52
- });
53
- return status;
54
- } catch (parseError) {
55
- if (parseError instanceof SyntaxError && parseError.message.includes('JSON')) {
56
- // 파일 내용에서 백슬래시 이스케이프 처리
57
- content = content.replace(/\\/g, '\\\\');
58
- const status = JSON.parse(content);
59
-
60
- // 스키마 검증 및 누락된 필드 추가
61
- const emptyStatus = getEmptyStatus();
62
- Object.keys(emptyStatus).forEach(key => {
63
- if (status[key] === undefined) {
64
- status[key] = emptyStatus[key];
65
- }
66
- });
67
-
68
- // 복구된 파일 저장
69
- fs.writeFileSync(statusFile, JSON.stringify(status, null, 2));
70
- return status;
71
- }
72
- throw parseError;
73
- }
74
- } catch (err) {
75
- if (i === retries - 1) {
76
- console.error('⚠️ 상태 파일 읽기 실패:', err.message);
77
- return getEmptyStatus();
78
- }
79
- // 짧은 대기 후 재시도
80
- const delay = Math.random() * 100;
81
- const start = Date.now();
82
- while (Date.now() - start < delay) { /* busy wait */ }
83
- }
84
- }
85
-
86
- return getEmptyStatus();
87
- }
88
-
89
- /**
90
- * 상태 파일 쓰기
91
- * 동시 접근 시 재시도 로직 포함
92
- */
93
- export function writeStatus(status, retries = 3) {
94
- const statusFile = getStatusFilePath();
95
- status.updatedAt = new Date().toISOString();
96
-
97
- for (let i = 0; i < retries; i++) {
98
- try {
99
- // 임시 파일에 먼저 쓰고 atomic rename
100
- const tempFile = `${statusFile}.tmp`;
101
- fs.writeFileSync(tempFile, JSON.stringify(status, null, 2));
102
- fs.renameSync(tempFile, statusFile);
103
- return true;
104
- } catch (err) {
105
- if (i === retries - 1) {
106
- console.error('⚠️ 상태 파일 쓰기 실패:', err.message);
107
- return false;
108
- }
109
- // 짧은 대기 후 재시도
110
- const delay = Math.random() * 100;
111
- const start = Date.now();
112
- while (Date.now() - start < delay) { /* busy wait */ }
113
- }
114
- }
115
-
116
- return false;
117
- }
118
-
119
- /**
120
- * 세션 등록
121
- */
122
- export function registerSession(sessionId, role, tool) {
123
- const status = readStatus();
124
-
125
- // 기존 세션 중복 확인
126
- const existing = status.activeSessions.find(s => s.sessionId === sessionId);
127
- if (existing) {
128
- return status;
129
- }
130
-
131
- status.activeSessions.push({
132
- sessionId,
133
- role,
134
- tool,
135
- startedAt: new Date().toISOString(),
136
- status: 'active'
137
- });
138
-
139
- writeStatus(status);
140
- return status;
141
- }
142
-
143
- /**
144
- * 세션 제거
145
- */
146
- export function unregisterSession(sessionId) {
147
- const status = readStatus();
148
-
149
- status.activeSessions = status.activeSessions.filter(s => s.sessionId !== sessionId);
150
-
151
- // 해당 세션이 보유한 잠금 해제
152
- Object.keys(status.locks).forEach(file => {
153
- if (status.locks[file].holder === sessionId) {
154
- delete status.locks[file];
155
- }
156
- });
157
-
158
- writeStatus(status);
159
- return status;
3
+ import { getWorkspaceDir, getSessionsDir, getTimestamp } from './files.js';
4
+
5
+ /**
6
+ * .ada-status.json 파일 경로 반환
7
+ */
8
+ export function getStatusFilePath() {
9
+ return path.join(getWorkspaceDir(), '.ada-status.json');
10
+ }
11
+
12
+ /**
13
+ * 상태 파일 템플릿
14
+ */
15
+ function getEmptyStatus() {
16
+ return {
17
+ version: '1.0',
18
+ updatedAt: new Date().toISOString(),
19
+ currentPhase: 'planning',
20
+ activeSessions: [],
21
+ pendingQuestions: [],
22
+ taskProgress: {},
23
+ notifications: [],
24
+ locks: {}
25
+ };
26
+ }
27
+
28
+ /**
29
+ * 상태 파일 읽기 (없으면 초기화)
30
+ * 동시 접근 시 재시도 로직 포함
31
+ */
32
+ export function readStatus(retries = 3) {
33
+ const statusFile = getStatusFilePath();
34
+
35
+ for (let i = 0; i < retries; i++) {
36
+ try {
37
+ if (!fs.existsSync(statusFile)) {
38
+ const initialStatus = getEmptyStatus();
39
+ fs.writeFileSync(statusFile, JSON.stringify(initialStatus, null, 2));
40
+ return initialStatus;
41
+ }
42
+
43
+ let content = fs.readFileSync(statusFile, 'utf-8');
44
+ try {
45
+ const status = JSON.parse(content);
46
+ // 스키마 검증 및 누락된 필드 추가
47
+ const emptyStatus = getEmptyStatus();
48
+ Object.keys(emptyStatus).forEach(key => {
49
+ if (status[key] === undefined) {
50
+ status[key] = emptyStatus[key];
51
+ }
52
+ });
53
+ return status;
54
+ } catch (parseError) {
55
+ if (parseError instanceof SyntaxError && parseError.message.includes('JSON')) {
56
+ // 파일 내용에서 백슬래시 이스케이프 처리
57
+ content = content.replace(/\\/g, '\\\\');
58
+ const status = JSON.parse(content);
59
+
60
+ // 스키마 검증 및 누락된 필드 추가
61
+ const emptyStatus = getEmptyStatus();
62
+ Object.keys(emptyStatus).forEach(key => {
63
+ if (status[key] === undefined) {
64
+ status[key] = emptyStatus[key];
65
+ }
66
+ });
67
+
68
+ // 복구된 파일 저장
69
+ fs.writeFileSync(statusFile, JSON.stringify(status, null, 2));
70
+ return status;
71
+ }
72
+ throw parseError;
73
+ }
74
+ } catch (err) {
75
+ if (i === retries - 1) {
76
+ console.error('⚠️ 상태 파일 읽기 실패:', err.message);
77
+ return getEmptyStatus();
78
+ }
79
+ // 짧은 대기 후 재시도
80
+ const delay = Math.random() * 100;
81
+ const start = Date.now();
82
+ while (Date.now() - start < delay) { /* busy wait */ }
83
+ }
84
+ }
85
+
86
+ return getEmptyStatus();
87
+ }
88
+
89
+ /**
90
+ * 상태 파일 쓰기
91
+ * 동시 접근 시 재시도 로직 포함
92
+ */
93
+ export function writeStatus(status, retries = 3) {
94
+ const statusFile = getStatusFilePath();
95
+ status.updatedAt = new Date().toISOString();
96
+
97
+ for (let i = 0; i < retries; i++) {
98
+ try {
99
+ // 임시 파일에 먼저 쓰고 atomic rename
100
+ const tempFile = `${statusFile}.tmp`;
101
+ fs.writeFileSync(tempFile, JSON.stringify(status, null, 2));
102
+ fs.renameSync(tempFile, statusFile);
103
+ return true;
104
+ } catch (err) {
105
+ if (i === retries - 1) {
106
+ console.error('⚠️ 상태 파일 쓰기 실패:', err.message);
107
+ return false;
108
+ }
109
+ // 짧은 대기 후 재시도
110
+ const delay = Math.random() * 100;
111
+ const start = Date.now();
112
+ while (Date.now() - start < delay) { /* busy wait */ }
113
+ }
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * 세션 등록
121
+ */
122
+ export function registerSession(sessionId, role, tool) {
123
+ const status = readStatus();
124
+
125
+ // 기존 세션 중복 확인
126
+ const existing = status.activeSessions.find(s => s.sessionId === sessionId);
127
+ if (existing) {
128
+ return status;
129
+ }
130
+
131
+ status.activeSessions.push({
132
+ sessionId,
133
+ role,
134
+ tool,
135
+ startedAt: new Date().toISOString(),
136
+ status: 'active'
137
+ });
138
+
139
+ writeStatus(status);
140
+ return status;
141
+ }
142
+
143
+ /**
144
+ * 세션 제거
145
+ */
146
+ export function unregisterSession(sessionId) {
147
+ const status = readStatus();
148
+
149
+ status.activeSessions = status.activeSessions.filter(s => s.sessionId !== sessionId);
150
+
151
+ // 해당 세션이 보유한 잠금 해제
152
+ Object.keys(status.locks).forEach(file => {
153
+ if (status.locks[file].holder === sessionId) {
154
+ delete status.locks[file];
155
+ }
156
+ });
157
+
158
+ writeStatus(status);
159
+ return status;
160
+ }
161
+
162
+ /**
163
+ * 세션 상태 업데이트
164
+ */
165
+ export function updateSessionStatus(sessionId, newStatus) {
166
+ return updateSessionDetails(sessionId, { status: newStatus });
160
167
  }
161
168
 
162
169
  /**
163
- * 세션 상태 업데이트
170
+ * 세션 메타데이터 업데이트
164
171
  */
165
- export function updateSessionStatus(sessionId, newStatus) {
172
+ export function updateSessionDetails(sessionId, updates = {}) {
166
173
  const status = readStatus();
167
174
 
168
175
  const session = status.activeSessions.find(s => s.sessionId === sessionId);
169
176
  if (session) {
170
- session.status = newStatus;
177
+ Object.assign(session, updates);
171
178
  session.lastUpdate = new Date().toISOString();
172
179
  writeStatus(status);
173
180
  }
174
181
 
175
182
  return status;
176
183
  }
177
-
184
+
185
+ /**
186
+ * 질문 추가
187
+ */
188
+ export function addQuestion(from, to, question, options = [], priority = 'normal') {
189
+ const status = readStatus();
190
+
191
+ const questionId = `Q${from.substring(0, 1).toUpperCase()}${String(status.pendingQuestions.length + 1).padStart(3, '0')}`;
192
+
193
+ const newQuestion = {
194
+ id: questionId,
195
+ from,
196
+ to,
197
+ question,
198
+ options,
199
+ priority,
200
+ status: 'waiting',
201
+ createdAt: new Date().toISOString()
202
+ };
203
+
204
+ status.pendingQuestions.push(newQuestion);
205
+
206
+ // 알림 추가 (to 대상 지정)
207
+ addNotificationInternal(status, 'question', from, `새 질문 [${questionId}]: ${question}`, to);
208
+
209
+ writeStatus(status);
210
+ return questionId;
211
+ }
212
+
213
+ /**
214
+ * 질문 응답
215
+ */
216
+ export function answerQuestion(questionId, answer) {
217
+ const status = readStatus();
218
+
219
+ const question = status.pendingQuestions.find(q => q.id === questionId);
220
+ if (question) {
221
+ question.status = 'answered';
222
+ question.answer = answer;
223
+ question.answeredAt = new Date().toISOString();
224
+
225
+ // 질문 관련 알림을 읽음으로 표시
226
+ status.notifications.forEach(n => {
227
+ if (n.message.includes(questionId) && !n.read) {
228
+ n.read = true;
229
+ n.readAt = new Date().toISOString();
230
+ }
231
+ });
232
+
233
+ // 응답 알림 추가 (질문자에게)
234
+ addNotificationInternal(status, 'info', 'manager', `질문 ${questionId} 응답됨: ${answer}`, question.from);
235
+
236
+ writeStatus(status);
237
+ }
238
+
239
+ return status;
240
+ }
241
+
242
+ /**
243
+ * Task 진행률 업데이트
244
+ */
245
+ export function updateTaskProgress(taskId, updates) {
246
+ const status = readStatus();
247
+
248
+ if (!status.taskProgress[taskId]) {
249
+ status.taskProgress[taskId] = {};
250
+ }
251
+
252
+ Object.assign(status.taskProgress[taskId], updates, {
253
+ lastUpdate: new Date().toISOString()
254
+ });
255
+
256
+ writeStatus(status);
257
+ return status;
258
+ }
259
+
260
+ /**
261
+ * 알림 추가 (내부용)
262
+ */
263
+ function addNotificationInternal(status, type, from, message, to = 'all') {
264
+ const notificationId = `N${String(status.notifications.length + 1).padStart(3, '0')}`;
265
+
266
+ status.notifications.push({
267
+ id: notificationId,
268
+ type,
269
+ from,
270
+ to,
271
+ message,
272
+ read: false,
273
+ createdAt: new Date().toISOString()
274
+ });
275
+
276
+ // 알림은 최근 50개만 유지
277
+ if (status.notifications.length > 50) {
278
+ status.notifications = status.notifications.slice(-50);
279
+ }
280
+ }
281
+
282
+ /**
283
+ * 알림 추가 (외부용)
284
+ */
285
+ export function addNotification(type, from, message, to = 'all') {
286
+ const status = readStatus();
287
+ addNotificationInternal(status, type, from, message, to);
288
+ writeStatus(status);
289
+ return status;
290
+ }
291
+
292
+ /**
293
+ * 알림을 읽음으로 표시
294
+ */
295
+ export function markNotificationAsRead(notificationId) {
296
+ const status = readStatus();
297
+
298
+ const notification = status.notifications.find(n => n.id === notificationId);
299
+ if (notification && !notification.read) {
300
+ notification.read = true;
301
+ notification.readAt = new Date().toISOString();
302
+ writeStatus(status);
303
+ return true;
304
+ }
305
+
306
+ return false;
307
+ }
308
+
309
+ /**
310
+ * 여러 알림을 읽음으로 표시
311
+ */
312
+ export function markNotificationsAsRead(notificationIds) {
313
+ const status = readStatus();
314
+ let markedCount = 0;
315
+
316
+ notificationIds.forEach(id => {
317
+ const notification = status.notifications.find(n => n.id === id);
318
+ if (notification && !notification.read) {
319
+ notification.read = true;
320
+ notification.readAt = new Date().toISOString();
321
+ markedCount++;
322
+ }
323
+ });
324
+
325
+ if (markedCount > 0) {
326
+ writeStatus(status);
327
+ }
328
+
329
+ return markedCount;
330
+ }
331
+
332
+ /**
333
+ * 특정 조건에 맞는 알림을 읽음으로 표시
334
+ */
335
+ export function markNotificationsByFilter(filter) {
336
+ const status = readStatus();
337
+ let markedCount = 0;
338
+
339
+ status.notifications.forEach(n => {
340
+ if (!n.read && filter(n)) {
341
+ n.read = true;
342
+ n.readAt = new Date().toISOString();
343
+ markedCount++;
344
+ }
345
+ });
346
+
347
+ if (markedCount > 0) {
348
+ writeStatus(status);
349
+ }
350
+
351
+ return markedCount;
352
+ }
353
+
354
+ /**
355
+ * 파일 잠금 획득
356
+ */
357
+ export function acquireLock(sessionId, filePath, timeoutMs = 30000) {
358
+ const status = readStatus();
359
+
360
+ // 기존 잠금 확인
361
+ if (status.locks[filePath]) {
362
+ const lock = status.locks[filePath];
363
+ const lockAge = Date.now() - new Date(lock.acquiredAt).getTime();
364
+
365
+ // 타임아웃 초과 시 강제 해제
366
+ if (lockAge > timeoutMs) {
367
+ addNotificationInternal(status, 'warning', 'system',
368
+ `파일 잠금 타임아웃: ${filePath} (${lock.holder})`);
369
+ delete status.locks[filePath];
370
+ } else {
371
+ // 다른 세션이 잠금 보유 중
372
+ return false;
373
+ }
374
+ }
375
+
376
+ // 잠금 획득
377
+ status.locks[filePath] = {
378
+ holder: sessionId,
379
+ acquiredAt: new Date().toISOString()
380
+ };
381
+
382
+ writeStatus(status);
383
+ return true;
384
+ }
385
+
386
+ /**
387
+ * 파일 잠금 해제
388
+ */
389
+ export function releaseLock(sessionId, filePath) {
390
+ const status = readStatus();
391
+
392
+ if (status.locks[filePath] && status.locks[filePath].holder === sessionId) {
393
+ delete status.locks[filePath];
394
+ writeStatus(status);
395
+ return true;
396
+ }
397
+
398
+ return false;
399
+ }
400
+
401
+ /**
402
+ * 읽지 않은 알림 개수
403
+ */
404
+ export function getUnreadNotificationCount() {
405
+ const status = readStatus();
406
+ return status.notifications.filter(n => !n.read).length;
407
+ }
408
+
409
+ /**
410
+ * 활성 세션 목록
411
+ */
412
+ export function getActiveSessions() {
413
+ const status = readStatus();
414
+ return status.activeSessions;
415
+ }
416
+
417
+ /**
418
+ * 대기 중인 질문 목록
419
+ */
420
+ export function getPendingQuestions() {
421
+ const status = readStatus();
422
+ return status.pendingQuestions.filter(q => q.status === 'waiting');
423
+ }
424
+
178
425
  /**
179
- * 질문 추가
426
+ * 좀비 세션 정리 (프로세스 종료/오래된 세션 제거)
180
427
  */
181
- export function addQuestion(from, to, question, options = [], priority = 'normal') {
428
+ export function cleanupZombieSessions(maxAgeMinutes = 60) {
182
429
  const status = readStatus();
430
+ const now = Date.now();
183
431
 
184
- const questionId = `Q${from.substring(0, 1).toUpperCase()}${String(status.pendingQuestions.length + 1).padStart(3, '0')}`;
185
-
186
- const newQuestion = {
187
- id: questionId,
188
- from,
189
- to,
190
- question,
191
- options,
192
- priority,
193
- status: 'waiting',
194
- createdAt: new Date().toISOString()
195
- };
196
-
197
- status.pendingQuestions.push(newQuestion);
432
+ const removedSessions = [];
198
433
 
199
- // 알림 추가
200
- addNotificationInternal(status, 'question', from, `새 질문 [${questionId}]: ${question}`);
434
+ status.activeSessions = status.activeSessions.filter(session => {
435
+ const pidStatus = isProcessAlive(session.pid);
436
+ if (pidStatus === false) {
437
+ removedSessions.push({ sessionId: session.sessionId, reason: 'process' });
438
+ return false;
439
+ }
440
+ if (pidStatus === true) {
441
+ return true;
442
+ }
201
443
 
202
- writeStatus(status);
203
- return questionId;
204
- }
444
+ const startedAtMs = new Date(session.startedAt).getTime();
445
+ if (Number.isNaN(startedAtMs)) {
446
+ removedSessions.push({ sessionId: session.sessionId, reason: 'time' });
447
+ return false;
448
+ }
205
449
 
206
- /**
207
- * 질문 응답
208
- */
209
- export function answerQuestion(questionId, answer) {
210
- const status = readStatus();
450
+ const age = now - startedAtMs;
451
+ if (age >= maxAgeMinutes * 60 * 1000) {
452
+ removedSessions.push({ sessionId: session.sessionId, reason: 'time' });
453
+ return false;
454
+ }
211
455
 
212
- const question = status.pendingQuestions.find(q => q.id === questionId);
213
- if (question) {
214
- question.status = 'answered';
215
- question.answer = answer;
216
- question.answeredAt = new Date().toISOString();
456
+ return true;
457
+ });
217
458
 
218
- // 알림 추가
219
- addNotificationInternal(status, 'info', 'manager', `질문 ${questionId} 응답됨: ${answer}`);
459
+ if (removedSessions.length > 0) {
460
+ removedSessions.forEach(({ sessionId, reason }) => {
461
+ if (reason === 'process') {
462
+ markSessionFileAsError(sessionId, '프로세스 종료 감지로 정리됨');
463
+ } else {
464
+ markSessionFileAsError(sessionId, '오래된 세션 정리됨');
465
+ }
466
+ });
220
467
 
468
+ addNotificationInternal(status, 'info', 'system', `좀비 세션 ${removedSessions.length}개 정리됨`);
221
469
  writeStatus(status);
222
470
  }
223
471
 
224
- return status;
472
+ return removedSessions.length;
225
473
  }
226
474
 
227
- /**
228
- * Task 진행률 업데이트
229
- */
230
- export function updateTaskProgress(taskId, updates) {
231
- const status = readStatus();
232
-
233
- if (!status.taskProgress[taskId]) {
234
- status.taskProgress[taskId] = {};
475
+ function isProcessAlive(pid) {
476
+ const parsedPid = typeof pid === 'string' ? Number.parseInt(pid, 10) : pid;
477
+ if (!Number.isFinite(parsedPid) || parsedPid <= 0) {
478
+ return null;
235
479
  }
236
480
 
237
- Object.assign(status.taskProgress[taskId], updates, {
238
- lastUpdate: new Date().toISOString()
239
- });
240
-
241
- writeStatus(status);
242
- return status;
243
- }
244
-
245
- /**
246
- * 알림 추가 (내부용)
247
- */
248
- function addNotificationInternal(status, type, from, message) {
249
- const notificationId = `N${String(status.notifications.length + 1).padStart(3, '0')}`;
250
-
251
- status.notifications.push({
252
- id: notificationId,
253
- type,
254
- from,
255
- message,
256
- read: false,
257
- createdAt: new Date().toISOString()
258
- });
259
-
260
- // 알림은 최근 50개만 유지
261
- if (status.notifications.length > 50) {
262
- status.notifications = status.notifications.slice(-50);
263
- }
264
- }
265
-
266
- /**
267
- * 알림 추가 (외부용)
268
- */
269
- export function addNotification(type, from, message) {
270
- const status = readStatus();
271
- addNotificationInternal(status, type, from, message);
272
- writeStatus(status);
273
- return status;
274
- }
275
-
276
- /**
277
- * 파일 잠금 획득
278
- */
279
- export function acquireLock(sessionId, filePath, timeoutMs = 30000) {
280
- const status = readStatus();
281
-
282
- // 기존 잠금 확인
283
- if (status.locks[filePath]) {
284
- const lock = status.locks[filePath];
285
- const lockAge = Date.now() - new Date(lock.acquiredAt).getTime();
286
-
287
- // 타임아웃 초과 시 강제 해제
288
- if (lockAge > timeoutMs) {
289
- addNotificationInternal(status, 'warning', 'system',
290
- `파일 잠금 타임아웃: ${filePath} (${lock.holder})`);
291
- delete status.locks[filePath];
292
- } else {
293
- // 다른 세션이 잠금 보유 중
294
- return false;
481
+ try {
482
+ process.kill(parsedPid, 0);
483
+ return true;
484
+ } catch (error) {
485
+ if (error.code === 'EPERM') {
486
+ return true;
295
487
  }
488
+ return false;
296
489
  }
297
-
298
- // 잠금 획득
299
- status.locks[filePath] = {
300
- holder: sessionId,
301
- acquiredAt: new Date().toISOString()
302
- };
303
-
304
- writeStatus(status);
305
- return true;
306
490
  }
307
491
 
308
- /**
309
- * 파일 잠금 해제
310
- */
311
- export function releaseLock(sessionId, filePath) {
312
- const status = readStatus();
313
-
314
- if (status.locks[filePath] && status.locks[filePath].holder === sessionId) {
315
- delete status.locks[filePath];
316
- writeStatus(status);
317
- return true;
492
+ function markSessionFileAsError(sessionId, reason) {
493
+ const sessionsDir = getSessionsDir();
494
+ const sessionFile = path.join(sessionsDir, sessionId, 'session.json');
495
+ if (!fs.existsSync(sessionFile)) {
496
+ return;
318
497
  }
319
498
 
320
- return false;
321
- }
322
-
323
- /**
324
- * 읽지 않은 알림 개수
325
- */
326
- export function getUnreadNotificationCount() {
327
- const status = readStatus();
328
- return status.notifications.filter(n => !n.read).length;
329
- }
330
-
331
- /**
332
- * 활성 세션 목록
333
- */
334
- export function getActiveSessions() {
335
- const status = readStatus();
336
- return status.activeSessions;
337
- }
338
-
339
- /**
340
- * 대기 중인 질문 목록
341
- */
342
- export function getPendingQuestions() {
343
- const status = readStatus();
344
- return status.pendingQuestions.filter(q => q.status === 'waiting');
345
- }
346
-
347
- /**
348
- * 좀비 세션 정리 (오래된 세션 제거)
349
- */
350
- export function cleanupZombieSessions(maxAgeMinutes = 60) {
351
- const status = readStatus();
352
- const now = Date.now();
353
-
354
- const originalCount = status.activeSessions.length;
355
-
356
- status.activeSessions = status.activeSessions.filter(session => {
357
- const age = now - new Date(session.startedAt).getTime();
358
- return age < maxAgeMinutes * 60 * 1000;
359
- });
360
-
361
- const removedCount = originalCount - status.activeSessions.length;
362
-
363
- if (removedCount > 0) {
364
- addNotificationInternal(status, 'info', 'system', `좀비 세션 ${removedCount}개 정리됨`);
365
- writeStatus(status);
499
+ try {
500
+ const session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
501
+ if (session.status && session.status !== 'active') {
502
+ return;
503
+ }
504
+ session.status = 'error';
505
+ session.error = reason;
506
+ if (!session.ended_at) {
507
+ session.ended_at = getTimestamp();
508
+ }
509
+ fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2));
510
+ } catch (error) {
511
+ // 세션 파일 오류는 무시
366
512
  }
367
-
368
- return removedCount;
369
513
  }