flow-extension/local_server_example.py
type-two 3cff97df93 fix: circuit-breaker on failure streaks; stop binning rate-limited tasks
Two bugs, both found by autopsy of the djsim_phonewall run (2026-07-08):
15 calendar prompts queued, 2 generated, then THIRTEEN consecutive
"Timed out — no new asset" at a metronomic 3m30s apart. 45 minutes of the
driver politely feeding prompts into a Flow that had stopped generating.

1. NO CIRCUIT BREAKER. Each timeout threw, was reported failed, and the
   loop moved straight on. When Flow goes quiet it does not spontaneously
   start again. Now: 3 consecutive failures -> clear isRunning, log a loud
   system message, leave the queue intact for a hand-resume. (The abuse-flag
   path already had consecutiveRateLimits; failures had nothing.)

2. done_ids() COUNTED 'ratelimited' AS DONE. handleRateLimit() writes that
   status with the explicit comment "report the task back as pending" — and
   the server then binned the task forever. Now excluded. 'failed' is still
   counted: a timeout may have burned credits invisibly, so nothing retries
   automatically.

requeue.py: the deliberate way to retry. --list / --dry / by id or prefix /
--failed. Writes a .bak, never re-runs a success. Used it to make the 13
cal79 prompts pending again.

Note for whoever reads this next: the LIVE server + queue live in
~/Library/Application Support/flowrinse/, not this repo. HERE is derived
from the script's own dir, so running the repo copy silently serves the
stale Jul-6 queue. Copy the script over, run it from there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:57:56 +10:00

190 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Flow Auto-Pilot local queue.
Source of work: queue.jsonl — one task per line: {"prompt": "...", "kind": "video"|"image", "model": "Veo 3.1 - Fast"}
(id optional; auto-assigned by line number)
Output: results.jsonl — one record per finished task: {id, status, assets:[urls], error, ts}
Behaviour:
* /next-task -> hands out the next PENDING task, video-first (rinse the pricey credits first).
* /task-completed -> appends to results.jsonl, marks done.
* On startup, tasks already in results.jsonl are skipped -> safe to restart during a month-long run.
Harvested `assets` are googleusercontent URLs; they need your logged-in session to download,
so this just records them. ponytail: fetch+save the blobs from the extension later if you want local files.
"""
import base64, json, os, time
from http.server import HTTPServer, BaseHTTPRequestHandler
HERE = os.path.dirname(os.path.abspath(__file__))
QUEUE_FILE = os.path.join(HERE, "queue.jsonl")
RESULTS_FILE = os.path.join(HERE, "results.jsonl")
DOWNLOADS = os.path.join(HERE, "downloads")
EXT = {"video/mp4": "mp4", "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"}
def save_asset(rec):
"""Write downloaded bytes to downloads/<category>/<id>_<idx>.<ext> (+ dest copy)."""
data = base64.b64decode(rec.get("dataBase64", ""))
ct = (rec.get("contentType") or "").split(";")[0].strip()
ext = EXT.get(ct, "mp4" if "video" in ct else "png")
name = f'{rec.get("id", "asset")}_{rec.get("idx", 0)}.{ext}'
outdir = os.path.join(DOWNLOADS, rec.get("category") or "misc")
os.makedirs(outdir, exist_ok=True)
path = os.path.join(outdir, name)
with open(path, "wb") as f:
f.write(data)
dest = rec.get("dest") # game asked for a copy in its own folder
if dest:
os.makedirs(dest, exist_ok=True)
with open(os.path.join(dest, name), "wb") as f:
f.write(data)
return path
def enqueue_task(rec):
"""Append a game-requested task to the queue (persisted, picked up on next poll)."""
t = {
"id": rec.get("id") or f'req_{int(time.time() * 1000)}',
"kind": rec.get("kind", "image"),
"model": rec.get("model") or ("Veo 3.1 - Fast" if rec.get("kind") == "video" else "Nano Banana 2"),
"prompt": rec["prompt"],
"category": rec.get("category", "request"),
}
if rec.get("dest"):
t["dest"] = rec["dest"]
with open(QUEUE_FILE, "a") as f:
f.write(json.dumps(t) + "\n")
return t
# Fallback demo queue if queue.jsonl is absent (so it runs out of the box).
DEMO = [
{"prompt": "Cinematic synthwave keyboard warrior fighting digital demons, 8k", "kind": "video", "model": "Veo 3.1 - Fast"},
{"prompt": "Op-shop record crate, warm 90s film grain, product hero shot", "kind": "image", "model": "Nano Banana 2"},
]
def load_queue():
rows = []
if os.path.exists(QUEUE_FILE):
with open(QUEUE_FILE) as f:
for line in f:
line = line.strip()
if line:
rows.append(json.loads(line))
else:
rows = list(DEMO)
# assign stable ids by position; video first so the expensive credits burn first
for i, r in enumerate(rows):
r.setdefault("id", f"task_{i:05d}")
r.setdefault("kind", "image")
rows.sort(key=lambda r: 0 if r.get("kind") == "video" else 1)
return rows
def done_ids():
"""Ids that must NOT be handed out again.
'ratelimited' is deliberately absent. handleRateLimit() writes that status meaning
"report the task back as pending" — no gen ran, the abuse flag just froze us mid-task.
Counting it as done silently binned the task forever, contradicting its own comment.
'failed' IS counted: a timeout may have burned credits invisibly, so nothing is retried
automatically. To re-run a failed task, delete its line from results.jsonl (requeue.py).
"""
ids = set()
if os.path.exists(RESULTS_FILE):
with open(RESULTS_FILE) as f:
for line in f:
line = line.strip()
if line:
row = json.loads(line)
if row.get("status") != "ratelimited":
ids.add(row.get("id"))
return ids
class Handler(BaseHTTPRequestHandler):
def _send(self, code=200, body=None):
self.send_response(code)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Content-Type", "application/json")
self.end_headers()
if body is not None:
self.wfile.write(json.dumps(body).encode())
def do_OPTIONS(self):
self._send(200, {})
def do_GET(self):
if self.path != "/next-task":
return self._send(404, {"error": "not found"})
done = done_ids()
for t in load_queue():
# in-flight entries expire after 15 min: a hard-refreshed tab that never
# reported back must get its task re-served, not stranded
if t["id"] not in done and (t["id"] not in INFLIGHT or time.time() - INFLIGHT[t["id"]] > 900):
INFLIGHT[t["id"]] = time.time()
remaining = sum(1 for x in load_queue() if x["id"] not in done)
print(f"[SERVER] -> {t['id']} ({t.get('kind')}) | {remaining} left")
return self._send(200, t)
return self._send(204) # queue drained
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
rec = json.loads(self.rfile.read(n).decode() or "{}")
if self.path == "/task-completed":
INFLIGHT.pop(rec.get("id"), None)
if rec.get("status") not in ("ratelimited", "requeue"): # both stay PENDING, retried after cooldown/reload
rec["ts"] = time.time()
with open(RESULTS_FILE, "a") as f:
f.write(json.dumps(rec) + "\n")
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(rec.get('assets') or [])} asset(s)")
return self._send(200, {"ok": True})
if self.path == "/save":
path = save_asset(rec)
print(f"[SERVER] saved {rec.get('id')} -> {path}")
return self._send(200, {"ok": True, "path": path})
if self.path == "/enqueue":
t = enqueue_task(rec)
print(f"[SERVER] enqueued {t['id']} ({t['kind']})")
return self._send(200, {"ok": True, "id": t["id"]})
return self._send(404, {"error": "not found"})
def log_message(self, *a): # quiet the default noise
pass
INFLIGHT = {}
def selftest():
# video-first ordering + resume-skip
global load_queue
_orig = load_queue
load_queue = lambda: [ # noqa: E731
{"id": "a", "kind": "image"}, {"id": "b", "kind": "video"}]
assert [t["id"] for t in load_queue()] == ["a", "b"] # loader keeps as-is here
q = sorted(load_queue(), key=lambda r: 0 if r["kind"] == "video" else 1)
assert q[0]["id"] == "b", "video must be handed out first"
load_queue = _orig
print("selftest ok")
def run(port=8017):
print(f"--- Flow queue on http://localhost:{port} ---")
print(f"queue: {QUEUE_FILE if os.path.exists(QUEUE_FILE) else '(demo — no queue.jsonl)'}")
print(f"pending: {sum(1 for t in load_queue() if t['id'] not in done_ids())}")
HTTPServer(("", port), Handler).serve_forever()
if __name__ == "__main__":
import sys
if "--selftest" in sys.argv:
selftest()
else:
try:
run()
except KeyboardInterrupt:
print("\nstopped")