diff --git a/deploy/reactor.py b/deploy/reactor.py index 556b2ad..588abb9 100644 --- a/deploy/reactor.py +++ b/deploy/reactor.py @@ -173,17 +173,40 @@ async def _claude_call(messages: list, system: str = "") -> str: raise RuntimeError(f"Claude API error {resp.status}: {data.get('error',{}).get('message','')}") return data["content"][0]["text"] +def _parse_groq_reset(val: str) -> float: + """Parse Groq's Go-style duration strings ('229ms', '2.5s', '2m52.8s') into seconds.""" + import re as _re + if not val: + return 0.0 + total = 0.0 + for num, unit in _re.findall(r'([\d.]+)(ms|s|m|h)', val): + n = float(num) + total += n/1000 if unit == 'ms' else n*60 if unit == 'm' else n*3600 if unit == 'h' else n + return total + async def _groq_call(messages: list, system: str = "") -> str: + # Added 2026-07-07: retry once on 429 (rate limit) using Groq's own reset-time + # header, capped short — this account's tier has a low 12k-tokens/min ceiling that + # gmail_triage alone can exhaust, so transient contention here is common and often + # clears in well under a second. Capped so a genuinely long reset just falls + # through to Ollama instead of blocking the whole compose flow. all_msgs = ([{"role": "system", "content": system}] if system else []) + messages payload = {"model": GROQ_MODEL, "messages": all_msgs, "max_tokens": 4096} headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"} - async with aiohttp.ClientSession() as session: - async with session.post("https://api.groq.com/openai/v1/chat/completions", json=payload, - headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as resp: - data = await resp.json() - if resp.status != 200: - raise RuntimeError(f"Groq error {resp.status}") - return data["choices"][0]["message"]["content"] + for attempt in range(2): + async with aiohttp.ClientSession() as session: + async with session.post("https://api.groq.com/openai/v1/chat/completions", json=payload, + headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as resp: + if resp.status == 429 and attempt == 0: + wait = min(_parse_groq_reset(resp.headers.get("x-ratelimit-reset-tokens", "")) or + _parse_groq_reset(resp.headers.get("x-ratelimit-reset-requests", "")) or 2.0, 8.0) + log.info(f"[LLM] Groq 429 — retrying in {wait:.1f}s") + await asyncio.sleep(wait) + continue + data = await resp.json() + if resp.status != 200: + raise RuntimeError(f"Groq error {resp.status}") + return data["choices"][0]["message"]["content"] async def _ollama_call(messages: list, system: str = "") -> str: prompt = (system + "\n\n" if system else "") + "\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages) diff --git a/public_html/admin/index.php b/public_html/admin/index.php index 332964f..21f0ab9 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -4458,6 +4458,7 @@ function outboxCompose() { +
`, async () => { const to = document.getElementById('oc-to')?.value.trim(); @@ -4465,14 +4466,39 @@ function outboxCompose() { const body = document.getElementById('oc-body')?.value.trim(); const account = document.getElementById('oc-account')?.value; if (!to || !body) { toast('Please fill To and message description', 'err'); return; } + const statusEl = document.getElementById('oc-status'); const d = await api('compose_email', {to, subject, body, account}); - if (d.job_id) { - toast('Compose job dispatched — Job #' + d.job_id, 'ok'); - closeModal(); - setTimeout(loadOutbox, 5000); - } else { - toast('Failed: ' + (d.error||'Arc Reactor offline'), 'err'); + if (!d.job_id) { toast('Failed: ' + (d.error||'Arc Reactor offline'), 'err'); return; } + // Fixed 2026-07-07: this used to toast "dispatched" and close immediately, with + // no way to ever find out if the job later failed — it just silently vanished. + // Now polls the actual job status until it finishes, so failures are visible. + if (statusEl) { statusEl.style.display = 'block'; statusEl.textContent = 'Drafting — Job #' + d.job_id + '...'; } + document.getElementById('modalSave').disabled = true; + const jobId = d.job_id; + const deadline = Date.now() + 120000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 3000)); + const j = await api('arc_job_get', {id: jobId}).catch(() => null); + if (!j || j.error) continue; + if (j.status === 'done') { + toast('Draft ready: ' + (j.result?.subject || '(no subject)'), 'ok'); + closeModal(); + loadOutbox(); + return; + } else if (j.status === 'failed') { + if (statusEl) { + statusEl.style.color = 'var(--red)'; + statusEl.textContent = '✗ Failed: ' + (j.error || 'unknown error'); + } + document.getElementById('modalSave').disabled = false; + document.getElementById('modalSave').textContent = 'CLOSE'; + document.getElementById('modalSave').onclick = closeModal; + return; + } + if (statusEl) statusEl.textContent = 'Drafting — Job #' + jobId + ' (' + j.status + ')...'; } + if (statusEl) { statusEl.style.color = 'var(--yellow)'; statusEl.textContent = '⚠ Still running in background — check outbox shortly.'; } + document.getElementById('modalSave').disabled = false; }, 'DISPATCH'); }