mirror of
https://github.com/myronblair/jarvis
synced 2026-07-27 16:22:55 -05:00
Fix silent compose failures: poll actual job status in the compose modal instead of a fire-and-forget dispatch toast; add Groq 429 retry-with-backoff using its own rate-limit-reset header (12k tokens/min tier gets exhausted by gmail_triage alone)
This commit is contained in:
+30
-7
@@ -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','')}")
|
raise RuntimeError(f"Claude API error {resp.status}: {data.get('error',{}).get('message','')}")
|
||||||
return data["content"][0]["text"]
|
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:
|
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
|
all_msgs = ([{"role": "system", "content": system}] if system else []) + messages
|
||||||
payload = {"model": GROQ_MODEL, "messages": all_msgs, "max_tokens": 4096}
|
payload = {"model": GROQ_MODEL, "messages": all_msgs, "max_tokens": 4096}
|
||||||
headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}
|
||||||
async with aiohttp.ClientSession() as session:
|
for attempt in range(2):
|
||||||
async with session.post("https://api.groq.com/openai/v1/chat/completions", json=payload,
|
async with aiohttp.ClientSession() as session:
|
||||||
headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as resp:
|
async with session.post("https://api.groq.com/openai/v1/chat/completions", json=payload,
|
||||||
data = await resp.json()
|
headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as resp:
|
||||||
if resp.status != 200:
|
if resp.status == 429 and attempt == 0:
|
||||||
raise RuntimeError(f"Groq error {resp.status}")
|
wait = min(_parse_groq_reset(resp.headers.get("x-ratelimit-reset-tokens", "")) or
|
||||||
return data["choices"][0]["message"]["content"]
|
_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:
|
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)
|
prompt = (system + "\n\n" if system else "") + "\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages)
|
||||||
|
|||||||
@@ -4458,6 +4458,7 @@ function outboxCompose() {
|
|||||||
<input id="oc-to" class="inp" placeholder="To: email address" type="email">
|
<input id="oc-to" class="inp" placeholder="To: email address" type="email">
|
||||||
<input id="oc-subject" class="inp" placeholder="Subject">
|
<input id="oc-subject" class="inp" placeholder="Subject">
|
||||||
<textarea id="oc-body" class="inp" rows="5" style="resize:vertical" placeholder="Describe what to say — AI will draft the full message"></textarea>
|
<textarea id="oc-body" class="inp" rows="5" style="resize:vertical" placeholder="Describe what to say — AI will draft the full message"></textarea>
|
||||||
|
<div id="oc-status" style="display:none;font-size:0.65rem;color:var(--text-dim);padding:8px;background:#060a0e;border:1px solid var(--border);border-radius:3px"></div>
|
||||||
</div>
|
</div>
|
||||||
`, async () => {
|
`, async () => {
|
||||||
const to = document.getElementById('oc-to')?.value.trim();
|
const to = document.getElementById('oc-to')?.value.trim();
|
||||||
@@ -4465,14 +4466,39 @@ function outboxCompose() {
|
|||||||
const body = document.getElementById('oc-body')?.value.trim();
|
const body = document.getElementById('oc-body')?.value.trim();
|
||||||
const account = document.getElementById('oc-account')?.value;
|
const account = document.getElementById('oc-account')?.value;
|
||||||
if (!to || !body) { toast('Please fill To and message description', 'err'); return; }
|
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});
|
const d = await api('compose_email', {to, subject, body, account});
|
||||||
if (d.job_id) {
|
if (!d.job_id) { toast('Failed: ' + (d.error||'Arc Reactor offline'), 'err'); return; }
|
||||||
toast('Compose job dispatched — Job #' + d.job_id, 'ok');
|
// Fixed 2026-07-07: this used to toast "dispatched" and close immediately, with
|
||||||
closeModal();
|
// no way to ever find out if the job later failed — it just silently vanished.
|
||||||
setTimeout(loadOutbox, 5000);
|
// Now polls the actual job status until it finishes, so failures are visible.
|
||||||
} else {
|
if (statusEl) { statusEl.style.display = 'block'; statusEl.textContent = 'Drafting — Job #' + d.job_id + '...'; }
|
||||||
toast('Failed: ' + (d.error||'Arc Reactor offline'), 'err');
|
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');
|
}, 'DISPATCH');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user