fix(web): repair worker setup and post-solve follow-ups - #13
Merged
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Follow-up pending can stick forever
- Confirmed: unpaired FOLLOWUP_STARTED left followupPending true with no recovery; the wrapper now emits FOLLOWUP_FAILED if no terminal event exists, SSE replay synthesizes the same failure when no standby is live, and RUN_FINISHED/RUN_REOPENED clear the leftover lock.
Or push these changes by commenting:
@cursor push e483aae42e
Preview (e483aae42e)
diff --git a/apps/web/run_manager.py b/apps/web/run_manager.py
--- a/apps/web/run_manager.py
+++ b/apps/web/run_manager.py
@@ -3161,10 +3161,26 @@
driver = build_standby_driver(cmd, mgr=self)
async def _go() -> None:
+ followup_terminal = False
+
+ async def _note_followup_terminal(ev: Event) -> None:
+ nonlocal followup_terminal
+ if ev.event_type not in {
+ EventType.FOLLOWUP_COMPLETED, EventType.FOLLOWUP_FAILED,
+ }:
+ return
+ wanted = str(cmd.get("followup_id") or "")
+ ev_id = str((ev.payload or {}).get("followup_id") or "")
+ if not wanted or ev_id == wanted:
+ followup_terminal = True
+
async def _emit_followup_failed(detail: str) -> None:
- if action not in {"ask", "writeup"}:
+ nonlocal followup_terminal
+ if action not in {"ask", "writeup"} or followup_terminal:
return
try:
+ if bool(getattr(run.bus, "_closed", False)):
+ self._fresh_bus(run)
await run.bus.emit(Event(
event_type=EventType.FOLLOWUP_FAILED,
run_id=run_id,
@@ -3174,9 +3190,11 @@
"detail": detail,
},
))
+ followup_terminal = True
except Exception:
pass
+ run.bus.add_sink(_note_followup_terminal)
try:
LOG.info("standby worker starting for %s action=%s",
run_id, cmd.get("action"))
@@ -3210,6 +3228,11 @@
except Exception:
pass
finally:
+ run.bus.remove_sink(_note_followup_terminal)
+ if action in {"ask", "writeup"} and not followup_terminal:
+ # Crash, kill, or a failed start/complete emit must not leave
+ # the finished composer locked on FOLLOWUP_STARTED.
+ await _emit_followup_failed("后续操作已中断")
# Do not close the bus; retain the completed task as an observable
# receipt. `_ensure_standby` checks `.done()` and replaces it on the
# next command, so this does not block subsequent follow-ups.
diff --git a/apps/web/server.py b/apps/web/server.py
--- a/apps/web/server.py
+++ b/apps/web/server.py
@@ -1482,6 +1482,9 @@
replayed_seq = 0
replayed_count = 0
last_lifecycle = ""
+ last_followup = ""
+ last_followup_id = ""
+ last_followup_kind = "ask"
async for ev in run.store.replay_monotonic(run_id, after_seq=last_id):
replayed_seq = ev.seq
replayed_count += 1
@@ -1490,6 +1493,14 @@
EventType.RUN_FINISHED,
EventType.RUN_REOPENED):
last_lifecycle = ev.event_type.value
+ if ev.event_type in (EventType.FOLLOWUP_STARTED,
+ EventType.FOLLOWUP_COMPLETED,
+ EventType.FOLLOWUP_FAILED):
+ last_followup = ev.event_type.value
+ last_followup_id = str(
+ (ev.payload or {}).get("followup_id") or "")
+ last_followup_kind = str(
+ (ev.payload or {}).get("kind") or "ask")
yield {
"id": str(ev.seq),
"event": ev.event_type.value,
@@ -1524,7 +1535,26 @@
"event": synth.event_type.value,
"data": synth.model_dump_json(),
}
- # live tail: everything after what we just replayed (or after the
+ # Ghost-followup guard: a persisted FOLLOWUP_STARTED without a
+ # terminal event and no live standby would lock ask/writeup/resolve
+ # across reload. Synthesize FOLLOWUP_FAILED so replay can settle.
+ if (fresh and not manager._standby_busy(run)
+ and last_followup == EventType.FOLLOWUP_STARTED.value):
+ replayed_seq = max(replayed_seq, run.store.last_stream_seq(run_id)) + 1
+ synth = Event(
+ event_type=EventType.FOLLOWUP_FAILED, run_id=run_id,
+ seq=replayed_seq,
+ payload={
+ "followup_id": last_followup_id,
+ "kind": last_followup_kind,
+ "detail": "后续操作已中断",
+ })
+ yield {
+ "id": str(replayed_seq),
+ "event": synth.event_type.value,
+ "data": synth.model_dump_json(),
+ }
+ # live tail: everything after what we just replayed (or after the)
# client's Last-Event-ID on a reconnect). A finished run's bus is
# closed, so subscribe() returns after backlog replay. Do NOT let the
# HTTP response EOF: browser EventSource treats EOF as an error and
diff --git a/apps/web/ui/lib/events.ts b/apps/web/ui/lib/events.ts
--- a/apps/web/ui/lib/events.ts
+++ b/apps/web/ui/lib/events.ts
@@ -2579,6 +2579,9 @@
}
s.finished = true;
s.preparing = false;
+ // A generation terminal is not a follow-up terminal. Drop a leftover
+ // ask/writeup lock so a later finished composer cannot stay disabled.
+ s.followupPending = false;
// A hard stop can kill the coordinator before it ever emits race_concluded
// (operator stop cancels the whole run task mid-race). Clear the pill here so
// the UI never sticks on "racing" past the terminal event.
@@ -2791,6 +2794,7 @@
// false-positive flag invalidation. Keep the operator copy precise.
s.finished = false;
s.preparing = false;
+ s.followupPending = false;
s.solved = false;
s.acceptedOnly = false;
s.finishedAt = undefined;
diff --git a/tests/test_standby_hitl.py b/tests/test_standby_hitl.py
--- a/tests/test_standby_hitl.py
+++ b/tests/test_standby_hitl.py
@@ -869,6 +869,52 @@
assert lifecycle[-1].payload["detail"] == "后续操作已取消"
+def test_standby_wrapper_synthesizes_failed_when_start_has_no_terminal(
+ tmp_path, monkeypatch):
+ from apps.web import run_manager as rm
+ from muteki.core.events import Event, EventType
+ import apps.web.drivers as drivers
+
+ async def _started_then_exit(run):
+ await run.bus.emit(Event(
+ event_type=EventType.FOLLOWUP_STARTED,
+ run_id=run.run_id,
+ payload={
+ "followup_id": "followup-orphan", "kind": "ask",
+ "question": "证据来源是什么?",
+ },
+ ))
+
+ monkeypatch.setattr(
+ drivers, "build_standby_driver", lambda cmd, mgr=None: _started_then_exit,
+ )
+
+ async def _run():
+ mgr = rm.RunManager(sessions_root=tmp_path / "sessions")
+ run = mgr.create("run-x")
+ run.started = True
+ run.finished = True
+ run.solved = True
+ assert mgr._ensure_standby(run.run_id, {
+ "action": "ask", "text": "证据来源是什么?",
+ "followup_id": "followup-orphan",
+ })
+ await asyncio.gather(run.standby_task, return_exceptions=True)
+ return [event async for event in run.store.replay(run.run_id)]
+
+ events = asyncio.run(_run())
+ lifecycle = [
+ event for event in events if event.event_type in {
+ EventType.FOLLOWUP_STARTED, EventType.FOLLOWUP_FAILED,
+ }
+ ]
+ assert [event.event_type for event in lifecycle] == [
+ EventType.FOLLOWUP_STARTED, EventType.FOLLOWUP_FAILED,
+ ]
+ assert lifecycle[-1].payload["followup_id"] == "followup-orphan"
+ assert lifecycle[-1].payload["detail"] == "后续操作已中断"
+
+
def test_standby_final_cancel_log_redacts_callback_exception(
tmp_path, monkeypatch, caplog):
from apps.web import run_manager as rm
diff --git a/tests/test_web_deck_ux.py b/tests/test_web_deck_ux.py
--- a/tests/test_web_deck_ux.py
+++ b/tests/test_web_deck_ux.py
@@ -461,6 +461,21 @@
assert(s.chat.filter((m) => m.followupId === "F1"
&& m.content.includes("后续操作已取消")).length === 1,
"terminal row remains idempotent after replay");
+
+ s = lib.reduce(s, {{ event_type: lib.EventType.FOLLOWUP_STARTED,
+ run_id: "run-followup", ts: 4, payload: {{ followup_id: "F2",
+ kind: "ask", question: "还卡着吗?" }} }});
+ assert(s.followupPending, "a new follow-up can pend again");
+ s = lib.reduce(s, {{ event_type: lib.EventType.RUN_FINISHED,
+ run_id: "run-followup", ts: 5, payload: {{ solved: true }} }});
+ assert(!s.followupPending, "run.finished clears a leftover follow-up lock");
+ s = lib.reduce(s, {{ event_type: lib.EventType.FOLLOWUP_STARTED,
+ run_id: "run-followup", ts: 6, payload: {{ followup_id: "F3",
+ kind: "writeup" }} }});
+ assert(s.followupPending, "writeup can pend after finish");
+ s = lib.reduce(s, {{ event_type: lib.EventType.RUN_REOPENED,
+ run_id: "run-followup", ts: 7, payload: {{ reason: "resolve" }} }});
+ assert(!s.followupPending, "run.reopened clears a leftover follow-up lock");
"""
)
_run_ui_node(script)
diff --git a/tests/test_web_server.py b/tests/test_web_server.py
--- a/tests/test_web_server.py
+++ b/tests/test_web_server.py
@@ -731,6 +731,32 @@
assert EventType.RUN_FINISHED.value in seen # the synthetic terminator
+async def test_events_injects_followup_failed_for_orphan_start(server_mgr) -> None:
+ from muteki.core.events import Event
+ s, mgr = server_mgr
+ rid = "ghost-followup-1"
+ run = mgr.create(rid)
+ await run.bus.emit(Event(event_type=EventType.RUN_STARTED, run_id=rid,
+ payload={"challenge": {"name": "x"}}))
+ await run.bus.emit(Event(event_type=EventType.RUN_FINISHED, run_id=rid,
+ payload={"solved": True}))
+ await run.bus.emit(Event(
+ event_type=EventType.FOLLOWUP_STARTED, run_id=rid,
+ payload={"followup_id": "F-orphan", "kind": "ask", "question": "why?"},
+ ))
+ run.started = True
+ run.finished = True
+ run.task = None
+ run.standby_task = None
+ async with httpx.AsyncClient(base_url=s.base, timeout=30, trust_env=False) as client:
+ seen: set = set()
+ await asyncio.wait_for(
+ _collect_sse(client, rid, seen, EventType.FOLLOWUP_FAILED.value),
+ timeout=15)
+ assert EventType.FOLLOWUP_STARTED.value in seen
+ assert EventType.FOLLOWUP_FAILED.value in seen
+
+
async def test_events_do_not_inject_run_finished_for_protocol2_ghost(server_mgr) -> None:
from muteki.core.events import EventYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit db296d2. Configure here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


概要
验证
git diff --check公开仓库专属 README 更新已保留;本 PR 只包含 24 个产品代码与测试文件。
Note
High Risk
Moves trusted resume identity out of Worker-writable files and changes post-run event admission, control, and credential/backend persistence. A bug here can resume the wrong session, leak authority, or leave follow-ups stuck after restart.
Overview
Moves the trusted winning Worker resume handle out of workspace
winner.jsoninto coordinator-onlywinner-continuation.json. Standby, resolve, and BTW now load identity, session, and a path-checked workdir from that store; Worker-writable files no longer pick profiles, credentials, backends, or host paths. Legacy runs recover non-sensitive identity from durable events.Ask and Writeup become a separate follow-up lifecycle (
followup.started/completed/failed) that can still publish afterRUN_FINISHED. The UI shows a finished-run composer with pending/disable state, correlated chat rows, and restart recovery that fails orphaned follow-ups. Conversational standbys use a 5-minute timeout, require a real process success and non-empty reply, and writeups are instructed not to keep investigating.Worker settings save seats, credentials, and backend as one validated snapshot (rollback on illegal container + system-login). Review/Verifier can reuse ordinary seats; Verifier defaults to off. Claude
--bareis only added for injected credentials so host Keychain login still works.Reviewed by Cursor Bugbot for commit 054e64a. Bugbot is set up for automated code reviews on this repo. Configure here.