programgarden 1.29.3__tar.gz → 1.29.4__tar.gz

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 (37) hide show
  1. {programgarden-1.29.3 → programgarden-1.29.4}/PKG-INFO +1 -1
  2. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/executor.py +142 -85
  3. {programgarden-1.29.3 → programgarden-1.29.4}/pyproject.toml +1 -1
  4. {programgarden-1.29.3 → programgarden-1.29.4}/README.md +0 -0
  5. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/__init__.py +0 -0
  6. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/binding_validator.py +0 -0
  7. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/client.py +0 -0
  8. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/code_worker.py +0 -0
  9. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/context.py +0 -0
  10. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/database/__init__.py +0 -0
  11. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/database/checkpoint_manager.py +0 -0
  12. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/database/query_builder.py +0 -0
  13. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/database/workflow_position_tracker.py +0 -0
  14. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/database/workflow_risk_tracker.py +0 -0
  15. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/deep_fixtures.py +0 -0
  16. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/node_runner.py +0 -0
  17. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/plugin/__init__.py +0 -0
  18. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/plugin/sandbox.py +0 -0
  19. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/providers/__init__.py +0 -0
  20. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/providers/llm_errors.py +0 -0
  21. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/providers/llm_provider.py +0 -0
  22. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/reconnect_handler.py +0 -0
  23. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/resolver.py +0 -0
  24. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.29.3 → programgarden-1.29.4}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.29.3
3
+ Version: 1.29.4
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -15,6 +15,7 @@ import ast
15
15
  import re
16
16
  import uuid
17
17
  import logging
18
+ import sys
18
19
 
19
20
  from programgarden.resolver import WorkflowResolver, ResolvedWorkflow, ValidationResult
20
21
  from programgarden_core import (
@@ -42,6 +43,44 @@ from programgarden_core.nodes.base import BaseMessagingNode
42
43
  logger = logging.getLogger("programgarden.executor")
43
44
 
44
45
 
46
+ def _safe_print(*args: Any, **kwargs: Any) -> None:
47
+ """Emit console output without ever letting it abort the caller.
48
+
49
+ Console output is a side effect — a workflow must never die because a log
50
+ line could not be rendered. On a console whose encoding cannot represent
51
+ the text (cp949 on Korean Windows is the case we hit), a bare ``print``
52
+ raises ``UnicodeEncodeError``. Two things make that fatal rather than
53
+ cosmetic:
54
+
55
+ 1. The progress line at the head of ``_execute_main_flow`` runs before any
56
+ node does, so the whole job dies before it starts.
57
+ 2. The same call pattern sits inside ``except`` blocks, so the *reporting*
58
+ path raises too and the original cause is swallowed — the failure never
59
+ reaches ``notify_job_state`` and vanishes silently.
60
+
61
+ Note that ASCII-only literals are not sufficient protection: interpolated
62
+ values (node names, exception messages) can themselves carry characters the
63
+ console cannot encode. So every print in this module goes through here.
64
+ """
65
+ try:
66
+ print(*args, **kwargs)
67
+ except UnicodeEncodeError:
68
+ # Re-render through the stream's own encoding so the line still lands,
69
+ # with unrepresentable characters replaced rather than the job lost.
70
+ try:
71
+ stream = kwargs.get("file") or sys.stdout
72
+ encoding = getattr(stream, "encoding", None) or "ascii"
73
+ sep = kwargs.get("sep", " ")
74
+ end = kwargs.get("end", "\n")
75
+ text = sep.join(str(a) for a in args) + end
76
+ stream.write(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
77
+ except Exception:
78
+ logger.debug("console output dropped", exc_info=True)
79
+ except Exception:
80
+ # Logging is never load-bearing.
81
+ logger.debug("console output dropped", exc_info=True)
82
+
83
+
45
84
  # lib reserved iteration variable roots — valid ONLY mid-iteration.
46
85
  # `{{ item.* }}` (auto-iterate per-item) and `{{ row.* }}` (ConditionNode
47
86
  # items.extract) only resolve while the executor has an iteration context set;
@@ -8396,8 +8435,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
8396
8435
 
8397
8436
  if chart_type == "summary":
8398
8437
  # Raw JSON 표시
8399
- print(f"\n📋 {title or 'Data Summary'} [{now}]")
8400
- print("=" * 60)
8438
+ _safe_print(f"\n📋 {title or 'Data Summary'} [{now}]")
8439
+ _safe_print("=" * 60)
8401
8440
  try:
8402
8441
  def serialize(obj):
8403
8442
  if hasattr(obj, 'model_dump'):
@@ -8411,11 +8450,11 @@ class DisplayNodeExecutor(NodeExecutorBase):
8411
8450
  return obj
8412
8451
 
8413
8452
  serialized = serialize(data)
8414
- print(json.dumps(serialized, indent=2, ensure_ascii=False, default=str))
8453
+ _safe_print(json.dumps(serialized, indent=2, ensure_ascii=False, default=str))
8415
8454
  except Exception as e:
8416
- print(f"(직렬화 실패: {e})")
8417
- print(str(data)[:500])
8418
- print("=" * 60 + "\n")
8455
+ _safe_print(f"(직렬화 실패: {e})")
8456
+ _safe_print(str(data)[:500])
8457
+ _safe_print("=" * 60 + "\n")
8419
8458
 
8420
8459
  output_data["data"] = data
8421
8460
 
@@ -8426,8 +8465,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
8426
8465
  sort_by = config.get("sort_by")
8427
8466
  sort_order = config.get("sort_order", "desc")
8428
8467
 
8429
- print(f"\n📊 {title or 'Table'} [{now}]")
8430
- print("=" * 80)
8468
+ _safe_print(f"\n📊 {title or 'Table'} [{now}]")
8469
+ _safe_print("=" * 80)
8431
8470
 
8432
8471
  # 데이터 형식 판단
8433
8472
  if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict):
@@ -8441,13 +8480,13 @@ class DisplayNodeExecutor(NodeExecutorBase):
8441
8480
  (k, k) for k in list(rows[0].keys())[:8]
8442
8481
  ]
8443
8482
  header = " | ".join(f"{label:<12}" for _, label in cols)
8444
- print(header)
8445
- print("-" * 80)
8483
+ _safe_print(header)
8484
+ _safe_print("-" * 80)
8446
8485
  for row in rows:
8447
8486
  values = " | ".join(
8448
8487
  f"{self._format_value(row.get(key)):<12}" for key, _ in cols
8449
8488
  )
8450
- print(values)
8489
+ _safe_print(values)
8451
8490
 
8452
8491
  elif isinstance(data, dict):
8453
8492
  # dict of dicts (symbol: {field: value}) 또는 flat dict
@@ -8463,19 +8502,19 @@ class DisplayNodeExecutor(NodeExecutorBase):
8463
8502
  (k, k) for k in list(items[0][1].keys())[:6]
8464
8503
  ]
8465
8504
  header = f"{'Key':<12} | " + " | ".join(f"{label:<12}" for _, label in cols)
8466
- print(header)
8467
- print("-" * 80)
8505
+ _safe_print(header)
8506
+ _safe_print("-" * 80)
8468
8507
  for key, val in items:
8469
8508
  values = f"{key:<12} | " + " | ".join(
8470
8509
  f"{self._format_value(val.get(col_key)):<12}" for col_key, _ in cols
8471
8510
  )
8472
- print(values)
8511
+ _safe_print(values)
8473
8512
  else:
8474
8513
  # flat dict
8475
8514
  for k, v in list(data.items())[:limit]:
8476
- print(f" {k:<25}: {self._format_value(v)}")
8515
+ _safe_print(f" {k:<25}: {self._format_value(v)}")
8477
8516
 
8478
- print("=" * 80 + "\n")
8517
+ _safe_print("=" * 80 + "\n")
8479
8518
  output_data["data"] = data
8480
8519
 
8481
8520
  elif chart_type == "line":
@@ -8489,36 +8528,36 @@ class DisplayNodeExecutor(NodeExecutorBase):
8489
8528
  context.log("error", f"line 차트에 x_field, y_field 필수", node_id)
8490
8529
  return {"rendered": False, "error": "x_field, y_field 필수"}
8491
8530
 
8492
- print(f"\n📈 {title or 'Line Chart'} [{now}]")
8493
- print("=" * 60)
8531
+ _safe_print(f"\n📈 {title or 'Line Chart'} [{now}]")
8532
+ _safe_print("=" * 60)
8494
8533
 
8495
8534
  if isinstance(data, list) and len(data) > 0:
8496
8535
  first = data[0]
8497
8536
  last = data[-1]
8498
- print(f"📊 데이터 포인트: {len(data)}개")
8499
- print(f"📅 X축 범위: {first.get(x_field)} ~ {last.get(x_field)}")
8500
- print(f"📈 Y축({y_field}) 시작: {self._format_value(first.get(y_field))}")
8501
- print(f"📈 Y축({y_field}) 끝: {self._format_value(last.get(y_field))}")
8537
+ _safe_print(f"📊 데이터 포인트: {len(data)}개")
8538
+ _safe_print(f"📅 X축 범위: {first.get(x_field)} ~ {last.get(x_field)}")
8539
+ _safe_print(f"📈 Y축({y_field}) 시작: {self._format_value(first.get(y_field))}")
8540
+ _safe_print(f"📈 Y축({y_field}) 끝: {self._format_value(last.get(y_field))}")
8502
8541
 
8503
8542
  # 최대/최소
8504
8543
  values = [d.get(y_field, 0) for d in data if d.get(y_field) is not None]
8505
8544
  if values:
8506
- print(f"📈 최대: {self._format_value(max(values))}")
8507
- print(f"📉 최소: {self._format_value(min(values))}")
8545
+ _safe_print(f"📈 최대: {self._format_value(max(values))}")
8546
+ _safe_print(f"📉 최소: {self._format_value(min(values))}")
8508
8547
 
8509
8548
  # 시그널 마커 표시
8510
8549
  if signal_field:
8511
8550
  signals = self._extract_signals(data, x_field, signal_field, side_field)
8512
8551
  if signals:
8513
- print(f"\n🎯 시그널 ({len(signals)}개):")
8552
+ _safe_print(f"\n🎯 시그널 ({len(signals)}개):")
8514
8553
  for sig in signals[:10]: # 최대 10개만 표시
8515
- print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8554
+ _safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8516
8555
  if len(signals) > 10:
8517
- print(f" ... 외 {len(signals) - 10}개")
8556
+ _safe_print(f" ... 외 {len(signals) - 10}개")
8518
8557
  else:
8519
- print("(데이터 없음)")
8558
+ _safe_print("(데이터 없음)")
8520
8559
 
8521
- print("=" * 60 + "\n")
8560
+ _safe_print("=" * 60 + "\n")
8522
8561
  output_data["data"] = data
8523
8562
  output_data["x_field"] = x_field
8524
8563
  output_data["y_field"] = y_field
@@ -8542,8 +8581,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
8542
8581
  context.log("error", f"multi_line 차트에 x_field, y_field, series_key 필수", node_id)
8543
8582
  return {"rendered": False, "error": "x_field, y_field, series_key 필수"}
8544
8583
 
8545
- print(f"\n📈 {title or 'Multi-Line Chart'} [{now}]")
8546
- print("=" * 80)
8584
+ _safe_print(f"\n📈 {title or 'Multi-Line Chart'} [{now}]")
8585
+ _safe_print("=" * 80)
8547
8586
 
8548
8587
  # data가 평탄화된 배열 형식인 경우 [{symbol, date, value, ...}, ...]
8549
8588
  if isinstance(data, list):
@@ -8568,29 +8607,29 @@ class DisplayNodeExecutor(NodeExecutorBase):
8568
8607
 
8569
8608
  series_items = series_items[:limit]
8570
8609
 
8571
- print(f"📊 시리즈 수: {len(series_items)}개 (limit: {limit})")
8572
- print("-" * 80)
8573
- print(f"{'Series':<12} | {'Points':<8} | {f'First {y_field}':<15} | {f'Last {y_field}':<15}")
8574
- print("-" * 80)
8610
+ _safe_print(f"📊 시리즈 수: {len(series_items)}개 (limit: {limit})")
8611
+ _safe_print("-" * 80)
8612
+ _safe_print(f"{'Series':<12} | {'Points':<8} | {f'First {y_field}':<15} | {f'Last {y_field}':<15}")
8613
+ _safe_print("-" * 80)
8575
8614
 
8576
8615
  for symbol, rows in series_items:
8577
8616
  if isinstance(rows, list) and len(rows) > 0:
8578
8617
  first_val = rows[0].get(y_field, 0)
8579
8618
  last_val = rows[-1].get(y_field, 0)
8580
- print(f"{symbol:<12} | {len(rows):<8} | {self._format_value(first_val):<15} | {self._format_value(last_val):<15}")
8619
+ _safe_print(f"{symbol:<12} | {len(rows):<8} | {self._format_value(first_val):<15} | {self._format_value(last_val):<15}")
8581
8620
 
8582
8621
  # 시그널 마커 표시
8583
8622
  if signal_field:
8584
8623
  signals = self._extract_signals(data, x_field, signal_field, side_field, series_key)
8585
8624
  if signals:
8586
- print(f"\n🎯 시그널 ({len(signals)}개):")
8625
+ _safe_print(f"\n🎯 시그널 ({len(signals)}개):")
8587
8626
  for sig in signals[:15]: # 최대 15개만 표시
8588
8627
  series_info = f" [{sig.get('series', '')}]" if sig.get('series') else ""
8589
- print(f" {sig['marker']} {sig['x']}{series_info} ({sig['signal']}/{sig['side']})")
8628
+ _safe_print(f" {sig['marker']} {sig['x']}{series_info} ({sig['signal']}/{sig['side']})")
8590
8629
  if len(signals) > 15:
8591
- print(f" ... 외 {len(signals) - 15}개")
8630
+ _safe_print(f" ... 외 {len(signals) - 15}개")
8592
8631
 
8593
- print("=" * 80 + "\n")
8632
+ _safe_print("=" * 80 + "\n")
8594
8633
  output_data["data"] = data
8595
8634
  output_data["x_field"] = x_field
8596
8635
  output_data["y_field"] = y_field
@@ -8616,16 +8655,16 @@ class DisplayNodeExecutor(NodeExecutorBase):
8616
8655
  context.log("error", f"candlestick 차트에 date_field, open_field, high_field, low_field, close_field 필수", node_id)
8617
8656
  return {"rendered": False, "error": "OHLC 필드 필수"}
8618
8657
 
8619
- print(f"\n📊 {title or 'Candlestick Chart'} [{now}]")
8620
- print("=" * 80)
8658
+ _safe_print(f"\n📊 {title or 'Candlestick Chart'} [{now}]")
8659
+ _safe_print("=" * 80)
8621
8660
 
8622
8661
  if isinstance(data, list) and len(data) > 0:
8623
8662
  first = data[0]
8624
8663
  last = data[-1]
8625
- print(f"📊 캔들 수: {len(data)}개")
8626
- print(f"📅 기간: {first.get(date_field)} ~ {last.get(date_field)}")
8627
- print(f"💵 시작 종가: {self._format_value(first.get(close_field))}")
8628
- print(f"💵 최종 종가: {self._format_value(last.get(close_field))}")
8664
+ _safe_print(f"📊 캔들 수: {len(data)}개")
8665
+ _safe_print(f"📅 기간: {first.get(date_field)} ~ {last.get(date_field)}")
8666
+ _safe_print(f"💵 시작 종가: {self._format_value(first.get(close_field))}")
8667
+ _safe_print(f"💵 최종 종가: {self._format_value(last.get(close_field))}")
8629
8668
 
8630
8669
  # 수익률
8631
8670
  start_price = first.get(close_field, 0)
@@ -8634,21 +8673,21 @@ class DisplayNodeExecutor(NodeExecutorBase):
8634
8673
  pct_change = (end_price - start_price) / start_price * 100
8635
8674
  sign = "+" if pct_change >= 0 else ""
8636
8675
  color = "\033[92m" if pct_change >= 0 else "\033[91m"
8637
- print(f"📈 변동률: {color}{sign}{pct_change:.2f}%\033[0m")
8676
+ _safe_print(f"📈 변동률: {color}{sign}{pct_change:.2f}%\033[0m")
8638
8677
 
8639
8678
  # 시그널 마커 표시
8640
8679
  if signal_field:
8641
8680
  signals = self._extract_signals(data, date_field, signal_field, side_field)
8642
8681
  if signals:
8643
- print(f"\n🎯 시그널 ({len(signals)}개):")
8682
+ _safe_print(f"\n🎯 시그널 ({len(signals)}개):")
8644
8683
  for sig in signals[:10]: # 최대 10개만 표시
8645
- print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8684
+ _safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8646
8685
  if len(signals) > 10:
8647
- print(f" ... 외 {len(signals) - 10}개")
8686
+ _safe_print(f" ... 외 {len(signals) - 10}개")
8648
8687
  else:
8649
- print("(데이터 없음)")
8688
+ _safe_print("(데이터 없음)")
8650
8689
 
8651
- print("=" * 80 + "\n")
8690
+ _safe_print("=" * 80 + "\n")
8652
8691
  output_data["data"] = data
8653
8692
  output_data["date_field"] = date_field
8654
8693
  output_data["open_field"] = open_field
@@ -8671,23 +8710,23 @@ class DisplayNodeExecutor(NodeExecutorBase):
8671
8710
  context.log("error", f"bar 차트에 x_field, y_field 필수", node_id)
8672
8711
  return {"rendered": False, "error": "x_field, y_field 필수"}
8673
8712
 
8674
- print(f"\n📊 {title or 'Bar Chart'} [{now}]")
8675
- print("=" * 60)
8713
+ _safe_print(f"\n📊 {title or 'Bar Chart'} [{now}]")
8714
+ _safe_print("=" * 60)
8676
8715
 
8677
8716
  if isinstance(data, list) and len(data) > 0:
8678
- print(f"{'Bar':<20} | {y_field}")
8679
- print("-" * 60)
8717
+ _safe_print(f"{'Bar':<20} | {y_field}")
8718
+ _safe_print("-" * 60)
8680
8719
  for item in data[:20]:
8681
8720
  x_val = item.get(x_field, "")
8682
8721
  y_val = item.get(y_field, 0)
8683
- print(f"{str(x_val):<20} | {self._format_value(y_val)}")
8722
+ _safe_print(f"{str(x_val):<20} | {self._format_value(y_val)}")
8684
8723
  elif isinstance(data, dict):
8685
- print(f"{'Key':<20} | {'Value'}")
8686
- print("-" * 60)
8724
+ _safe_print(f"{'Key':<20} | {'Value'}")
8725
+ _safe_print("-" * 60)
8687
8726
  for k, v in list(data.items())[:20]:
8688
- print(f"{str(k):<20} | {self._format_value(v)}")
8727
+ _safe_print(f"{str(k):<20} | {self._format_value(v)}")
8689
8728
 
8690
- print("=" * 60 + "\n")
8729
+ _safe_print("=" * 60 + "\n")
8691
8730
  output_data["data"] = data
8692
8731
  output_data["x_field"] = x_field
8693
8732
  output_data["y_field"] = y_field
@@ -18203,7 +18242,7 @@ class WorkflowJob:
18203
18242
  except asyncio.CancelledError:
18204
18243
  self.status = "cancelled"
18205
18244
  logger.info(f"Job {self.job_id} cancelled")
18206
- print(f"⚠️ Job {self.job_id} cancelled")
18245
+ _safe_print(f"[CANCELLED] Job {self.job_id} cancelled")
18207
18246
  await self.context.notify_job_state("cancelled", self.stats)
18208
18247
  except Exception as e:
18209
18248
  self.status = "failed"
@@ -18220,9 +18259,17 @@ class WorkflowJob:
18220
18259
  }
18221
18260
  self.context.log("error", error_msg)
18222
18261
  logger.exception(f"Job {self.job_id} failed: {e}")
18223
- print(f"❌ Job {self.job_id} failed: {e}")
18262
+ # ASCII-only marker: this is the failure-reporting path, so it must
18263
+ # survive a console that could not render the text that failed.
18264
+ _safe_print(f"[FAILED] Job {self.job_id} failed: {e}")
18224
18265
  import traceback
18225
- traceback.print_exc()
18266
+ try:
18267
+ # print_exc renders the offending source line, which may itself
18268
+ # hold the character that could not be encoded — so it can raise
18269
+ # the very error it is reporting.
18270
+ traceback.print_exc()
18271
+ except Exception:
18272
+ logger.debug("traceback output dropped", exc_info=True)
18226
18273
  await self.context.notify_job_state("failed", self.stats)
18227
18274
 
18228
18275
  # WORKFLOW_FAILED notification
@@ -18296,7 +18343,7 @@ class WorkflowJob:
18296
18343
  skip_nodes: 복구 모드에서 이미 완료된 노드 ID 집합 (스킵)
18297
18344
  """
18298
18345
  skip_nodes = skip_nodes or set()
18299
- print(f"🔄 Executing main flow: {self.workflow.execution_order}")
18346
+ _safe_print(f"🔄 Executing main flow: {self.workflow.execution_order}")
18300
18347
 
18301
18348
  # === Item-based execution setup ===
18302
18349
  # Find Split/Aggregate pairs and their branch nodes
@@ -18336,7 +18383,7 @@ class WorkflowJob:
18336
18383
 
18337
18384
  # === Checkpoint restore: 이미 완료된 노드 스킵 ===
18338
18385
  if node_id in skip_nodes:
18339
- print(f" ⏭ Skipping restored node: {node_id} ({node.node_type})")
18386
+ _safe_print(f" ⏭ Skipping restored node: {node_id} ({node.node_type})")
18340
18387
  self._completed_node_ids.add(node_id)
18341
18388
  await self.context.notify_node_state(
18342
18389
  node_id=node_id,
@@ -18347,7 +18394,7 @@ class WorkflowJob:
18347
18394
 
18348
18395
  # === Item-based execution: Skip branch nodes (handled by SplitNode) ===
18349
18396
  if node_id in branch_nodes and node.node_type not in ("SplitNode", "AggregateNode"):
18350
- print(f" ⏭ Skipping branch node: {node_id} (handled by SplitNode)")
18397
+ _safe_print(f" ⏭ Skipping branch node: {node_id} (handled by SplitNode)")
18351
18398
  continue
18352
18399
 
18353
18400
  # === Item-based execution: Handle SplitNode specially ===
@@ -18358,12 +18405,12 @@ class WorkflowJob:
18358
18405
 
18359
18406
  # === Item-based execution: Skip AggregateNode (already executed by SplitNode) ===
18360
18407
  if node.node_type == "AggregateNode" and node_id in split_aggregate_pairs.values():
18361
- print(f" ⏭ Skipping AggregateNode: {node_id} (already executed by SplitNode)")
18408
+ _safe_print(f" ⏭ Skipping AggregateNode: {node_id} (already executed by SplitNode)")
18362
18409
  continue
18363
18410
 
18364
18411
  # === IfNode: 비활성 브랜치 스킵 ===
18365
18412
  if node_id in if_skipped_nodes:
18366
- print(f" ⏭ Skipping node: {node_id} (IfNode branch not taken)")
18413
+ _safe_print(f" ⏭ Skipping node: {node_id} (IfNode branch not taken)")
18367
18414
  await self.context.notify_node_state(
18368
18415
  node_id=node_id,
18369
18416
  node_type=node.node_type,
@@ -18371,7 +18418,7 @@ class WorkflowJob:
18371
18418
  )
18372
18419
  continue
18373
18420
 
18374
- print(f" ▶ Executing node: {node_id} ({node.node_type})")
18421
+ _safe_print(f" ▶ Executing node: {node_id} ({node.node_type})")
18375
18422
 
18376
18423
  # 🆕 노드 실행 시작 알림
18377
18424
  start_time = datetime.utcnow()
@@ -18542,7 +18589,7 @@ class WorkflowJob:
18542
18589
  new_skips = self._compute_if_skip_nodes(node_id, taken)
18543
18590
  if_skipped_nodes.update(new_skips)
18544
18591
  if new_skips:
18545
- print(f" 🔀 IfNode {node_id}: branch={taken}, skipping {new_skips}")
18592
+ _safe_print(f" 🔀 IfNode {node_id}: branch={taken}, skipping {new_skips}")
18546
18593
 
18547
18594
  # deep_validate: a node that *returns* a sole-`error` dict (rather
18548
18595
  # than raising) would otherwise be stored as a COMPLETED output and
@@ -18843,7 +18890,7 @@ class WorkflowJob:
18843
18890
  all_results = []
18844
18891
  total = len(items)
18845
18892
 
18846
- print(f" 🔄 Auto-iterate: {node_id} ({node.node_type}) - {total} items")
18893
+ _safe_print(f" 🔄 Auto-iterate: {node_id} ({node.node_type}) - {total} items")
18847
18894
 
18848
18895
  for idx, current_item in enumerate(items):
18849
18896
  # === item, index, total을 ExecutionContext에 설정 ===
@@ -18854,7 +18901,7 @@ class WorkflowJob:
18854
18901
 
18855
18902
  # 진행 상황 로그
18856
18903
  item_label = current_item.get("symbol", str(current_item)) if isinstance(current_item, dict) else str(current_item)
18857
- print(f" [{idx+1}/{total}] Processing: {item_label}")
18904
+ _safe_print(f" [{idx+1}/{total}] Processing: {item_label}")
18858
18905
  self.context.log("debug", f"Auto-iterate [{idx+1}/{total}]: {item_label}", node_id)
18859
18906
 
18860
18907
  # A-3: per-item spacing for order / external-API nodes
@@ -18887,7 +18934,7 @@ class WorkflowJob:
18887
18934
 
18888
18935
  # 결과 병합: 배열 필드는 병합, 단일 필드는 마지막 값
18889
18936
  merged = self._merge_iterate_results(all_results)
18890
- print(f" ✅ Auto-iterate complete: {len(all_results)} results merged")
18937
+ _safe_print(f" ✅ Auto-iterate complete: {len(all_results)} results merged")
18891
18938
 
18892
18939
  return merged
18893
18940
 
@@ -19081,7 +19128,7 @@ class WorkflowJob:
19081
19128
  """
19082
19129
  from programgarden_core.bases.listener import NodeState
19083
19130
 
19084
- print(f" 🔀 Executing SplitNode branch: {split_id}")
19131
+ _safe_print(f" 🔀 Executing SplitNode branch: {split_id}")
19085
19132
 
19086
19133
  # Get paired AggregateNode
19087
19134
  aggregate_id = split_aggregate_pairs.get(split_id)
@@ -19170,7 +19217,7 @@ class WorkflowJob:
19170
19217
  results_by_index: Dict[int, Any] = {}
19171
19218
  errors: List[str] = []
19172
19219
 
19173
- print(f" 📦 SplitNode: {total} items, parallel={parallel}, delay_ms={delay_ms}")
19220
+ _safe_print(f" 📦 SplitNode: {total} items, parallel={parallel}, delay_ms={delay_ms}")
19174
19221
 
19175
19222
  # === Execute branch for each item ===
19176
19223
  if parallel:
@@ -19417,7 +19464,7 @@ class WorkflowJob:
19417
19464
  if not node:
19418
19465
  return
19419
19466
 
19420
- print(f" 🔗 Executing AggregateNode: {aggregate_id}")
19467
+ _safe_print(f" 🔗 Executing AggregateNode: {aggregate_id}")
19421
19468
 
19422
19469
  start_time = datetime.utcnow()
19423
19470
  await self.context.notify_node_state(
@@ -19884,24 +19931,24 @@ class WorkflowJob:
19884
19931
  Follows topological order to ensure proper data flow.
19885
19932
  """
19886
19933
  from datetime import datetime
19887
- print(f"[{datetime.now().strftime('%H:%M:%S')}] 🔄 실시간 업데이트 이벤트 수신: trigger_nodes={event.trigger_nodes}")
19934
+ _safe_print(f"[{datetime.now().strftime('%H:%M:%S')}] 🔄 실시간 업데이트 이벤트 수신: trigger_nodes={event.trigger_nodes}")
19888
19935
 
19889
19936
  # trigger_nodes가 비어있으면 source_node_id에서 연결된 하위 노드 찾기
19890
19937
  trigger_nodes = event.trigger_nodes
19891
19938
  if not trigger_nodes and event.source_node_id:
19892
19939
  trigger_nodes = self._find_trigger_nodes(event.source_node_id)
19893
- print(f" → 자동 탐색된 trigger_nodes: {trigger_nodes}")
19940
+ _safe_print(f" → 자동 탐색된 trigger_nodes: {trigger_nodes}")
19894
19941
 
19895
19942
  if not trigger_nodes:
19896
- print(f" ⚠️ 트리거할 노드 없음, 스킵")
19943
+ _safe_print(f" ⚠️ 트리거할 노드 없음, 스킵")
19897
19944
  return
19898
19945
 
19899
19946
  logger.debug(f"Realtime update from {event.source_node_id}, triggering: {trigger_nodes}")
19900
- print(f" → {event.source_node_id}에서 트리거: {trigger_nodes}")
19947
+ _safe_print(f" → {event.source_node_id}에서 트리거: {trigger_nodes}")
19901
19948
 
19902
19949
  # 🆕 트리거된 노드들 + 하위 노드들을 모두 찾아서 실행 순서대로 정렬
19903
19950
  nodes_to_execute = self._find_downstream_nodes(trigger_nodes)
19904
- print(f" → 실행할 노드 체인: {nodes_to_execute}")
19951
+ _safe_print(f" → 실행할 노드 체인: {nodes_to_execute}")
19905
19952
 
19906
19953
  # Split→Aggregate 분기는 노드를 하나씩 재실행해서는 절대 채워지지 않는다.
19907
19954
  # AggregateNode 의 유일한 입력인 node_state["_collected_items"] 를 쓰는 곳은
@@ -19918,7 +19965,7 @@ class WorkflowJob:
19918
19965
  split_node = self.workflow.nodes.get(split_id)
19919
19966
  if not split_node:
19920
19967
  continue
19921
- print(f" 🔀 Re-driving split branch: {split_id} → {aggregate_id}")
19968
+ _safe_print(f" 🔀 Re-driving split branch: {split_id} → {aggregate_id}")
19922
19969
  await self._execute_split_branch(split_id, split_node, split_pairs, branch)
19923
19970
  branch_driven |= branch | {split_id, aggregate_id}
19924
19971
 
@@ -19940,7 +19987,7 @@ class WorkflowJob:
19940
19987
 
19941
19988
  # Re-execute the triggered node
19942
19989
  try:
19943
- print(f" ▶ Re-executing: {node_id} ({node.node_type})")
19990
+ _safe_print(f" ▶ Re-executing: {node_id} ({node.node_type})")
19944
19991
 
19945
19992
  # 소스 노드 ID를 config에 추가하여 최신 데이터 참조 가능하게
19946
19993
  config_with_source = dict(node.config)
@@ -20120,7 +20167,17 @@ class WorkflowJob:
20120
20167
  await self._task
20121
20168
  except asyncio.CancelledError:
20122
20169
  pass
20123
-
20170
+
20171
+ # Cleanup persistent trackers / flow-end nodes / broker·JIF 구독.
20172
+ # 여태 stop() 은 이 정리를 빼먹어(cancel() 에만 있었다) 잡이 stop 으로 끝나면
20173
+ # 계좌 추적기의 주기 폴링 루프가 취소되지 않고 무한히 LS 를 계속 조회했다(레이트
20174
+ # 리밋·불필요 호출 유발). cleanup_* 는 끝에서 딕셔너리를 비우므로 _run 의 정상완료
20175
+ # 경로가 먼저 정리했더라도 여기서 다시 불려도 빈 순회 = no-op(멱등).
20176
+ await self._cleanup_broker_fill_subscriptions()
20177
+ await self._cleanup_jif_subscriptions()
20178
+ await self.context.cleanup_persistent_nodes()
20179
+ await self.context.cleanup_flow_end_nodes()
20180
+
20124
20181
  # Cleanup RiskTracker flush
20125
20182
  if self.context.risk_tracker:
20126
20183
  try:
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.29.3"
8
+ version = "1.29.4"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
File without changes