claude-controller 0.1.2 → 0.3.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 (71) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +1189 -0
  4. package/bin/native-app.py +6 -3
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +11 -5
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +634 -473
  38. package/web/handler_fs.py +153 -0
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +372 -0
  41. package/web/handler_memory.py +203 -0
  42. package/web/handler_sessions.py +132 -0
  43. package/web/jobs.py +585 -13
  44. package/web/personas.py +419 -0
  45. package/web/pipeline.py +981 -0
  46. package/web/presets.py +506 -0
  47. package/web/projects.py +246 -0
  48. package/web/static/api.js +141 -0
  49. package/web/static/app.js +25 -1937
  50. package/web/static/attachments.js +144 -0
  51. package/web/static/base.css +497 -0
  52. package/web/static/context.js +204 -0
  53. package/web/static/dirs.js +246 -0
  54. package/web/static/form.css +763 -0
  55. package/web/static/goals.css +363 -0
  56. package/web/static/goals.js +300 -0
  57. package/web/static/i18n.js +625 -0
  58. package/web/static/index.html +215 -13
  59. package/web/static/{styles.css → jobs.css} +746 -1141
  60. package/web/static/jobs.js +1270 -0
  61. package/web/static/memoryview.js +117 -0
  62. package/web/static/personas.js +228 -0
  63. package/web/static/pipeline.css +338 -0
  64. package/web/static/pipelines.js +487 -0
  65. package/web/static/presets.js +244 -0
  66. package/web/static/send.js +135 -0
  67. package/web/static/settings-style.css +291 -0
  68. package/web/static/settings.js +81 -0
  69. package/web/static/stream.js +534 -0
  70. package/web/static/utils.js +131 -0
  71. package/web/webhook.py +210 -0
package/web/jobs.py CHANGED
@@ -4,6 +4,7 @@ Controller Service — Job 관리 및 서비스 제어 함수
4
4
 
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import signal
8
9
  import subprocess
9
10
  import time
@@ -12,12 +13,126 @@ from config import LOGS_DIR, FIFO_PATH, SERVICE_SCRIPT, CONTROLLER_DIR
12
13
  from utils import parse_meta_file, is_service_running
13
14
 
14
15
 
15
- def get_all_jobs():
16
- """logs/ 디렉토리의 모든 .meta 파일을 파싱하여 작업 목록을 반환한다."""
16
+ # ════════════════════════════════════════════════
17
+ # 에러 분류 원시 에러 텍스트를 사용자 친화적 메시지로 변환
18
+ # ════════════════════════════════════════════════
19
+
20
+ _ERROR_PATTERNS = [
21
+ {
22
+ "patterns": [r"rate.?limit", r"429", r"overloaded", r"too many requests", r"capacity"],
23
+ "summary": "API 요청 한도 초과",
24
+ "cause": "Claude API의 요청 제한에 도달했습니다. 단시간에 너무 많은 작업을 전송했을 수 있습니다.",
25
+ "next_steps": ["잠시 후 다시 시도하세요 (1~2분 대기 권장)", "동시 실행 작업 수를 줄여보세요"],
26
+ },
27
+ {
28
+ "patterns": [r"api.?key", r"unauthorized", r"401", r"authentication.*fail", r"invalid.*key", r"ANTHROPIC_API_KEY"],
29
+ "summary": "API 인증 실패",
30
+ "cause": "Claude API 키가 유효하지 않거나 설정되지 않았습니다.",
31
+ "next_steps": ["ANTHROPIC_API_KEY 환경변수가 올바르게 설정되었는지 확인하세요", "API 키가 만료되지 않았는지 확인하세요"],
32
+ },
33
+ {
34
+ "patterns": [r"permission.?denied", r"EACCES", r"Operation not permitted"],
35
+ "summary": "파일 접근 권한 오류",
36
+ "cause": "작업 대상 파일이나 디렉토리에 대한 읽기/쓰기 권한이 없습니다.",
37
+ "next_steps": ["작업 디렉토리의 파일 권한을 확인하세요", "다른 프로세스가 파일을 잠그고 있지 않은지 확인하세요"],
38
+ },
39
+ {
40
+ "patterns": [r"FIFO", r"Broken pipe", r"EPIPE", r"fifo.*not.*exist"],
41
+ "summary": "서비스 통신 오류",
42
+ "cause": "Controller 서비스와의 통신 파이프(FIFO)가 끊어졌습니다.",
43
+ "next_steps": ["서비스를 재시작하세요", "서비스 상태를 확인하세요 (상단 연결 상태 참조)"],
44
+ },
45
+ {
46
+ "patterns": [r"timed?\s*out", r"ETIMEDOUT", r"deadline.?exceeded", r"timeout"],
47
+ "summary": "작업 시간 초과",
48
+ "cause": "작업이 제한 시간 내에 완료되지 않았습니다. 프롬프트가 너무 복잡하거나 대상 파일이 너무 클 수 있습니다.",
49
+ "next_steps": ["프롬프트를 더 작은 단위로 나눠서 시도하세요", "대상 범위를 줄여보세요 (특정 파일/함수 지정)"],
50
+ },
51
+ {
52
+ "patterns": [r"ECONNREFUSED", r"ENOTFOUND", r"network", r"fetch.*fail", r"connection.*refused"],
53
+ "summary": "네트워크 연결 오류",
54
+ "cause": "외부 서비스에 연결할 수 없습니다. 네트워크가 불안정하거나 API 서버에 문제가 있을 수 있습니다.",
55
+ "next_steps": ["인터넷 연결 상태를 확인하세요", "잠시 후 다시 시도하세요"],
56
+ },
57
+ {
58
+ "patterns": [r"context.*(?:length|limit|window)", r"too.?long", r"max.*token", r"token.*limit", r"prompt.*too.*large"],
59
+ "summary": "컨텍스트 길이 초과",
60
+ "cause": "입력 프롬프트나 작업 대상 파일이 Claude의 처리 가능 범위를 초과했습니다.",
61
+ "next_steps": ["프롬프트를 더 짧게 줄여보세요", "대상 파일 범위를 줄이세요 (특정 함수나 섹션만 지정)"],
62
+ },
63
+ {
64
+ "patterns": [r"ENOSPC", r"no space", r"disk.*full"],
65
+ "summary": "디스크 공간 부족",
66
+ "cause": "서버 디스크에 여유 공간이 없어서 작업 결과를 저장할 수 없습니다.",
67
+ "next_steps": ["불필요한 파일을 정리하세요", "'완료 삭제' 버튼으로 오래된 작업 로그를 제거하세요"],
68
+ },
69
+ {
70
+ "patterns": [r"SIGKILL", r"killed", r"signal.*9", r"OOM", r"out of memory", r"ENOMEM"],
71
+ "summary": "프로세스가 강제 종료됨",
72
+ "cause": "작업 프로세스가 시스템에 의해 강제 종료되었습니다. 메모리 부족이 원인일 수 있습니다.",
73
+ "next_steps": ["시스템 메모리 사용량을 확인하세요", "동시 실행 작업 수를 줄여보세요"],
74
+ },
75
+ {
76
+ "patterns": [r"ENOENT", r"no such file", r"not found.*path", r"directory.*not.*exist"],
77
+ "summary": "파일 또는 디렉토리를 찾을 수 없음",
78
+ "cause": "작업에서 참조한 파일이나 디렉토리가 존재하지 않습니다.",
79
+ "next_steps": ["작업 디렉토리(cwd) 경로가 올바른지 확인하세요", "대상 파일이 삭제되거나 이동되지 않았는지 확인하세요"],
80
+ },
81
+ {
82
+ "patterns": [r"git.*conflict", r"merge conflict", r"CONFLICT"],
83
+ "summary": "Git 충돌 발생",
84
+ "cause": "작업 중 Git merge conflict가 발생했습니다.",
85
+ "next_steps": ["충돌이 발생한 파일을 수동으로 해결하세요", "작업 전에 최신 코드를 pull하세요"],
86
+ },
87
+ {
88
+ "patterns": [r"worktree.*(?:fail|error|lock)", r"already.*checked.*out"],
89
+ "summary": "Git Worktree 오류",
90
+ "cause": "격리 실행을 위한 Git worktree 생성에 실패했습니다.",
91
+ "next_steps": ["기존 worktree가 정리되지 않았다면 'git worktree prune'을 실행하세요", "작업 디렉토리가 유효한 Git 저장소인지 확인하세요"],
92
+ },
93
+ ]
94
+
95
+
96
+ def classify_error(raw_text):
97
+ """원시 에러 텍스트를 분류하여 사용자 친화적 메시지를 반환한다.
98
+
99
+ Returns:
100
+ dict: {"summary": str, "cause": str, "next_steps": list[str]}
101
+ None이면 분류 불가 (에러가 아닌 경우).
102
+ """
103
+ if not raw_text:
104
+ return None
105
+
106
+ for rule in _ERROR_PATTERNS:
107
+ for pattern in rule["patterns"]:
108
+ if re.search(pattern, raw_text, re.IGNORECASE):
109
+ return {
110
+ "summary": rule["summary"],
111
+ "cause": rule["cause"],
112
+ "next_steps": rule["next_steps"],
113
+ }
114
+
115
+ return {
116
+ "summary": "작업이 실패했습니다",
117
+ "cause": "예상하지 못한 오류가 발생했습니다.",
118
+ "next_steps": ["아래 상세 로그를 확인하세요", "같은 프롬프트로 다시 실행해보세요"],
119
+ }
120
+
121
+
122
+ def get_all_jobs(cwd_filter=None):
123
+ """logs/ 디렉토리의 모든 .meta 파일을 파싱하여 작업 목록을 반환한다.
124
+
125
+ Args:
126
+ cwd_filter: 지정하면 해당 경로(또는 하위)에서 실행된 작업만 반환한다.
127
+ """
17
128
  jobs = []
18
129
  if not LOGS_DIR.exists():
19
130
  return jobs
20
131
 
132
+ # cwd_filter 정규화
133
+ if cwd_filter:
134
+ cwd_filter = os.path.normpath(os.path.expanduser(cwd_filter))
135
+
21
136
  meta_files = sorted(LOGS_DIR.glob("job_*.meta"),
22
137
  key=lambda f: int(f.stem.split("_")[1]),
23
138
  reverse=True)
@@ -26,6 +141,16 @@ def get_all_jobs():
26
141
  if not meta:
27
142
  continue
28
143
 
144
+ # cwd 필터 적용
145
+ if cwd_filter:
146
+ job_cwd = meta.get("CWD", "")
147
+ if job_cwd:
148
+ job_cwd_norm = os.path.normpath(job_cwd)
149
+ if not (job_cwd_norm == cwd_filter or job_cwd_norm.startswith(cwd_filter + os.sep)):
150
+ continue
151
+ else:
152
+ continue
153
+
29
154
  if meta.get("STATUS") == "running" and meta.get("PID"):
30
155
  try:
31
156
  os.kill(int(meta["PID"]), 0)
@@ -83,7 +208,11 @@ def get_all_jobs():
83
208
  except OSError:
84
209
  pass
85
210
 
86
- jobs.append({
211
+ # 의존성 정보 추출
212
+ deps_str = meta.get("DEPENDS_ON", "")
213
+ depends_on = [d.strip() for d in deps_str.split(",") if d.strip()] if deps_str else None
214
+
215
+ job_entry = {
87
216
  "job_id": job_id_str,
88
217
  "status": meta.get("STATUS", "unknown"),
89
218
  "session_id": meta.get("SESSION_ID", "") or None,
@@ -94,10 +223,173 @@ def get_all_jobs():
94
223
  "result": result_text,
95
224
  "cost_usd": cost_usd,
96
225
  "duration_ms": duration_ms,
97
- })
226
+ "depends_on": depends_on,
227
+ }
228
+ if meta.get("STATUS") == "failed" and result_text:
229
+ job_entry["user_error"] = classify_error(result_text)
230
+ jobs.append(job_entry)
98
231
  return jobs
99
232
 
100
233
 
234
+ def get_stats(from_ts=None, to_ts=None):
235
+ """기간 내 작업 통계를 집계하여 반환한다.
236
+
237
+ Args:
238
+ from_ts: 시작 시각 (Unix timestamp). None이면 제한 없음.
239
+ to_ts: 종료 시각 (Unix timestamp). None이면 현재 시각.
240
+ """
241
+ if to_ts is None:
242
+ to_ts = time.time()
243
+
244
+ total = 0
245
+ running = 0
246
+ done = 0
247
+ failed = 0
248
+ total_cost = 0.0
249
+ total_duration = 0.0
250
+ cost_count = 0
251
+ duration_count = 0
252
+ by_cwd = {}
253
+
254
+ if not LOGS_DIR.exists():
255
+ return _build_stats_response(
256
+ total, running, done, failed,
257
+ total_cost, cost_count, total_duration, duration_count, by_cwd,
258
+ from_ts, to_ts,
259
+ )
260
+
261
+ for mf in LOGS_DIR.glob("job_*.meta"):
262
+ meta = parse_meta_file(mf)
263
+ if not meta:
264
+ continue
265
+
266
+ # 기간 필터: CREATED_AT 파싱
267
+ created_at = meta.get("CREATED_AT", "")
268
+ if created_at:
269
+ try:
270
+ ts = float(created_at) if created_at.replace(".", "").isdigit() else time.mktime(time.strptime(created_at, "%Y-%m-%d %H:%M:%S"))
271
+ except (ValueError, OverflowError):
272
+ ts = 0
273
+ else:
274
+ # CREATED_AT이 없으면 파일 mtime 사용
275
+ try:
276
+ ts = mf.stat().st_mtime
277
+ except OSError:
278
+ ts = 0
279
+
280
+ if from_ts and ts < from_ts:
281
+ continue
282
+ if ts > to_ts:
283
+ continue
284
+
285
+ total += 1
286
+ status = meta.get("STATUS", "unknown")
287
+
288
+ # 실행 중이지만 프로세스가 죽은 경우 보정
289
+ if status == "running" and meta.get("PID"):
290
+ try:
291
+ os.kill(int(meta["PID"]), 0)
292
+ except (ProcessLookupError, ValueError, OSError):
293
+ status = "done"
294
+
295
+ if status == "running":
296
+ running += 1
297
+ elif status == "done":
298
+ done += 1
299
+ elif status == "failed":
300
+ failed += 1
301
+
302
+ # cwd별 카운트
303
+ cwd = meta.get("CWD", "") or "unknown"
304
+ if cwd not in by_cwd:
305
+ by_cwd[cwd] = {"total": 0, "done": 0, "failed": 0}
306
+ by_cwd[cwd]["total"] += 1
307
+ if status == "done":
308
+ by_cwd[cwd]["done"] += 1
309
+ elif status == "failed":
310
+ by_cwd[cwd]["failed"] += 1
311
+
312
+ # 완료/실패 작업에서 비용·소요시간 추출
313
+ if status in ("done", "failed"):
314
+ job_id_str = meta.get("JOB_ID", "")
315
+ out_file = LOGS_DIR / f"job_{job_id_str}.out"
316
+ cost, dur = _extract_cost_duration(out_file)
317
+ if cost is not None:
318
+ total_cost += cost
319
+ cost_count += 1
320
+ if dur is not None:
321
+ total_duration += dur
322
+ duration_count += 1
323
+
324
+ return _build_stats_response(
325
+ total, running, done, failed,
326
+ total_cost, cost_count, total_duration, duration_count, by_cwd,
327
+ from_ts, to_ts,
328
+ )
329
+
330
+
331
+ def _extract_cost_duration(out_file):
332
+ """out 파일에서 cost_usd와 duration_ms를 추출한다."""
333
+ if not out_file.exists():
334
+ return None, None
335
+ cost = None
336
+ dur = None
337
+ try:
338
+ with open(out_file, "r") as f:
339
+ for line in f:
340
+ if '"type":"result"' not in line:
341
+ continue
342
+ try:
343
+ obj = json.loads(line)
344
+ if obj.get("type") == "result":
345
+ cost = obj.get("total_cost_usd")
346
+ dur = obj.get("duration_ms")
347
+ except json.JSONDecodeError:
348
+ continue
349
+ # fallback: 전체 파일이 단일 JSON인 경우
350
+ if cost is None:
351
+ try:
352
+ data = json.loads(out_file.read_text())
353
+ cost = data.get("total_cost_usd")
354
+ dur = data.get("duration_ms")
355
+ except (json.JSONDecodeError, OSError):
356
+ pass
357
+ except OSError:
358
+ pass
359
+ return cost, dur
360
+
361
+
362
+ def _build_stats_response(total, running, done, failed,
363
+ total_cost, cost_count, total_duration, duration_count,
364
+ by_cwd, from_ts, to_ts):
365
+ completed = done + failed
366
+ success_rate = round(done / completed, 4) if completed > 0 else None
367
+ avg_duration_ms = round(total_duration / duration_count, 1) if duration_count > 0 else None
368
+
369
+ return {
370
+ "period": {
371
+ "from": from_ts,
372
+ "to": to_ts,
373
+ },
374
+ "jobs": {
375
+ "total": total,
376
+ "running": running,
377
+ "done": done,
378
+ "failed": failed,
379
+ },
380
+ "success_rate": success_rate,
381
+ "cost": {
382
+ "total_usd": round(total_cost, 4) if cost_count > 0 else None,
383
+ "jobs_with_cost": cost_count,
384
+ },
385
+ "duration": {
386
+ "avg_ms": avg_duration_ms,
387
+ "jobs_with_duration": duration_count,
388
+ },
389
+ "by_cwd": by_cwd,
390
+ }
391
+
392
+
101
393
  def get_job_result(job_id):
102
394
  """작업 결과(.out 파일)에서 result 필드를 추출한다."""
103
395
  out_file = LOGS_DIR / f"job_{job_id}.out"
@@ -108,7 +400,15 @@ def get_job_result(job_id):
108
400
 
109
401
  meta = parse_meta_file(meta_file)
110
402
  if meta.get("STATUS") == "running":
111
- return {"status": "running", "result": None}, None
403
+ # 프로세스가 실제로 살아있는지 확인 — meta가 running이지만 PID가 죽었으면 done 처리
404
+ pid = meta.get("PID")
405
+ if pid:
406
+ try:
407
+ os.kill(int(pid), 0)
408
+ except (ProcessLookupError, ValueError, OSError):
409
+ meta["STATUS"] = "done"
410
+ if meta.get("STATUS") == "running":
411
+ return {"status": "running", "result": None}, None
112
412
 
113
413
  if not out_file.exists():
114
414
  return None, "출력 파일이 없습니다"
@@ -127,25 +427,31 @@ def get_job_result(job_id):
127
427
  continue
128
428
 
129
429
  if result_data:
130
- return {
430
+ resp = {
131
431
  "status": meta.get("STATUS", "unknown"),
132
432
  "result": result_data.get("result"),
133
433
  "cost_usd": result_data.get("total_cost_usd"),
134
434
  "duration_ms": result_data.get("duration_ms"),
135
435
  "session_id": result_data.get("session_id"),
136
436
  "is_error": result_data.get("is_error", False),
137
- }, None
437
+ }
438
+ if resp["is_error"] or meta.get("STATUS") == "failed":
439
+ resp["user_error"] = classify_error(result_data.get("result", ""))
440
+ return resp, None
138
441
 
139
442
  try:
140
443
  data = json.loads(content)
141
- return {
444
+ resp = {
142
445
  "status": meta.get("STATUS", "unknown"),
143
446
  "result": data.get("result"),
144
447
  "cost_usd": data.get("total_cost_usd"),
145
448
  "duration_ms": data.get("duration_ms"),
146
449
  "session_id": data.get("session_id"),
147
450
  "is_error": data.get("is_error", False),
148
- }, None
451
+ }
452
+ if resp["is_error"] or meta.get("STATUS") == "failed":
453
+ resp["user_error"] = classify_error(data.get("result", ""))
454
+ return resp, None
149
455
  except json.JSONDecodeError:
150
456
  pass
151
457
 
@@ -154,14 +460,27 @@ def get_job_result(job_id):
154
460
  return None, f"결과 파싱 실패: {e}"
155
461
 
156
462
 
157
- def send_to_fifo(prompt, cwd=None, job_id=None, images=None, session=None, reuse_worktree=None):
158
- """FIFO 파이프에 JSON 메시지를 전송한다."""
159
- if not FIFO_PATH.exists():
160
- return None, "FIFO 파이프가 존재하지 않습니다. 서비스가 실행 중인지 확인하세요."
463
+ def send_to_fifo(prompt, cwd=None, job_id=None, images=None, session=None, reuse_worktree=None, depends_on=None):
464
+ """FIFO 파이프에 JSON 메시지를 전송한다.
161
465
 
466
+ Args:
467
+ depends_on: 선행 작업 job_id 목록. 지정하면 모든 선행 작업이 완료될 때까지
468
+ pending 상태로 대기하다가 자동 디스패치된다.
469
+ """
162
470
  if not job_id:
163
471
  job_id = f"{int(time.time())}-web-{os.getpid()}-{id(prompt) % 10000}"
164
472
 
473
+ # 의존성이 있는 경우 → 선행 작업 완료 여부 확인
474
+ if depends_on:
475
+ deps = [str(d).strip() for d in depends_on if str(d).strip()]
476
+ if deps:
477
+ unmet = _check_dependencies(deps)
478
+ if unmet:
479
+ return _create_pending_job(prompt, cwd, job_id, images, session, deps)
480
+
481
+ if not FIFO_PATH.exists():
482
+ return None, "FIFO 파이프가 존재하지 않습니다. 서비스가 실행 중인지 확인하세요."
483
+
165
484
  payload = {"id": job_id, "prompt": prompt}
166
485
  if cwd:
167
486
  payload["cwd"] = cwd
@@ -181,6 +500,192 @@ def send_to_fifo(prompt, cwd=None, job_id=None, images=None, session=None, reuse
181
500
  return None, f"FIFO 전송 실패: {e}"
182
501
 
183
502
 
503
+ # ════════════════════════════════════════════════
504
+ # 작업 의존성 DAG — pending 작업 관리
505
+ # ════════════════════════════════════════════════
506
+
507
+ def _check_dependencies(depends_on):
508
+ """의존성 목록에서 아직 완료되지 않은 작업 ID를 반환한다."""
509
+ unmet = []
510
+ for dep_id in depends_on:
511
+ meta_file = LOGS_DIR / f"job_{dep_id}.meta"
512
+ if not meta_file.exists():
513
+ unmet.append(dep_id)
514
+ continue
515
+ meta = parse_meta_file(meta_file)
516
+ if meta.get("STATUS") != "done":
517
+ unmet.append(dep_id)
518
+ return unmet
519
+
520
+
521
+ def _next_job_id_py():
522
+ """Python에서 job counter를 원자적으로 증가시킨다 (쉘 호환 mkdir 스핀락)."""
523
+ counter_file = LOGS_DIR / ".job_counter"
524
+ lock_dir = LOGS_DIR / ".job_counter.lock"
525
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
526
+
527
+ waited = 0
528
+ while True:
529
+ try:
530
+ lock_dir.mkdir()
531
+ break
532
+ except FileExistsError:
533
+ time.sleep(0.01)
534
+ waited += 1
535
+ if waited > 500:
536
+ try:
537
+ lock_dir.rmdir()
538
+ except OSError:
539
+ pass
540
+ waited = 0
541
+
542
+ try:
543
+ current = 0
544
+ if counter_file.exists():
545
+ try:
546
+ current = int(counter_file.read_text().strip())
547
+ except (ValueError, OSError):
548
+ pass
549
+ next_id = current + 1
550
+ counter_file.write_text(str(next_id))
551
+ finally:
552
+ try:
553
+ lock_dir.rmdir()
554
+ except OSError:
555
+ pass
556
+
557
+ return next_id
558
+
559
+
560
+ def _sanitize_meta_value(value: str) -> str:
561
+ """meta 파일에 기록할 값에서 개행·제어문자를 제거하여 필드 인젝션을 방지한다."""
562
+ return re.sub(r'[\x00-\x1f\x7f]', '', value)
563
+
564
+
565
+ def _create_pending_job(prompt, cwd, uuid, images, session, depends_on):
566
+ """의존성이 충족되지 않은 작업을 pending 상태로 등록한다."""
567
+ new_id = _next_job_id_py()
568
+ meta_file = LOGS_DIR / f"job_{new_id}.meta"
569
+ pending_file = LOGS_DIR / f"job_{new_id}.pending"
570
+
571
+ safe_prompt = _sanitize_meta_value(prompt[:500].replace("'", "\\'"))
572
+ safe_uuid = _sanitize_meta_value(str(uuid or ''))
573
+ safe_cwd = _sanitize_meta_value(str(cwd or ''))
574
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
575
+ deps_str = _sanitize_meta_value(",".join(depends_on))
576
+
577
+ content = (
578
+ f"JOB_ID={new_id}\n"
579
+ f"STATUS=pending\n"
580
+ f"PID=\n"
581
+ f"PROMPT='{safe_prompt}'\n"
582
+ f"CREATED_AT='{ts}'\n"
583
+ f"SESSION_ID=\n"
584
+ f"UUID={safe_uuid}\n"
585
+ f"CWD='{safe_cwd}'\n"
586
+ f"DEPENDS_ON={deps_str}\n"
587
+ )
588
+
589
+ # FIFO에 보낼 페이로드를 .pending 파일에 저장
590
+ payload = {"id": uuid, "prompt": prompt, "pending_job_id": str(new_id)}
591
+ if cwd:
592
+ payload["cwd"] = cwd
593
+ if images:
594
+ payload["images"] = images
595
+ if session:
596
+ payload["session"] = session
597
+
598
+ # 원자적 쓰기
599
+ tmp = meta_file.with_suffix(f".tmp.{os.getpid()}")
600
+ tmp.write_text(content)
601
+ os.rename(str(tmp), str(meta_file))
602
+ pending_file.write_text(json.dumps(payload, ensure_ascii=False))
603
+
604
+ return {
605
+ "job_id": str(new_id),
606
+ "prompt": prompt,
607
+ "cwd": cwd,
608
+ "status": "pending",
609
+ "depends_on": depends_on,
610
+ }, None
611
+
612
+
613
+ def dispatch_pending_jobs():
614
+ """pending 상태의 작업 중 의존성이 충족된 것을 FIFO로 디스패치한다.
615
+
616
+ Returns:
617
+ list[str]: 디스패치된 job_id 목록
618
+ """
619
+ if not LOGS_DIR.exists() or not FIFO_PATH.exists():
620
+ return []
621
+
622
+ dispatched = []
623
+ for mf in sorted(LOGS_DIR.glob("job_*.meta")):
624
+ meta = parse_meta_file(mf)
625
+ if not meta or meta.get("STATUS") != "pending":
626
+ continue
627
+
628
+ deps_str = meta.get("DEPENDS_ON", "")
629
+ if not deps_str:
630
+ continue
631
+
632
+ depends_on = [d.strip() for d in deps_str.split(",") if d.strip()]
633
+
634
+ # 선행 작업 중 실패한 것이 있으면 이 작업도 실패 처리
635
+ any_failed = False
636
+ for dep_id in depends_on:
637
+ dep_meta_file = LOGS_DIR / f"job_{dep_id}.meta"
638
+ if dep_meta_file.exists():
639
+ dep_meta = parse_meta_file(dep_meta_file)
640
+ if dep_meta.get("STATUS") == "failed":
641
+ any_failed = True
642
+ break
643
+
644
+ job_id = meta.get("JOB_ID", "")
645
+ if any_failed:
646
+ _mark_pending_failed(job_id)
647
+ dispatched.append(job_id)
648
+ continue
649
+
650
+ unmet = _check_dependencies(depends_on)
651
+ if unmet:
652
+ continue
653
+
654
+ # 모든 의존성 충족 → FIFO로 전송
655
+ pending_file = LOGS_DIR / f"job_{job_id}.pending"
656
+ if not pending_file.exists():
657
+ continue
658
+
659
+ try:
660
+ payload = json.loads(pending_file.read_text())
661
+ except (json.JSONDecodeError, OSError):
662
+ continue
663
+
664
+ try:
665
+ fd = os.open(str(FIFO_PATH), os.O_WRONLY | os.O_NONBLOCK)
666
+ with os.fdopen(fd, "w") as f:
667
+ f.write(json.dumps(payload, ensure_ascii=False) + "\n")
668
+ pending_file.unlink(missing_ok=True)
669
+ dispatched.append(job_id)
670
+ except OSError:
671
+ continue
672
+
673
+ return dispatched
674
+
675
+
676
+ def _mark_pending_failed(job_id):
677
+ """선행 작업 실패로 인해 pending 작업을 failed로 전환한다."""
678
+ meta_file = LOGS_DIR / f"job_{job_id}.meta"
679
+ pending_file = LOGS_DIR / f"job_{job_id}.pending"
680
+ if meta_file.exists():
681
+ content = meta_file.read_text()
682
+ content = content.replace("STATUS=pending", "STATUS=failed")
683
+ tmp = meta_file.with_suffix(f".tmp.{os.getpid()}")
684
+ tmp.write_text(content)
685
+ os.rename(str(tmp), str(meta_file))
686
+ pending_file.unlink(missing_ok=True)
687
+
688
+
184
689
  def start_controller_service():
185
690
  """컨트롤러 서비스를 백그라운드로 시작한다."""
186
691
  running, pid = is_service_running()
@@ -226,3 +731,70 @@ def stop_controller_service():
226
731
  return True, None
227
732
  except OSError as e:
228
733
  return False, f"종료 실패: {e}"
734
+
735
+
736
+ def cleanup_old_jobs(retention_days=30):
737
+ """보존 기간이 지난 완료/실패 작업 파일을 삭제한다.
738
+
739
+ Args:
740
+ retention_days: 보존 기간 (일). 이 기간보다 오래된 done/failed 작업을 삭제.
741
+
742
+ Returns:
743
+ dict: 삭제 결과 (cleaned 수, skipped_running 수, freed_bytes 등)
744
+ """
745
+ if not LOGS_DIR.exists():
746
+ return {"cleaned": 0, "skipped_running": 0, "freed_bytes": 0}
747
+
748
+ cutoff_ts = time.time() - retention_days * 86400
749
+ cleaned = 0
750
+ skipped_running = 0
751
+ freed_bytes = 0
752
+
753
+ for mf in list(LOGS_DIR.glob("job_*.meta")):
754
+ meta = parse_meta_file(mf)
755
+ if not meta:
756
+ continue
757
+
758
+ status = meta.get("STATUS", "unknown")
759
+
760
+ # running 상태는 건드리지 않음
761
+ if status == "running":
762
+ pid = meta.get("PID")
763
+ if pid:
764
+ try:
765
+ os.kill(int(pid), 0)
766
+ skipped_running += 1
767
+ continue
768
+ except (ProcessLookupError, ValueError, OSError):
769
+ pass # 프로세스 죽음 → 정리 대상
770
+ else:
771
+ skipped_running += 1
772
+ continue
773
+
774
+ # 파일 수정 시각 기준으로 보존 기간 확인
775
+ try:
776
+ mtime = mf.stat().st_mtime
777
+ except OSError:
778
+ continue
779
+ if mtime >= cutoff_ts:
780
+ continue
781
+
782
+ # job ID 추출 → 관련 파일 일괄 삭제
783
+ job_id_str = mf.stem.replace("job_", "") # job_123.meta → 123
784
+ for suffix in (".meta", ".out", ".ext_id"):
785
+ target = LOGS_DIR / f"job_{job_id_str}{suffix}"
786
+ if target.exists():
787
+ try:
788
+ freed_bytes += target.stat().st_size
789
+ target.unlink()
790
+ except OSError:
791
+ pass
792
+ cleaned += 1
793
+
794
+ return {
795
+ "cleaned": cleaned,
796
+ "skipped_running": skipped_running,
797
+ "freed_bytes": freed_bytes,
798
+ "freed_mb": round(freed_bytes / 1048576, 2),
799
+ "retention_days": retention_days,
800
+ }