process-gpt-agent-sdk 0.3.16__py3-none-any.whl → 0.3.18__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.

@@ -2,7 +2,6 @@ import os
2
2
  import json
3
3
  import asyncio
4
4
  import socket
5
- import uuid
6
5
  from typing import Any, Dict, List, Optional, Tuple, Callable, TypeVar
7
6
 
8
7
  from dotenv import load_dotenv
@@ -11,26 +10,22 @@ import logging
11
10
  import random
12
11
 
13
12
  T = TypeVar("T")
14
-
15
-
16
- # 모듈 전역 로거 (정상 경로는 로깅하지 않고, 오류 시에만 사용)
17
13
  logger = logging.getLogger(__name__)
18
14
 
19
-
20
- # ============================================================================
21
- # Utility: 재시도 헬퍼 및 유틸
22
- # 설명: 동기 DB 호출을 안전하게 재시도 (지수 백오프 + 지터) 및 유틸
23
- # ============================================================================
24
-
15
+ # ------------------------------ Retry & JSON utils ------------------------------
25
16
  async def _async_retry(
26
- fn: Callable[[], T],
17
+ fn: Callable[[], Any],
27
18
  *,
28
19
  name: str,
29
20
  retries: int = 3,
30
21
  base_delay: float = 0.8,
31
- fallback: Optional[Callable[[], T]] = None,
32
- ) -> Optional[T]:
33
- """지수 백오프+jitter로 재시도하고 실패 시 fallback/None 반환."""
22
+ fallback: Optional[Callable[[], Any]] = None,
23
+ ) -> Optional[Any]:
24
+ """
25
+ - 각 시도 실패: warning 로깅(시도/지연/에러 포함)
26
+ - 최종 실패: FATAL 로깅(스택 포함), 예외는 재전파하지 않고 None 반환(기존 정책 유지)
27
+ - fallback 이 있으면 실행(실패 시에도 로깅 후 None)
28
+ """
34
29
  last_err: Optional[Exception] = None
35
30
  for attempt in range(1, retries + 1):
36
31
  try:
@@ -39,32 +34,29 @@ async def _async_retry(
39
34
  last_err = e
40
35
  jitter = random.uniform(0, 0.3)
41
36
  delay = base_delay * (2 ** (attempt - 1)) + jitter
37
+ logger.warning(
38
+ "retry warn: name=%s attempt=%d/%d delay=%.2fs error=%s",
39
+ name, attempt, retries, delay, str(e),
40
+ exc_info=e
41
+ )
42
42
  await asyncio.sleep(delay)
43
+
44
+ # 최종 실패
43
45
  if last_err is not None:
44
46
  logger.error(
45
- "retry failed: name=%s retries=%s error=%s", name, retries, str(last_err),
46
- exc_info=last_err,
47
+ "FATAL: retry failed: name=%s retries=%s error=%s",
48
+ name, retries, str(last_err), exc_info=last_err
47
49
  )
50
+
48
51
  if fallback is not None:
49
52
  try:
50
- fb_val = fallback()
51
- return fb_val
53
+ return fallback()
52
54
  except Exception as fb_err:
53
55
  logger.error("fallback failed: name=%s error=%s", name, str(fb_err), exc_info=fb_err)
54
56
  return None
55
57
  return None
56
-
57
58
 
58
- def _is_valid_uuid(value: str) -> bool:
59
- """UUID 문자열 형식 검증 (v1~v8 포함)"""
60
- try:
61
- uuid.UUID(value)
62
- return True
63
- except Exception:
64
- return False
65
-
66
59
  def _to_jsonable(value: Any) -> Any:
67
- """간단한 JSON 변환: dict 재귀, list/tuple/set→list, 기본형 유지, 나머지는 repr."""
68
60
  try:
69
61
  if value is None or isinstance(value, (str, int, float, bool)):
70
62
  return value
@@ -78,16 +70,10 @@ def _to_jsonable(value: Any) -> Any:
78
70
  except Exception:
79
71
  return repr(value)
80
72
 
81
-
82
- # ============================================================================
83
- # DB 연결/클라이언트
84
- # 설명: 환경 변수 로드, Supabase 클라이언트 초기화/반환, 컨슈머 식별자
85
- # ============================================================================
73
+ # ------------------------------ DB Client ------------------------------
86
74
  _supabase_client: Optional[Client] = None
87
75
 
88
-
89
76
  def initialize_db() -> None:
90
- """환경변수 로드 및 Supabase 클라이언트 초기화"""
91
77
  global _supabase_client
92
78
  if _supabase_client is not None:
93
79
  return
@@ -103,16 +89,12 @@ def initialize_db() -> None:
103
89
  logger.error("initialize_db failed: %s", str(e), exc_info=e)
104
90
  raise
105
91
 
106
-
107
92
  def get_db_client() -> Client:
108
- """초기화된 Supabase 클라이언트 반환."""
109
93
  if _supabase_client is None:
110
94
  raise RuntimeError("DB 미초기화: initialize_db() 먼저 호출")
111
95
  return _supabase_client
112
96
 
113
-
114
97
  def get_consumer_id() -> str:
115
- """파드/프로세스 식별자 생성(CONSUMER_ID>HOST:PID)."""
116
98
  env_consumer = os.getenv("CONSUMER_ID")
117
99
  if env_consumer:
118
100
  return env_consumer
@@ -120,17 +102,9 @@ def get_consumer_id() -> str:
120
102
  pid = os.getpid()
121
103
  return f"{host}:{pid}"
122
104
 
123
-
124
- # ============================================================================
125
- # 데이터 조회
126
- # 설명: TODOLIST 테이블 조회, 완료 output 목록 조회, 이벤트 조회, 폼 조회, 테넌트 MCP 설정 조회, 사용자 및 에이전트 조회
127
- # ============================================================================
105
+ # ------------------------------ Polling ------------------------------
128
106
  async def polling_pending_todos(agent_orch: str, consumer: str) -> Optional[Dict[str, Any]]:
129
- """TODOLIST 테이블에서 대기중인 워크아이템을 조회 (agent_orch 전달).
130
-
131
- - 정상 동작 시 로그를 남기지 않는다.
132
- - 예외 시에만 풍부한 에러 정보를 남기되, 호출자에게 None을 반환하여 폴링 루프가 중단되지 않게 한다.
133
- """
107
+ """단일 RPC(fetch_pending_task) 호출: p_env dev/prod 분기"""
134
108
  if agent_orch is None:
135
109
  agent_orch = ""
136
110
  if consumer is None:
@@ -139,319 +113,126 @@ async def polling_pending_todos(agent_orch: str, consumer: str) -> Optional[Dict
139
113
  def _call():
140
114
  client = get_db_client()
141
115
  consumer_id = consumer or socket.gethostname()
142
- env = (os.getenv("ENV") or "").lower()
143
-
144
- if env == "dev":
145
- resp = client.rpc(
146
- "fetch_pending_task_dev",
147
- {"p_agent_orch": agent_orch, "p_consumer": consumer_id, "p_limit": 1, "p_tenant_id": "uengine"},
148
- ).execute()
149
- else:
150
- resp = client.rpc(
151
- "fetch_pending_task",
152
- {"p_agent_orch": agent_orch, "p_consumer": consumer_id, "p_limit": 1},
153
- ).execute()
154
-
155
- rows = resp.data or []
156
- return rows[0] if rows else None
157
-
158
- return await _async_retry(_call, name="polling_pending_todos", fallback=lambda: None)
159
116
 
117
+ # ENV 값을 dev / (그외=prod) 로만 정규화
118
+ p_env = (os.getenv("ENV") or "").lower()
119
+ if p_env != "dev":
120
+ p_env = "prod"
121
+
122
+ resp = client.rpc(
123
+ "fetch_pending_task",
124
+ {
125
+ "p_agent_orch": agent_orch,
126
+ "p_consumer": consumer_id,
127
+ "p_limit": 1,
128
+ "p_env": p_env,
129
+ },
130
+ ).execute()
160
131
 
161
- def fetch_human_response_sync(job_id: str) -> Optional[Dict[str, Any]]:
162
- """events에서 특정 job_id의 human_response 조회"""
163
- if not job_id:
164
- return None
165
- try:
166
- client = get_db_client()
167
- resp = (
168
- client
169
- .table("events")
170
- .select("*")
171
- .eq("job_id", job_id)
172
- .eq("event_type", "human_response")
173
- .execute()
174
- )
175
132
  rows = resp.data or []
176
133
  return rows[0] if rows else None
177
- except Exception as e:
178
- logger.error("fetch_human_response_sync failed: %s", str(e), exc_info=e)
179
- return None
180
134
 
135
+ return await _async_retry(_call, name="polling_pending_todos", fallback=lambda: None)
181
136
 
182
- async def fetch_task_status(todo_id: str) -> Optional[str]:
183
- """todo의 draft_status를 조회한다."""
184
- if not todo_id:
185
- return None
186
- def _call():
187
- client = get_db_client()
188
- return (
189
- client.table("todolist").select("draft_status").eq("id", todo_id).single().execute()
190
- )
191
-
192
- try:
193
- resp = await _async_retry(_call, name="fetch_task_status")
194
- except Exception as e:
195
- logger.error("fetch_task_status fatal: %s", str(e), exc_info=e)
196
- return None
197
- if not resp or not getattr(resp, "data", None):
198
- return None
199
- try:
200
- return resp.data.get("draft_status")
201
- except Exception as e:
202
- logger.error("fetch_task_status parse error: %s", str(e), exc_info=e)
203
- return None
204
-
205
-
206
-
207
- async def fetch_all_agents() -> List[Dict[str, Any]]:
208
- """모든 에이전트 목록을 정규화하여 반환한다."""
209
- def _call():
210
- client = get_db_client()
211
- return (
212
- client.table("users")
213
- .select("id, username, role, goal, persona, tools, profile, model, tenant_id, is_agent, endpoint")
214
- .eq("is_agent", True)
215
- .execute()
216
- )
217
-
218
- try:
219
- resp = await _async_retry(_call, name="fetch_all_agents")
220
- except Exception as e:
221
- logger.error("fetch_all_agents fatal: %s", str(e), exc_info=e)
222
- return []
223
- rows = resp.data or [] if resp else []
224
- try:
225
- normalized: List[Dict[str, Any]] = []
226
- for row in rows:
227
- normalized.append(
228
- {
229
- "id": row.get("id"),
230
- "name": row.get("username"),
231
- "role": row.get("role"),
232
- "goal": row.get("goal"),
233
- "persona": row.get("persona"),
234
- "tools": row.get("tools") or "mem0",
235
- "profile": row.get("profile"),
236
- "model": row.get("model"),
237
- "tenant_id": row.get("tenant_id"),
238
- "endpoint": row.get("endpoint"),
239
- }
240
- )
241
- return normalized
242
- except Exception as e:
243
- logger.error("fetch_all_agents parse error: %s", str(e), exc_info=e)
244
- return []
245
-
246
-
247
- async def fetch_agent_data(user_ids: str) -> List[Dict[str, Any]]:
248
- """TODOLIST의 user_id 값으로, 역할로 지정된 에이전트를 조회하고 정규화해 반환한다."""
249
-
250
- raw_ids = [x.strip() for x in (user_ids or "").split(",") if x.strip()]
251
- valid_ids = [x for x in raw_ids if _is_valid_uuid(x)]
252
-
253
- if not valid_ids:
254
- return await fetch_all_agents()
255
-
137
+ # ------------------------------ Context Bundle ------------------------------
138
+ async def fetch_context_bundle(
139
+ proc_inst_id: str,
140
+ tenant_id: str,
141
+ tool_val: str,
142
+ user_ids: str,
143
+ ) -> Tuple[str, Optional[Dict[str, Any]], Tuple[Optional[str], List[Dict[str, Any]], Optional[str]], List[Dict[str, Any]]]:
256
144
  def _call():
257
145
  client = get_db_client()
258
- resp = (
259
- client
260
- .table("users")
261
- .select("id, username, role, goal, persona, tools, profile, model, tenant_id, is_agent, endpoint")
262
- .in_("id", valid_ids)
263
- .eq("is_agent", True)
264
- .execute()
265
- )
146
+ resp = client.rpc(
147
+ "fetch_context_bundle",
148
+ {
149
+ "p_proc_inst_id": proc_inst_id or "",
150
+ "p_tenant_id": tenant_id or "",
151
+ "p_tool": tool_val or "",
152
+ "p_user_ids": user_ids or "",
153
+ },
154
+ ).execute()
266
155
  rows = resp.data or []
267
- normalized: List[Dict[str, Any]] = []
268
- for row in rows:
269
- normalized.append(
270
- {
271
- "id": row.get("id"),
272
- "name": row.get("username"),
273
- "role": row.get("role"),
274
- "goal": row.get("goal"),
275
- "persona": row.get("persona"),
276
- "tools": row.get("tools") or "mem0",
277
- "profile": row.get("profile"),
278
- "model": row.get("model"),
279
- "tenant_id": row.get("tenant_id"),
280
- "endpoint": row.get("endpoint"),
281
- }
282
- )
283
- return normalized
156
+ row = rows[0] if rows else {}
157
+ notify = (row.get("notify_emails") or "").strip()
158
+ mcp = row.get("tenant_mcp") or None
159
+ form_id = row.get("form_id")
160
+ form_fields = row.get("form_fields")
161
+ form_html = row.get("form_html")
162
+
163
+ # form 정보가 없는 경우 자유형식 폼으로 처리
164
+ if not form_id or not form_fields:
165
+ form_id = "freeform"
166
+ form_fields = [{"key": "freeform", "type": "textarea", "text": "자유형식 입력", "placeholder": "원하는 내용을 자유롭게 입력해주세요."}]
167
+ form_html = None
168
+ agents = row.get("agents") or []
169
+ return notify, mcp, (form_id, form_fields, form_html), agents
284
170
 
285
171
  try:
286
- result = await _async_retry(_call, name="fetch_agent_data", fallback=lambda: [])
172
+ return await _async_retry(_call, name="fetch_context_bundle", fallback=lambda: ("", None, (None, [], None), []))
287
173
  except Exception as e:
288
- logger.error("fetch_agent_data fatal: %s", str(e), exc_info=e)
289
- return await fetch_all_agents()
290
-
291
- if not result:
292
- return await fetch_all_agents()
293
-
294
- return result
174
+ logger.error("fetch_context_bundle fatal: %s", str(e), exc_info=e)
175
+ return ("", None, (None, [], None), [])
295
176
 
177
+ # ------------------------------ Events & Results ------------------------------
178
+ async def record_events_bulk(payloads: List[Dict[str, Any]]) -> None:
179
+ """
180
+ 이벤트 다건 저장:
181
+ - 성공: 'record_events_bulk ok'
182
+ - 실패(최종): '❌ record_events_bulk failed' (개수 포함)
183
+ """
184
+ if not payloads:
185
+ return
296
186
 
297
- async def fetch_form_types(tool_val: str, tenant_id: str) -> Tuple[str, List[Dict[str, Any]], Optional[str]]:
298
- """폼 타입 정의를 조회해 (form_id, fields, html)로 반환한다."""
299
- if tool_val is None:
300
- tool_val = ""
301
- if tenant_id is None:
302
- tenant_id = ""
303
- form_id = tool_val[12:] if tool_val.startswith("formHandler:") else tool_val
304
-
305
- def _call():
306
- client = get_db_client()
307
- resp = (
308
- client
309
- .table("form_def")
310
- .select("fields_json, html")
311
- .eq("id", form_id)
312
- .eq("tenant_id", tenant_id)
313
- .execute()
314
- )
315
- fields_json = resp.data[0].get("fields_json") if resp.data else None
316
- form_html = resp.data[0].get("html") if resp.data else None
317
- if not fields_json:
318
- return form_id, [{"key": form_id, "type": "default", "text": ""}], form_html
319
- return form_id, fields_json, form_html
320
-
321
- try:
322
- resp = await _async_retry(
323
- _call,
324
- name="fetch_form_types",
325
- fallback=lambda: (form_id, [{"key": form_id, "type": "default", "text": ""}], None),
326
- )
327
- except Exception as e:
328
- logger.error("fetch_form_types fatal: %s", str(e), exc_info=e)
329
- resp = None
330
- return resp if resp else (form_id, [{"key": form_id, "type": "default", "text": ""}], None)
331
-
187
+ safe_list: List[Dict[str, Any]] = []
188
+ for p in payloads:
189
+ sp = _to_jsonable(p)
190
+ if isinstance(sp, dict) and sp.get("status", "") == "":
191
+ sp["status"] = None
192
+ safe_list.append(sp)
332
193
 
333
- async def fetch_tenant_mcp_config(tenant_id: str) -> Optional[Dict[str, Any]]:
334
- """테넌트 MCP 설정을 조회해 반환한다."""
335
- if not tenant_id:
336
- return None
337
194
  def _call():
338
195
  client = get_db_client()
339
- return client.table("tenants").select("mcp").eq("id", tenant_id).single().execute()
340
-
341
- try:
342
- resp = await _async_retry(_call, name="fetch_tenant_mcp_config", fallback=lambda: None)
343
- except Exception as e:
344
- logger.error("fetch_tenant_mcp_config fatal: %s", str(e), exc_info=e)
345
- return None
346
- return resp.data.get("mcp") if resp and getattr(resp, "data", None) else None
347
-
348
-
349
- async def fetch_human_users_by_proc_inst_id(proc_inst_id: str) -> str:
350
- """proc_inst_id로 현재 프로세스의 모든 사용자 이메일 목록을 쉼표로 반환한다."""
351
- if not proc_inst_id:
352
- return ""
353
-
354
- def _sync():
355
- try:
356
- supabase = get_db_client()
357
-
358
- resp = (
359
- supabase
360
- .table('todolist')
361
- .select('user_id')
362
- .eq('proc_inst_id', proc_inst_id)
363
- .execute()
364
- )
365
-
366
- if not resp.data:
367
- return ""
368
-
369
- all_user_ids = set()
370
- for row in resp.data:
371
- user_id = row.get('user_id', '')
372
- if user_id:
373
- ids = [id.strip() for id in user_id.split(',') if id.strip()]
374
- all_user_ids.update(ids)
375
-
376
- if not all_user_ids:
377
- return ""
378
-
379
- human_user_emails = []
380
- for user_id in all_user_ids:
381
- if not _is_valid_uuid(user_id):
382
- continue
383
-
384
- user_resp = (
385
- supabase
386
- .table('users')
387
- .select('id, email, is_agent')
388
- .eq('id', user_id)
389
- .execute()
390
- )
391
-
392
- if user_resp.data:
393
- user = user_resp.data[0]
394
- is_agent = user.get('is_agent')
395
- if not is_agent:
396
- email = (user.get('email') or '').strip()
397
- if email:
398
- human_user_emails.append(email)
399
-
400
- return ','.join(human_user_emails)
401
-
402
- except Exception as e:
403
- logger.error("fetch_human_users_by_proc_inst_id failed: %s", str(e), exc_info=e)
404
- return ""
405
-
406
- return await asyncio.to_thread(_sync)
196
+ return client.rpc("record_events_bulk", {"p_events": safe_list}).execute()
407
197
 
198
+ res = await _async_retry(_call, name="record_events_bulk", fallback=lambda: None)
199
+ if res is None:
200
+ logger.error("❌ record_events_bulk failed: events not persisted count=%d", len(safe_list))
201
+ else:
202
+ logger.info("record_events_bulk ok: count=%d", len(safe_list))
408
203
 
409
- # ============================================================================
410
- # 데이터 저장
411
- # 설명: 이벤트/알림/작업 결과 저장
412
- # ============================================================================
413
204
  async def record_event(payload: Dict[str, Any]) -> None:
414
- """UI용 events 테이블에 이벤트 기록 (전달된 payload 그대로 저장)"""
415
- if payload is None:
416
- logger.error("record_event invalid payload: None")
205
+ """
206
+ 단건 이벤트 저장.
207
+ - 성공: 'record_event ok'
208
+ - 실패(최종): '❌ record_event failed'
209
+ - 실패해도 워크플로우는 계속(요청사항)
210
+ """
211
+ if not payload:
417
212
  return
213
+
418
214
  def _call():
419
215
  client = get_db_client()
420
216
  safe_payload = _to_jsonable(payload)
421
- # 상태값이 문자열이면 NULL로
422
- if isinstance(safe_payload, dict):
423
- status_val = safe_payload.get("status")
424
- if status_val == "":
425
- safe_payload["status"] = None
217
+ if isinstance(safe_payload, dict) and safe_payload.get("status", "") == "":
218
+ safe_payload["status"] = None
426
219
  return client.table("events").insert(safe_payload).execute()
427
220
 
428
- try:
429
- resp = await _async_retry(_call, name="record_event", fallback=lambda: None)
430
- except Exception as e:
431
- try:
432
- logger.error("record_event fatal: %s payload=%s", str(e), json.dumps(_to_jsonable(payload), ensure_ascii=False), exc_info=e)
433
- except Exception:
434
- logger.error("record_event fatal (payload dump failed): %s", str(e), exc_info=e)
435
- return
436
- if resp is None:
437
- try:
438
- logger.error("events insert 실패: payload=%s", json.dumps(_to_jsonable(payload), ensure_ascii=False))
439
- except Exception:
440
- logger.error("events insert 실패 (payload dump failed)")
441
-
442
-
221
+ res = await _async_retry(_call, name="record_event", fallback=lambda: None)
222
+ if res is None:
223
+ logger.error("❌ record_event failed =%s", payload.get("event_type"))
224
+ else:
225
+ logger.info("record_event ok: event_type=%s", payload.get("event_type"))
443
226
 
444
227
  async def save_task_result(todo_id: str, result: Any, final: bool = False) -> None:
445
- """작업 결과를 저장한다. final=True 시 최종 저장."""
446
228
  if not todo_id:
447
229
  logger.error("save_task_result invalid todo_id: %s", str(todo_id))
448
230
  return
449
- # 안전한 직렬화: 실패 시 문자열화하여 저장자가 원인 파악 가능
450
- def _safe_payload(val: Any) -> Any:
231
+
232
+ def _safe(val: Any) -> Any:
451
233
  try:
452
234
  return _to_jsonable(val)
453
- except Exception as e:
454
- logger.error("save_task_result payload serialization failed: %s", str(e), exc_info=e)
235
+ except Exception:
455
236
  try:
456
237
  return {"repr": repr(val)}
457
238
  except Exception:
@@ -459,79 +240,26 @@ async def save_task_result(todo_id: str, result: Any, final: bool = False) -> No
459
240
 
460
241
  def _call():
461
242
  client = get_db_client()
462
- payload = _safe_payload(result)
463
- return client.rpc(
464
- "save_task_result",
465
- {"p_todo_id": todo_id, "p_payload": payload, "p_final": bool(final)},
466
- ).execute()
467
-
468
- try:
469
- await _async_retry(_call, name="save_task_result", fallback=lambda: None)
470
- except Exception as e:
471
- logger.error("save_task_result fatal: %s", str(e), exc_info=e)
243
+ payload = _safe(result)
244
+ return client.rpc("save_task_result", {"p_todo_id": todo_id, "p_payload": payload, "p_final": bool(final)}).execute()
472
245
 
246
+ res = await _async_retry(_call, name="save_task_result", fallback=lambda: None)
247
+ if res is None:
248
+ logger.error("❌ save_task_result failed todo_id=%s", todo_id)
249
+ else:
250
+ logger.info("save_task_result ok todo_id=%s", todo_id)
473
251
 
474
- def save_notification(
475
- *,
476
- title: str,
477
- notif_type: str,
478
- description: Optional[str] = None,
479
- user_ids_csv: Optional[str] = None,
480
- tenant_id: Optional[str] = None,
481
- url: Optional[str] = None,
482
- from_user_id: Optional[str] = None,
483
- ) -> None:
484
- """notifications 테이블에 알림 저장"""
485
- try:
486
- # 대상 사용자가 없으면 작업 생략
487
- if not user_ids_csv:
488
- return
489
-
490
- client = get_db_client()
491
-
492
- user_ids: List[str] = [uid.strip() for uid in user_ids_csv.split(',') if uid and uid.strip()]
493
- if not user_ids:
494
- return
495
-
496
- rows: List[Dict[str, Any]] = []
497
- for uid in user_ids:
498
- rows.append(
499
- {
500
- "id": str(uuid.uuid4()),
501
- "user_id": uid,
502
- "tenant_id": tenant_id,
503
- "title": title,
504
- "description": description,
505
- "type": notif_type,
506
- "url": url,
507
- "from_user_id": from_user_id,
508
- }
509
- )
510
-
511
- client.table("notifications").insert(rows).execute()
512
- except Exception as e:
513
- logger.error("save_notification failed: %s", str(e), exc_info=e)
514
-
515
- # ============================================================================
516
- # 상태 변경
517
- # 설명: 실패 작업 상태 업데이트
518
- # ============================================================================
519
-
252
+ # ------------------------------ Failure Status ------------------------------
520
253
  async def update_task_error(todo_id: str) -> None:
521
- """실패 작업의 상태를 FAILED로 갱신한다."""
522
254
  if not todo_id:
523
255
  return
256
+
524
257
  def _call():
525
258
  client = get_db_client()
526
- return (
527
- client
528
- .table('todolist')
529
- .update({'draft_status': 'FAILED', 'consumer': None})
530
- .eq('id', todo_id)
531
- .execute()
532
- )
259
+ return client.table("todolist").update({"draft_status": "FAILED", "consumer": None}).eq("id", todo_id).execute()
533
260
 
534
- try:
535
- await _async_retry(_call, name="update_task_error", fallback=lambda: None)
536
- except Exception as e:
537
- logger.error("update_task_error fatal: %s", str(e), exc_info=e)
261
+ res = await _async_retry(_call, name="update_task_error", fallback=lambda: None)
262
+ if res is None:
263
+ logger.error("❌ update_task_error failed todo_id=%s", todo_id)
264
+ else:
265
+ logger.info("update_task_error ok todo_id=%s", todo_id)