feat: browser stream waits through round gaps instead of disconnecting
Stale bridge state used to end the stream on the first stale read, so every round gap and every worker restart killed the watch (the 2026-10-30 demo watch died this way when a 15:03 deploy restarted the worker). Now a stale state switches to a waiting heartbeat every 5s while the task is still alive (running/queued/paused/ interrupted), and frames resume when the next round's MCP starts polling. The stream ends only on a terminal task state, a missing task row, or 10 minutes of continuous staleness. The panel shows a waiting status and keeps the last frame. Also force-close the SSE connection on stream end: HTTP/1.1 keep-alive otherwise left terminal streams hanging with no EOF.
This commit is contained in:
1 parent
671b4bedef
commit
962bb50a1c
4 files changed
+137
-22
No files matched your search
+62
-6
@@ -5383,9 +5383,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
current holder at least every 5s). The payload also
|
||||
carries the session url/title, so the console panel can
|
||||
track the header from the stream alone instead of
|
||||
polling the state endpoint while watching. Ends the
|
||||
stream when the state file goes stale, so a dead
|
||||
session reads as a disconnect, not a frozen picture.
|
||||
polling the state endpoint while watching.
|
||||
|
||||
Stale state does NOT end the stream while the task is
|
||||
still alive: between rounds (and across a worker
|
||||
restart) the MCP is gone for a while and the state file
|
||||
ages past the 30s staleness mark, which used to kill
|
||||
every watch at the first round gap. Now a stale state
|
||||
only switches the stream to waiting mode -- a heartbeat
|
||||
with waiting:true every 5s, task status checked at the
|
||||
same cadence -- and frames resume when the next round's
|
||||
MCP starts polling again. The stream ends only when the
|
||||
task reaches a terminal state (completed, merged,
|
||||
failed), the task row disappears, or the state stays
|
||||
stale for 10 minutes straight (abandoned watch).
|
||||
"""
|
||||
import time
|
||||
from cloud.playwright_remote import (
|
||||
@@ -5406,23 +5417,62 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
paths = bridge_paths(worktree)
|
||||
last_frame = None
|
||||
last_heartbeat = 0.0
|
||||
last_holder = ""
|
||||
stale_since = None
|
||||
last_status_check = 0.0
|
||||
task_status = None
|
||||
try:
|
||||
while True:
|
||||
state, serr = read_state(worktree)
|
||||
now = time.time()
|
||||
if serr is not None:
|
||||
break
|
||||
# No live session right now (round gap,
|
||||
# worker restart, or a blocked drain loop).
|
||||
# Wait it out while the task is still alive
|
||||
# instead of dropping the watch.
|
||||
if stale_since is None:
|
||||
stale_since = now
|
||||
if now - stale_since > 600:
|
||||
break # abandoned watch
|
||||
if now - last_status_check >= 5:
|
||||
last_status_check = now
|
||||
try:
|
||||
row = self._fetch_task(task_id)
|
||||
except Exception: # noqa: BLE001 - keep waiting
|
||||
row = False # transient error, not deletion
|
||||
if row is None:
|
||||
break # task row gone
|
||||
if row is not False:
|
||||
task_status = row["status"]
|
||||
if task_status in (
|
||||
"completed", "merged", "failed"):
|
||||
break
|
||||
if now - last_heartbeat >= 5:
|
||||
last_heartbeat = now
|
||||
payload = json.dumps(
|
||||
{"image": "",
|
||||
"holder": last_holder,
|
||||
"waiting": True,
|
||||
"task_status": task_status or "",
|
||||
"ts": int(now)}, ensure_ascii=False)
|
||||
self.wfile.write(
|
||||
("data: %s\n\n" % payload).encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
stale_since = None
|
||||
try:
|
||||
with open(paths["frame"], "rb") as fh:
|
||||
frame = fh.read().decode("utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
frame = ""
|
||||
now = time.time()
|
||||
if frame != last_frame or now - last_heartbeat >= 5:
|
||||
last_frame = frame
|
||||
last_heartbeat = now
|
||||
last_holder = effective_holder(worktree, state)
|
||||
payload = json.dumps(
|
||||
{"image": frame,
|
||||
"holder": effective_holder(worktree, state),
|
||||
"holder": last_holder,
|
||||
"url": state.get("url", ""),
|
||||
"title": state.get("title", ""),
|
||||
"ts": int(now)}, ensure_ascii=False)
|
||||
@@ -5432,6 +5482,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
time.sleep(0.02)
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass
|
||||
# An SSE stream has no Content-Length, so the client can
|
||||
# only tell "stream ended" from the connection closing.
|
||||
# HTTP/1.1 keep-alive would otherwise leave it hanging
|
||||
# after a terminal break (completed/failed/cap) with no
|
||||
# more bytes ever coming. Force the close on stream end.
|
||||
self.close_connection = True
|
||||
|
||||
def _task_chat_history(self, user_id, task_id):
|
||||
"""GET /tasks/{id}/chat-history:真正的交替历史,一次性
|
||||
|
||||
Reference in new issue
Block a user