process-gpt-agent-sdk 0.3.16__py3-none-any.whl → 0.3.17__py3-none-any.whl

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.

Potentially problematic release.


This version of process-gpt-agent-sdk might be problematic. Click here for more details.

@@ -1,111 +1,155 @@
1
1
  import asyncio
2
2
  import logging
3
3
  import json
4
- from datetime import datetime
4
+ import os
5
+ import signal
6
+ import uuid
5
7
  from typing import Dict, Any, Optional
6
8
  from dataclasses import dataclass
7
- import uuid
8
- import os
9
+
9
10
  from dotenv import load_dotenv
10
11
 
11
12
  from a2a.server.agent_execution import AgentExecutor, RequestContext
12
13
  from a2a.server.events import EventQueue, Event
13
- from a2a.types import (
14
- TaskArtifactUpdateEvent,
15
- TaskState,
16
- TaskStatusUpdateEvent,
17
- )
14
+ from a2a.types import TaskArtifactUpdateEvent, TaskState, TaskStatusUpdateEvent
18
15
 
19
- # DB 어댑터 사용
20
16
  from .database import (
21
17
  initialize_db,
22
18
  polling_pending_todos,
23
- record_event,
19
+ record_events_bulk,
20
+ record_event, # 단건 이벤트 기록
24
21
  save_task_result,
25
22
  update_task_error,
26
23
  get_consumer_id,
27
- fetch_agent_data,
28
- fetch_all_agents,
29
- fetch_form_types,
30
- fetch_tenant_mcp_config,
31
- fetch_human_users_by_proc_inst_id,
24
+ fetch_context_bundle,
32
25
  )
26
+ from .utils import summarize_error_to_user
33
27
 
34
28
  load_dotenv()
35
-
36
29
  logging.basicConfig(level=logging.INFO)
37
30
  logger = logging.getLogger(__name__)
38
31
 
32
+ # ------------------------------ 커스텀 예외 ------------------------------
33
+ class ContextPreparationError(Exception):
34
+ """컨텍스트 준비 실패를 상위 경계에서 단일 처리하기 위한 래퍼 예외."""
35
+ def __init__(self, original: Exception, friendly: Optional[str] = None):
36
+ super().__init__(f"{type(original).__name__}: {str(original)}")
37
+ self.original = original
38
+ self.friendly = friendly
39
+
40
+ # ------------------------------ Event Coalescing (env tunable) ------------------------------
41
+ COALESCE_DELAY = float(os.getenv("EVENT_COALESCE_DELAY_SEC", "1.0")) # 최대 지연
42
+ COALESCE_BATCH = int(os.getenv("EVENT_COALESCE_BATCH", "3")) # 즉시 flush 임계치
43
+
44
+ _EVENT_BUF: list[Dict[str, Any]] = []
45
+ _EVENT_TIMER: Optional[asyncio.TimerHandle] = None
46
+ _EVENT_LOCK = asyncio.Lock()
47
+
48
+ async def _flush_events_now():
49
+ """버퍼된 이벤트를 bulk RPC로 즉시 저장"""
50
+ global _EVENT_BUF, _EVENT_TIMER
51
+ async with _EVENT_LOCK:
52
+ buf = _EVENT_BUF[:]
53
+ _EVENT_BUF.clear()
54
+ if _EVENT_TIMER and not _EVENT_TIMER.cancelled():
55
+ _EVENT_TIMER.cancel()
56
+ _EVENT_TIMER = None
57
+ if not buf:
58
+ return
59
+
60
+ logger.info("📤 이벤트 버퍼 플러시 시작 - %d개 이벤트", len(buf))
61
+ # 실제 성공/실패 로깅은 record_events_bulk 내부에서 수행
62
+ await record_events_bulk(buf)
63
+ # 여기서는 시도 사실만 남김(성공처럼 보이는 'flushed' 오해 방지)
64
+ logger.info("🔄 이벤트 버퍼 플러시 시도 완료 - %d개 이벤트", len(buf))
65
+
66
+ def _schedule_delayed_flush():
67
+ global _EVENT_TIMER
68
+ if _EVENT_TIMER is None:
69
+ loop = asyncio.get_running_loop()
70
+ _EVENT_TIMER = loop.call_later(COALESCE_DELAY, lambda: asyncio.create_task(_flush_events_now()))
71
+
72
+ async def enqueue_ui_event_coalesced(payload: Dict[str, Any]):
73
+ """1초 코얼레싱 / COALESCE_BATCH개 모이면 즉시 플러시 (환경변수로 조절 가능)"""
74
+ global _EVENT_BUF
75
+ to_flush_now = False
76
+ async with _EVENT_LOCK:
77
+ _EVENT_BUF.append(payload)
78
+ logger.info("📥 이벤트 버퍼에 추가 - 현재 %d개 (임계치: %d개)", len(_EVENT_BUF), COALESCE_BATCH)
79
+ if len(_EVENT_BUF) >= COALESCE_BATCH:
80
+ to_flush_now = True
81
+ logger.info("⚡ 임계치 도달 - 즉시 플러시 예정")
82
+ else:
83
+ _schedule_delayed_flush()
84
+ logger.info("⏰ 지연 플러시 스케줄링")
85
+ if to_flush_now:
86
+ await _flush_events_now()
87
+
88
+ # ------------------------------ Request Context ------------------------------
39
89
  @dataclass
40
90
  class TodoListRowContext:
41
- """fetch_pending_task/… 로부터 받은 todolist 행을 감싼 컨텍스트용 DTO"""
42
91
  row: Dict[str, Any]
43
92
 
44
93
  class ProcessGPTRequestContext(RequestContext):
45
- """DB row(스키마 준수) 기반 RequestContext 구현"""
46
94
  def __init__(self, row: Dict[str, Any]):
47
95
  self.row = row
48
- self._user_input = (row.get('description') or '').strip()
96
+ self._user_input = (row.get("description") or "").strip()
49
97
  self._message = self._user_input
50
98
  self._current_task = None
51
- self._task_state = row.get('draft_status') or ''
99
+ self._task_state = row.get("draft_status") or ""
52
100
  self._extra_context: Dict[str, Any] = {}
53
101
 
54
102
  async def prepare_context(self) -> None:
55
- """database.py를 활용해 부가 컨텍스트를 미리 준비한다. 실패해도 기본값으로 계속 진행."""
56
- logger.info("\n🔧 컨텍스트 준비 중...")
57
- logger.info(" 📋 Task: %s", self.row.get('id'))
58
- logger.info(" 🛠️ Tool: %s", self.row.get('tool') or 'N/A')
59
- logger.info(" 🏢 Tenant: %s", self.row.get('tenant_id') or 'N/A')
60
- effective_proc_inst_id = self.row.get('root_proc_inst_id') or self.row.get('proc_inst_id')
61
- tool_val = self.row.get('tool') or ''
62
- tenant_id = self.row.get('tenant_id') or ''
63
- user_ids = self.row.get('user_id') or ''
103
+ """
104
+ 컨텍스트 준비.
105
+ - 실패 시: 더 이상 진행하지 않고 ContextPreparationError를 발생시켜
106
+ 상위 경계에서 FAILED 처리(이벤트 기록 포함) 단일 경로로 수행.
107
+ """
108
+ logger.info("\n🔧 컨텍스트 준비 시작...")
109
+
110
+ # 1단계: 기본 정보 추출
111
+ effective_proc_inst_id = self.row.get("root_proc_inst_id") or self.row.get("proc_inst_id")
112
+ tool_val = self.row.get("tool") or ""
113
+ tenant_id = self.row.get("tenant_id") or ""
114
+ user_ids = self.row.get("user_id") or ""
115
+
116
+ logger.info("📋 기본 정보 추출 완료 - proc_inst_id: %s, tool: %s, tenant: %s",
117
+ effective_proc_inst_id, tool_val, tenant_id)
64
118
 
65
119
  try:
66
- notif_task = fetch_human_users_by_proc_inst_id(effective_proc_inst_id)
67
- mcp_task = fetch_tenant_mcp_config(tenant_id)
68
- form_task = fetch_form_types(tool_val, tenant_id)
69
- agents_task = fetch_agent_data(user_ids or '')
70
-
71
- notify_emails, tenant_mcp, form_tuple, agents = await asyncio.gather(
72
- notif_task, mcp_task, form_task, agents_task
120
+ # 2단계: 컨텍스트 번들 조회
121
+ logger.info("🔍 컨텍스트 번들 조회 중...")
122
+ notify_emails, tenant_mcp, form_tuple, agents = await fetch_context_bundle(
123
+ effective_proc_inst_id, tenant_id, tool_val, user_ids
73
124
  )
74
-
75
- if not agents:
76
- agents = await fetch_all_agents()
77
-
78
125
  form_id, form_fields, form_html = form_tuple
126
+
127
+ logger.info("📦 컨텍스트 번들 조회 완료 - agents: %d개, notify_emails: %s",
128
+ len(agents) if isinstance(agents, list) else 0,
129
+ "있음" if notify_emails else "없음")
130
+
79
131
  except Exception as e:
80
- logger.exception(
81
- "prepare_context failed (proc_inst_id=%s, todolist_id=%s): %s",
82
- effective_proc_inst_id,
83
- self.row.get('id'),
84
- str(e),
85
- )
86
- notify_emails, tenant_mcp = "", None
87
- form_id, form_fields, form_html = None, [], None
88
- agents = []
132
+ logger.error("❌ 컨텍스트 번들 조회 실패: %s", str(e))
133
+ # 사용자 친화 요약은 상위 경계에서 한 번만 기록하도록 넘김
134
+ raise ContextPreparationError(e)
89
135
 
136
+ # 3단계: 컨텍스트 구성
137
+ logger.info("🏗️ 컨텍스트 구성 중...")
90
138
  self._extra_context = {
91
- 'id': self.row.get('id'),
92
- 'proc_inst_id': effective_proc_inst_id,
93
- 'root_proc_inst_id': self.row.get('root_proc_inst_id'),
94
- 'activity_name': self.row.get('activity_name'),
95
- 'agents': agents,
96
- 'tenant_mcp': tenant_mcp,
97
- 'form_fields': form_fields,
98
- 'form_html': form_html,
99
- 'form_id': form_id,
100
- 'notify_user_emails': notify_emails,
139
+ "id": self.row.get("id"),
140
+ "proc_inst_id": effective_proc_inst_id,
141
+ "root_proc_inst_id": self.row.get("root_proc_inst_id"),
142
+ "activity_name": self.row.get("activity_name"),
143
+ "agents": agents,
144
+ "tenant_mcp": tenant_mcp,
145
+ "form_fields": form_fields,
146
+ "form_html": form_html,
147
+ "form_id": form_id,
148
+ "notify_user_emails": notify_emails,
101
149
  }
102
- logger.info("\n✅ 컨텍스트 준비 완료!")
103
- logger.info(" 📋 Task: %s", self.row.get('id'))
104
- logger.info(" 🤖 Agents: %d개", len(agents) if isinstance(agents, list) else 0)
105
- logger.info(" ⚡ Activity: %s", self.row.get('activity_name'))
106
- logger.info(" 🏭 Process: %s", self.row.get('proc_inst_id'))
107
- logger.info(" 🏢 Tenant: %s", self.row.get('tenant_id'))
108
- logger.info("")
150
+
151
+ logger.info(" 컨텍스트 준비 완료! (agents=%d개)",
152
+ len(agents) if isinstance(agents, list) else 0)
109
153
 
110
154
  def get_user_input(self) -> str:
111
155
  return self._user_input
@@ -127,14 +171,10 @@ class ProcessGPTRequestContext(RequestContext):
127
171
  return self._task_state
128
172
 
129
173
  def get_context_data(self) -> Dict[str, Any]:
130
- return {
131
- 'row': self.row,
132
- 'extras': self._extra_context,
133
- }
174
+ return {"row": self.row, "extras": self._extra_context}
134
175
 
176
+ # ------------------------------ Event Queue ------------------------------
135
177
  class ProcessGPTEventQueue(EventQueue):
136
- """Events 테이블에 이벤트를 저장하는 EventQueue 구현 (database.record_event 사용)"""
137
-
138
178
  def __init__(self, todolist_id: str, agent_orch: str, proc_inst_id: Optional[str]):
139
179
  self.todolist_id = todolist_id
140
180
  self.agent_orch = agent_orch
@@ -142,106 +182,78 @@ class ProcessGPTEventQueue(EventQueue):
142
182
  super().__init__()
143
183
 
144
184
  def enqueue_event(self, event: Event):
145
- """A2A 이벤트 처리"""
146
185
  try:
147
- # 공식 식별자만 공통 추출 (metadata는 타입별로 필요 시만 읽음)
148
- proc_inst_id_val = getattr(event, 'contextId', None) or self.proc_inst_id
149
- todo_id_val = getattr(event, 'taskId', None) or str(self.todolist_id)
150
- logger.info("\n📨 이벤트 수신: %s", type(event).__name__)
151
- logger.info(" 📋 Task: %s", self.todolist_id)
152
- logger.info(" 🔄 Process: %s", proc_inst_id_val)
153
-
154
- # 1) 기본 매핑: Artifact → todolist 저장 (오직 결과물만). 실패해도 진행
186
+ proc_inst_id_val = getattr(event, "contextId", None) or self.proc_inst_id
187
+ todo_id_val = getattr(event, "taskId", None) or str(self.todolist_id)
188
+ logger.info("\n📨 이벤트 수신: %s (task=%s)", type(event).__name__, self.todolist_id)
189
+
190
+ # 1) 결과물 저장
155
191
  if isinstance(event, TaskArtifactUpdateEvent):
192
+ logger.info("📄 아티팩트 업데이트 이벤트 처리 중...")
156
193
  try:
157
194
  is_final = bool(
158
- getattr(event, 'final', None)
159
- or getattr(event, 'lastChunk', None)
160
- or getattr(event, 'last_chunk', None)
161
- or getattr(event, 'last', None)
195
+ getattr(event, "final", None)
196
+ or getattr(event, "lastChunk", None)
197
+ or getattr(event, "last_chunk", None)
198
+ or getattr(event, "last", None)
162
199
  )
163
200
  artifact_content = self._extract_payload(event)
164
- logger.info("\n💾 아티팩트 저장 중...")
165
- logger.info(" 📋 Task: %s", self.todolist_id)
166
- logger.info(" 🏁 Final: %s", "예" if is_final else "아니오")
201
+ logger.info("💾 아티팩트 저장 중... (final=%s)", is_final)
167
202
  asyncio.create_task(save_task_result(self.todolist_id, artifact_content, is_final))
168
- logger.info(" ✅ 저장 완료!\n")
203
+ logger.info("✅ 아티팩트 저장 완료")
169
204
  except Exception as e:
170
- logger.exception(
171
- "enqueue_event artifact save failed (todolist_id=%s, proc_inst_id=%s, event=%s): %s",
172
- self.todolist_id,
173
- self.proc_inst_id,
174
- type(event).__name__,
175
- str(e),
176
- )
205
+ logger.exception("❌ 아티팩트 저장 실패: %s", str(e))
177
206
  return
178
207
 
179
- # 2) 기본 매핑: Statusevents 저장 (crew_type/event_type/status/job_id는 metadata). 실패해도 진행
208
+ # 2) 상태 이벤트 저장(코얼레싱bulk)
180
209
  if isinstance(event, TaskStatusUpdateEvent):
181
- metadata = getattr(event, 'metadata', None)
182
- if not isinstance(metadata, dict):
183
- metadata = {}
184
- crew_type_val = metadata.get('crew_type')
185
- # 상태 기반 event_type 매핑 (input_required -> human_asked)
186
- status_obj = getattr(event, 'status', None)
187
- state_val = getattr(status_obj, 'state', None)
188
- event_type_val = {TaskState.input_required: 'human_asked'}.get(state_val) or metadata.get('event_type')
189
- status_val = metadata.get('status')
190
- job_id_val = metadata.get('job_id')
210
+ logger.info("📊 상태 업데이트 이벤트 처리 중...")
211
+ metadata = getattr(event, "metadata", None) or {}
212
+ crew_type_val = metadata.get("crew_type")
213
+ status_obj = getattr(event, "status", None)
214
+ state_val = getattr(status_obj, "state", None)
215
+ event_type_val = {TaskState.input_required: "human_asked"}.get(state_val) or metadata.get("event_type")
216
+ status_val = metadata.get("status")
217
+ job_id_val = metadata.get("job_id")
218
+
219
+ logger.info("🔍 이벤트 메타데이터 분석 - event_type: %s, status: %s", event_type_val, status_val)
220
+
191
221
  try:
192
222
  payload: Dict[str, Any] = {
193
- 'id': str(uuid.uuid4()),
194
- 'job_id': job_id_val,
195
- 'todo_id': str(todo_id_val),
196
- 'proc_inst_id': proc_inst_id_val,
197
- 'crew_type': crew_type_val,
198
- 'event_type': event_type_val,
199
- 'data': self._extract_payload(event),
200
- 'status': status_val or None,
223
+ "id": str(uuid.uuid4()),
224
+ "job_id": job_id_val,
225
+ "todo_id": str(todo_id_val),
226
+ "proc_inst_id": proc_inst_id_val,
227
+ "crew_type": crew_type_val,
228
+ "event_type": event_type_val,
229
+ "data": self._extract_payload(event),
230
+ "status": status_val or None,
201
231
  }
202
-
203
- logger.info("\n📝 상태 이벤트 기록 중...")
204
- logger.info(" 📋 Task: %s", self.todolist_id)
205
- logger.info(" 🆔 Job: %s", job_id_val or 'N/A')
206
- logger.info(" 🏷️ Type: %s", crew_type_val or 'N/A')
207
- logger.info(" 🔍 Event: %s", event_type_val or 'N/A')
208
- asyncio.create_task(record_event(payload))
209
- logger.info(" ✅ 기록 완료!\n")
232
+ logger.info("📤 상태 이벤트 큐에 추가 중...")
233
+ asyncio.create_task(enqueue_ui_event_coalesced(payload))
234
+ logger.info(" 상태 이벤트 큐 추가 완료")
210
235
  except Exception as e:
211
- logger.exception(
212
- "enqueue_event status record failed (todolist_id=%s, proc_inst_id=%s, job_id=%s, crew_type=%s): %s",
213
- self.todolist_id,
214
- self.proc_inst_id,
215
- job_id_val,
216
- crew_type_val,
217
- str(e),
218
- )
236
+ logger.exception("❌ 상태 이벤트 기록 실패: %s", str(e))
219
237
  return
220
238
 
221
239
  except Exception as e:
222
- logger.error(f"Failed to enqueue event: {e}")
240
+ logger.error(" 이벤트 처리 실패: %s", str(e))
223
241
  raise
224
242
 
225
243
  def _extract_payload(self, event: Event) -> Any:
226
- """이벤트에서 실질 페이로드를 추출한다."""
227
244
  try:
228
- artifact_or_none = getattr(event, 'artifact', None)
229
- status_or_none = getattr(event, 'status', None)
230
- message_or_none = getattr(status_or_none, 'message', None)
231
-
245
+ artifact_or_none = getattr(event, "artifact", None)
246
+ status_or_none = getattr(event, "status", None)
247
+ message_or_none = getattr(status_or_none, "message", None)
232
248
  source = artifact_or_none if artifact_or_none is not None else message_or_none
233
249
  return self._parse_json_or_text(source)
234
250
  except Exception:
235
251
  return {}
236
252
 
237
253
  def _parse_json_or_text(self, value: Any) -> Any:
238
- """간소화: new_* 유틸 출력(dict)과 문자열만 처리하여 순수 payload 반환."""
239
254
  try:
240
- # 1) None → 빈 구조
241
255
  if value is None:
242
256
  return {}
243
-
244
- # 2) 문자열이면 JSON 파싱 시도
245
257
  if isinstance(value, str):
246
258
  text = value.strip()
247
259
  if not text:
@@ -250,153 +262,208 @@ class ProcessGPTEventQueue(EventQueue):
250
262
  return json.loads(text)
251
263
  except Exception:
252
264
  return text
253
-
254
- # 3) 모델 → dict로 정규화 (있으면만)
255
- try:
256
- if hasattr(value, "model_dump") and callable(getattr(value, "model_dump")):
257
- value = value.model_dump()
258
- except Exception:
259
- pass
260
- try:
261
- if not isinstance(value, dict) and hasattr(value, "dict") and callable(getattr(value, "dict")):
262
- value = value.dict()
263
- except Exception:
264
- pass
265
- if not isinstance(value, dict) and hasattr(value, "__dict__"):
265
+ if hasattr(value, "model_dump") and callable(getattr(value, "model_dump")):
266
+ value = value.model_dump()
267
+ elif not isinstance(value, dict) and hasattr(value, "dict") and callable(getattr(value, "dict")):
268
+ value = value.dict()
269
+ elif not isinstance(value, dict) and hasattr(value, "__dict__"):
266
270
  value = value.__dict__
267
-
268
- # 4) dict만 대상으로 parts[0].text → parts[0].root.text → top-level text/content/data 순으로 추출
269
271
  if isinstance(value, dict):
270
272
  parts = value.get("parts")
271
273
  if isinstance(parts, list) and parts:
272
274
  first = parts[0] if isinstance(parts[0], dict) else None
273
- if isinstance(first, dict):
274
- text_candidate = (
275
- first.get("text") or first.get("content") or first.get("data")
276
- )
277
- if not isinstance(text_candidate, str):
278
- root = first.get("root") if isinstance(first.get("root"), dict) else None
279
- if root:
280
- text_candidate = (
281
- root.get("text") or root.get("content") or root.get("data")
282
- )
283
- if isinstance(text_candidate, str):
284
- return self._parse_json_or_text(text_candidate)
275
+ if first and isinstance(first, dict):
276
+ txt = first.get("text") or first.get("content") or first.get("data")
277
+ if isinstance(txt, str):
278
+ try:
279
+ return json.loads(txt)
280
+ except Exception:
281
+ return txt
285
282
  top_text = value.get("text") or value.get("content") or value.get("data")
286
283
  if isinstance(top_text, str):
287
- return self._parse_json_or_text(top_text)
284
+ try:
285
+ return json.loads(top_text)
286
+ except Exception:
287
+ return top_text
288
288
  return value
289
-
290
- # 5) 그 외 타입은 원형 반환
291
289
  return value
292
290
  except Exception:
293
291
  return {}
294
292
 
295
293
  def task_done(self) -> None:
296
294
  try:
295
+ logger.info("🏁 작업 완료 이벤트 생성 중...")
297
296
  payload: Dict[str, Any] = {
298
- 'id': str(uuid.uuid4()),
299
- 'job_id': "CREW_FINISHED",
300
- 'todo_id': str(self.todolist_id),
301
- 'proc_inst_id': self.proc_inst_id,
302
- 'crew_type': "crew",
303
- 'data': "Task completed successfully",
304
- 'event_type': 'crew_completed',
305
- 'status': None,
297
+ "id": str(uuid.uuid4()),
298
+ "job_id": "CREW_FINISHED",
299
+ "todo_id": str(self.todolist_id),
300
+ "proc_inst_id": self.proc_inst_id,
301
+ "crew_type": "agent",
302
+ "data": "Task completed successfully",
303
+ "event_type": "crew_completed",
304
+ "status": None,
306
305
  }
307
- asyncio.create_task(record_event(payload))
308
- logger.info("\n🏁 작업 완료 기록됨: %s\n", self.todolist_id)
306
+ logger.info("📤 작업 완료 이벤트 큐에 추가 중...")
307
+ asyncio.create_task(enqueue_ui_event_coalesced(payload))
308
+ logger.info("✅ 작업 완료 이벤트 기록 완료")
309
309
  except Exception as e:
310
- logger.error(f"Failed to record task completion: {e}")
310
+ logger.error(" 작업 완료 이벤트 기록 실패: %s", str(e))
311
311
  raise
312
312
 
313
+ # ------------------------------ Agent Server ------------------------------
313
314
  class ProcessGPTAgentServer:
314
- """DB 폴링 기반 Agent Server (database.py 사용)"""
315
-
316
315
  def __init__(self, agent_executor: AgentExecutor, agent_type: str):
317
316
  self.agent_executor = agent_executor
318
317
  self.agent_orch = agent_type
319
- self.polling_interval = 5 # seconds
320
318
  self.is_running = False
319
+ self._shutdown_event = asyncio.Event()
320
+ self._current_todo_id: Optional[str] = None # 진행 중 작업 추적(참고용)
321
+
322
+ async def _install_signal_handlers(self):
323
+ loop = asyncio.get_running_loop()
324
+ try:
325
+ loop.add_signal_handler(signal.SIGTERM, lambda: self._shutdown_event.set())
326
+ loop.add_signal_handler(signal.SIGINT, lambda: self._shutdown_event.set())
327
+ except NotImplementedError:
328
+ # Windows 등 일부 환경은 지원 안 됨
329
+ pass
321
330
 
322
331
  async def run(self):
323
- """메인 실행 루프"""
324
332
  self.is_running = True
325
- logger.info("\n\n🚀 ===============================================")
326
- logger.info(" ProcessGPT Agent Server STARTED")
327
- logger.info(f" Agent Type: {self.agent_orch}")
328
- logger.info("===============================================\n")
333
+ logger.info("\n\n🚀 ProcessGPT Agent Server START (agent=%s)\n", self.agent_orch)
329
334
  initialize_db()
330
-
331
- while self.is_running:
335
+ await self._install_signal_handlers()
336
+
337
+ while self.is_running and not self._shutdown_event.is_set():
332
338
  try:
333
- logger.info("\n🔍 Polling for tasks (agent_orch=%s)...", self.agent_orch)
339
+ logger.info("🔍 Polling for tasks (agent_orch=%s)...", self.agent_orch)
334
340
  row = await polling_pending_todos(self.agent_orch, get_consumer_id())
335
341
 
336
342
  if row:
337
- logger.info("\n\n✅ 새 작업 발견!")
338
- logger.info("📋 Task ID: %s", row.get('id'))
339
- logger.info("🔄 Process: %s", row.get('proc_inst_id'))
340
- logger.info("⚡ Activity: %s", row.get('activity_name'))
341
- logger.info("")
343
+ logger.info("✅ 새 작업: %s (proc=%s, activity=%s)", row.get("id"), row.get("proc_inst_id"), row.get("activity_name"))
342
344
  try:
345
+ self._current_todo_id = str(row.get("id"))
343
346
  await self.process_todolist_item(row)
344
347
  except Exception as e:
348
+ # 경계에서 처리(에러 이벤트 + FAILED 마킹) 후 예외 재전달됨.
345
349
  logger.exception("process_todolist_item failed: %s", str(e))
346
- try:
347
- await self.mark_task_failed(str(row.get('id')), str(e))
348
- except Exception as ee:
349
- logger.exception("mark_task_failed failed: %s", str(ee))
350
- await asyncio.sleep(self.polling_interval)
350
+ finally:
351
+ self._current_todo_id = None
352
+ # 작업이 있었으므로 슬립 생략 → 즉시 다음 폴링
353
+ continue
354
+
355
+ # 작업 없을 때만 10초 대기
356
+ await asyncio.sleep(10)
351
357
 
352
358
  except Exception as e:
353
- logger.exception(
354
- "run loop error (agent_orch=%s): %s",
355
- self.agent_orch,
356
- str(e),
357
- )
358
- await asyncio.sleep(self.polling_interval)
359
+ # 폴링 자체 오류는 특정 작업에 귀속되지 않으므로 상태 마킹 대상 없음
360
+ logger.exception("run loop error: %s", str(e))
361
+ await asyncio.sleep(10)
362
+
363
+ # 종료 시 남은 이벤트 강제 flush (오류로 간주하지 않음)
364
+ try:
365
+ await _flush_events_now()
366
+ logger.info("🧹 graceful shutdown: pending events flushed")
367
+ except Exception as e:
368
+ logger.exception("flush on shutdown failed: %s", str(e))
369
+
370
+ logger.info("👋 Agent server stopped.")
359
371
 
360
372
  async def process_todolist_item(self, row: Dict[str, Any]):
361
- """개별 todolist 항목을 처리"""
362
- logger.info("\n\n🎯 ============== 작업 처리 시작 ==============")
363
- logger.info("📝 Task ID: %s", row.get('id'))
364
- logger.info("🔧 Tool: %s", row.get('tool'))
365
- logger.info("🏭 Process: %s", row.get('proc_inst_id'))
366
- logger.info("=" * 50 + "\n")
373
+ """
374
+ 경계 정책(최종본):
375
+ - 어떤 예외든 여기에서 잡힘
376
+ - 항상 단일 경로로:
377
+ 1) 사용자 친화 5줄 설명 생성
378
+ 2) event_type='error' 단건 이벤트 기록
379
+ 3) todolist를 FAILED로 마킹
380
+ 4) 예외 재전달(상위 루프는 죽지 않고 다음 폴링)
381
+ """
382
+ task_id = row.get("id")
383
+ logger.info("\n🎯 작업 처리 시작 - Task ID: %s", task_id)
384
+ logger.info("📝 작업 정보 - proc_inst_id: %s, activity: %s, tool: %s",
385
+ row.get("proc_inst_id"), row.get("activity_name"), row.get("tool"))
367
386
 
387
+ friendly_text: Optional[str] = None
388
+
368
389
  try:
390
+ # 1) 컨텍스트 준비 (실패 시 ContextPreparationError로 올라옴)
391
+ logger.info("🔧 컨텍스트 준비 단계 시작...")
369
392
  context = ProcessGPTRequestContext(row)
370
393
  await context.prepare_context()
371
- event_queue = ProcessGPTEventQueue(str(row.get('id')), self.agent_orch, row.get('proc_inst_id'))
394
+ logger.info("✅ 컨텍스트 준비 완료")
395
+
396
+ # 2) 실행
397
+ logger.info("🤖 에이전트 실행 단계 시작...")
398
+ event_queue = ProcessGPTEventQueue(str(task_id), self.agent_orch, row.get("proc_inst_id"))
372
399
  await self.agent_executor.execute(context, event_queue)
400
+ logger.info("✅ 에이전트 실행 완료")
401
+
402
+ # 3) 정상 완료 이벤트
403
+ logger.info("🏁 작업 완료 처리 중...")
373
404
  event_queue.task_done()
374
- logger.info("\n\n🎉 ============== 작업 완료 ==============")
375
- logger.info("✨ Task ID: %s", row.get('id'))
376
- logger.info("=" * 45 + "\n\n")
377
-
378
- except Exception as e:
379
- logger.exception(
380
- "process_todolist_item error (todolist_id=%s, proc_inst_id=%s): %s",
381
- row.get('id'),
382
- row.get('proc_inst_id'),
383
- str(e),
384
- )
385
- await self.mark_task_failed(str(row.get('id')), str(e))
386
- raise
405
+ logger.info("🎉 작업 완료: %s\n", task_id)
387
406
 
388
- async def mark_task_failed(self, todolist_id: str, error_message: str):
389
- """태스크 실패 처리 (DB 상태 업데이트)"""
390
- try:
391
- await update_task_error(todolist_id)
392
407
  except Exception as e:
393
- logger.exception(
394
- "mark_task_failed error (todolist_id=%s): %s",
395
- todolist_id,
396
- str(e),
397
- )
408
+ logger.error("❌ 작업 처리 중 오류 발생: %s", str(e))
409
+
410
+ # 컨텍스트 실패라면 friendly가 없을 수 있어, 여기서 반드시 생성
411
+ try:
412
+ logger.info("📝 사용자 친화 오류 메시지 생성 중...")
413
+ if isinstance(e, ContextPreparationError) and e.friendly:
414
+ friendly_text = e.friendly
415
+ else:
416
+ friendly_text = await summarize_error_to_user(
417
+ e if not isinstance(e, ContextPreparationError) else e.original,
418
+ {
419
+ "task_id": task_id,
420
+ "proc_inst_id": row.get("proc_inst_id"),
421
+ "agent_orch": self.agent_orch,
422
+ "tool": row.get("tool"),
423
+ },
424
+ )
425
+ logger.info("✅ 사용자 친화 오류 메시지 생성 완료")
426
+ except Exception:
427
+ logger.warning("⚠️ 사용자 친화 오류 메시지 생성 실패")
428
+ # 요약 생성 실패 시에도 처리 계속
429
+ friendly_text = None
430
+
431
+ # 에러 이벤트 기록(단건). 실패해도 로그만 남기고 진행.
432
+ logger.info("📤 오류 이벤트 기록 중...")
433
+ payload: Dict[str, Any] = {
434
+ "id": str(uuid.uuid4()),
435
+ "job_id": "TASK_ERROR",
436
+ "todo_id": str(task_id),
437
+ "proc_inst_id": row.get("proc_inst_id"),
438
+ "crew_type": "agent",
439
+ "event_type": "error",
440
+ "data": {
441
+ "name": "시스템 오류 알림",
442
+ "goal": "오류 원인과 대처 안내를 전달합니다.",
443
+ "agent_profile": "/images/chat-icon.png",
444
+ "friendly": friendly_text or "처리 중 오류가 발생했습니다. 로그를 확인해 주세요.",
445
+ "raw_error": f"{type(e).__name__}: {str(e)}" if not isinstance(e, ContextPreparationError) else f"{type(e.original).__name__}: {str(e.original)}",
446
+ }
447
+ }
448
+ try:
449
+ asyncio.create_task(record_event(payload))
450
+ logger.info("✅ 오류 이벤트 기록 완료")
451
+ except Exception:
452
+ logger.exception("❌ 오류 이벤트 기록 실패")
453
+
454
+ # 상태 FAILED 마킹
455
+ logger.info("🏷️ 작업 상태 FAILED로 마킹 중...")
456
+ try:
457
+ await update_task_error(str(task_id))
458
+ logger.info("✅ 작업 상태 FAILED 마킹 완료")
459
+ except Exception:
460
+ logger.exception("❌ 작업 상태 FAILED 마킹 실패")
461
+
462
+ # 상위로 재전달하여 루프는 계속(죽지 않음)
463
+ logger.error("🔄 오류 처리 완료 - 다음 작업으로 계속 진행")
464
+ raise
398
465
 
399
466
  def stop(self):
400
- """서버 중지"""
401
467
  self.is_running = False
402
- logger.info("ProcessGPT Agent Server stopped")
468
+ self._shutdown_event.set()
469
+ logger.info("ProcessGPT Agent Server stopping...")