diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt
index 763de9b..00a63d5 100644
--- a/TODO_NEXT_STEPS.txt
+++ b/TODO_NEXT_STEPS.txt
@@ -4,6 +4,7 @@
================================================================================
【已完成】
+ - V10.218 補 `/observability/ppt_audit_history` AiderHeal 去重鎖:同一份簡報已在背景修復時,再次點擊會回「已在執行中」,避免重複開 SSH / 模型 / git 修復流程。
- V10.217 讓 `/observability/ppt_audit_history` 的 AiderHeal 派工改為非阻塞背景任務:頁面立即回「已排入」,修復工作在背景執行,避免瀏覽器與 Gunicorn worker 等 SSH、模型與 git push 到超時。
- V10.216 修正 `/observability/ppt_audit_history` 的 AiderHeal 派工斷點:失敗簡報即使只有 `issues_found` 診斷摘要也能一鍵送修,並修正 `execute_code_fix` 參數與 dict 回傳解析,避免按鈕 400/500。
- V10.215 強化 `/observability/ppt_audit_history` 視覺問題追蹤:將 `issues_found` 拆成投影片、問題類型、問題文字與回放入口,新增「視覺問題追蹤」面板,讓問題簡報能直接定位與預覽。
diff --git a/config.py b/config.py
index 48b7a00..2f2c4b8 100644
--- a/config.py
+++ b/config.py
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.217"
+SYSTEM_VERSION = "V10.218"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示
diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py
index a212854..4e52f82 100644
--- a/routes/admin_observability_routes.py
+++ b/routes/admin_observability_routes.py
@@ -35,6 +35,9 @@ admin_observability_bp = Blueprint(
url_prefix='/observability',
)
+_PPT_AIDER_HEAL_LOCK = threading.Lock()
+_PPT_AIDER_HEAL_ACTIVE = set()
+
# ─────────────────────────────────────────────────────────────────────────────
# /observability/overview — Phase 45 總覽(單頁聚合 6 項 KPI)
@@ -1835,6 +1838,17 @@ def ppt_audit_trigger_aider_heal():
'triggered_by': 'admin_observability',
'issue_summary': issue_summary[:500],
}
+ heal_key = pptx_filename or diagnosis[:160] or 'manual'
+ with _PPT_AIDER_HEAL_LOCK:
+ if heal_key in _PPT_AIDER_HEAL_ACTIVE:
+ return jsonify({
+ 'ok': True,
+ 'status': 'already_running',
+ 'action': 'CODE_FIX',
+ 'message': '這份簡報的 AiderHeal 已在背景執行中,請等 Telegram/Gitea/CD 結果回報。',
+ 'target_file': 'services/ppt_generator.py',
+ }), 202
+ _PPT_AIDER_HEAL_ACTIVE.add(heal_key)
def _heal_worker():
try:
@@ -1855,6 +1869,9 @@ def ppt_audit_trigger_aider_heal():
"[PPTAudit] AiderHeal 背景任務失敗 | file=%s",
pptx_filename or '-',
)
+ finally:
+ with _PPT_AIDER_HEAL_LOCK:
+ _PPT_AIDER_HEAL_ACTIVE.discard(heal_key)
thread_key = ''.join(ch for ch in pptx_filename if ch.isalnum())[:24] or 'manual'
threading.Thread(
diff --git a/tests/test_admin_observability_routes.py b/tests/test_admin_observability_routes.py
index 9de65f4..3b9a29d 100644
--- a/tests/test_admin_observability_routes.py
+++ b/tests/test_admin_observability_routes.py
@@ -565,6 +565,51 @@ def test_ppt_audit_trigger_aider_heal_accepts_issue_summary(client, monkeypatch)
assert captured['context']['issue_summary'] == 'S1: 圖表被切掉:右側圖例超出邊界'
+def test_ppt_audit_trigger_aider_heal_dedupes_same_file(client, monkeypatch):
+ """同一份 PPT 已在背景修復時,重複按鈕不應重開第二條 AiderHeal。"""
+ from routes import admin_observability_routes as mod
+ from services import aider_heal_executor as svc
+
+ calls = []
+
+ def fake_execute_code_fix(**kwargs):
+ calls.append(kwargs)
+ return {
+ 'success': True,
+ 'action': 'CODE_FIX',
+ 'message': '不應在此測試執行',
+ 'commit_sha': None,
+ 'reverted': False,
+ }
+
+ class HoldingThread:
+ def __init__(self, target, **_kwargs):
+ self.target = target
+
+ def start(self):
+ return None
+
+ monkeypatch.setattr(svc, 'execute_code_fix', fake_execute_code_fix)
+ monkeypatch.setattr(mod.threading, 'Thread', HoldingThread)
+ mod._PPT_AIDER_HEAL_ACTIVE.clear()
+
+ payload = {
+ 'pptx_filename': 'ocbot_daily_20260517.pptx',
+ 'issue_summary': 'S1: 圖表被切掉:右側圖例超出邊界',
+ }
+ first = client.post('/observability/ppt_audit/trigger_aider_heal', json=payload)
+ second = client.post('/observability/ppt_audit/trigger_aider_heal', json=payload)
+
+ try:
+ assert first.status_code == 202
+ assert first.get_json()['status'] == 'queued'
+ assert second.status_code == 202
+ assert second.get_json()['status'] == 'already_running'
+ assert calls == []
+ finally:
+ mod._PPT_AIDER_HEAL_ACTIVE.clear()
+
+
# ──────────────────────────────────────────────────────────────────────────
# /observability/host_health
# ──────────────────────────────────────────────────────────────────────────
diff --git a/web/static/js/observability-charts.js b/web/static/js/observability-charts.js
index 6ad8b9b..ba16cdb 100644
--- a/web/static/js/observability-charts.js
+++ b/web/static/js/observability-charts.js
@@ -596,7 +596,9 @@
throw new Error(data.error || data.message || '觸發失敗');
}
if (triggerButton) {
- triggerButton.innerHTML = '已排入';
+ triggerButton.innerHTML = data.status === 'already_running'
+ ? '執行中'
+ : '已排入';
}
if (statusNode) {
statusNode.classList.remove('is-working');