From b14791074f5db4041badf2eab0e0d738c2ce6101 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 20 Aug 2026 16:13:50 -0700 Subject: [PATCH] Revert "[eric] agents: Stop disarms every queued injection, so a stopped agent can never resurrect" This reverts commit 332ae7d183b75f9bc3658638da329fcd6a247c23. --- backend/apps/agents/agent_manager.py | 12 +-- backend/apps/agents/manager/SessionControl.py | 11 --- .../tests/test_stop_kills_continuations.py | 85 ------------------- 3 files changed, 1 insertion(+), 107 deletions(-) delete mode 100644 backend/tests/test_stop_kills_continuations.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index d5f52e53..5e97b0b8 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -64,13 +64,6 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr if p_tail: logger.info(f"continuation for {session_id} superseded by a user message during the {delay_s}s wait") return - # Defence in depth against the resurrection above: the flag may have been armed before the - # stop, or set by a path that never learned about it. A stopped session is the user's - # decision and outranks any self-heal we queued. - p_now = self.sessions.get(session_id) - if p_now is None or p_now.status in ("stopped", "error"): - logger.info(f"continuation for {session_id} stood down: session is {getattr(p_now, 'status', 'gone')}") - return try: await self.send_message(session_id, prompt, hidden=True) except Exception: @@ -357,10 +350,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr p_cont = session.pending_continuation_prompt or "Continue." session.pending_continuation = False session.pending_continuation_prompt = None - # Never relabel a session the user stopped; that flip is what let the error path - # hand a dead agent back to the dispatcher as if it were healthy. - if session.status != "stopped": - session.status = "completed" + session.status = "completed" p_cont_delay = int(getattr(session, "pending_continuation_delay_s", 0) or 0) session.pending_continuation_delay_s = 0 asyncio.create_task(self.dispatch_hidden_continuation(session_id, p_cont, p_cont_delay)) diff --git a/backend/apps/agents/manager/SessionControl.py b/backend/apps/agents/manager/SessionControl.py index 2e05e847..b74ee0f5 100644 --- a/backend/apps/agents/manager/SessionControl.py +++ b/backend/apps/agents/manager/SessionControl.py @@ -43,17 +43,6 @@ class SessionControl(AgentManagerProtocol): # Only a LIVE turn can be stopped. A finished session's task lingers in the registry, and shutdown stops every task it finds, so an unconditional flip relabelled every completed chat "stopped" on restart and hung a Resume button off a conversation that was already answered. p_was_live = session.status in ("running", "waiting_approval") - # Stop must disarm every queued injection, not just the live turn. A self-heal, nudge or - # retry armed moments earlier survives the stop, sleeps out its delay (up to 15 minutes) - # and then sends itself, which is how a stopped agent walks back from the dead minutes - # later and starts typing (field report 2026-08-20: "it'll resurrect itself and pop up - # out of nowhere"). Cleared unconditionally: a finished session with a stale flag would - # resurrect just as happily. - session.pending_continuation = False - session.pending_continuation_prompt = None - session.pending_continuation_delay_s = 0 - session.pending_continuation_toolless = False - session.awaiting_reconnect = False if p_was_live: session.status = "stopped" session.needs_fresh_session = True diff --git a/backend/tests/test_stop_kills_continuations.py b/backend/tests/test_stop_kills_continuations.py deleted file mode 100644 index 7bc690ca..00000000 --- a/backend/tests/test_stop_kills_continuations.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Stop means stop: a queued injection must never resurrect an agent the user killed. - -Field report 2026-08-20, verbatim: "even if we stop an agent by pausing it, or quitting the agent -outright, it'll resurrect itself and continue with the task, and pop up out of nowhere." The reporter -guessed the cause correctly (the hidden continuations), and the guess was right: stop_agent finalised -status and the live turn but left pending_continuation armed, and the dispatcher slept out its delay -(up to 900s once transient retries existed) before sending itself into a dead session. -""" - -import asyncio - - -from backend.apps.agents.agent_manager import agent_manager -from backend.apps.agents.core.models import AgentSession - - -def p_armed_session() -> AgentSession: - s = AgentSession(name="t", model="sonnet-5", dashboard_id="d") - s.status = "running" - s.pending_continuation = True - s.pending_continuation_prompt = "carry on" - s.pending_continuation_delay_s = 300 - agent_manager.sessions[s.id] = s - return s - - -def test_stop_disarms_the_queued_injection(): - s = p_armed_session() - asyncio.run(agent_manager.stop_agent(s.id)) - assert s.pending_continuation is False, "a stopped agent must not stay armed" - assert s.pending_continuation_delay_s == 0 - assert s.awaiting_reconnect is False - assert s.status == "stopped" - - -def test_the_dispatcher_stands_down_on_a_stopped_session(): - """Defence in depth: even if something arms the flag AFTER the stop, nothing may be sent.""" - s = p_armed_session() - s.status = "stopped" - sent = [] - real = agent_manager.send_message - - async def spy(sid, prompt, hidden=False, **kw): - sent.append((sid, prompt)) - - agent_manager.send_message = spy - try: - asyncio.run(agent_manager.dispatch_hidden_continuation(s.id, "carry on", 0)) - finally: - agent_manager.send_message = real - assert sent == [], "nothing may be injected into a session the user stopped" - - -def test_a_live_session_still_receives_its_continuation(): - """NEGATIVE CONTROL. The guard must not delete the self-heal it exists to protect; without this - every retry we built today would silently stop working.""" - s = p_armed_session() - s.status = "running" - sent = [] - real = agent_manager.send_message - - async def spy(sid, prompt, hidden=False, **kw): - sent.append((sid, prompt)) - - agent_manager.send_message = spy - try: - asyncio.run(agent_manager.dispatch_hidden_continuation(s.id, "carry on", 0)) - finally: - agent_manager.send_message = real - assert sent == [(s.id, "carry on")], "a live session must still self-heal" - - -def test_a_vanished_session_is_not_resurrected_either(): - sent = [] - real = agent_manager.send_message - - async def spy(sid, prompt, hidden=False, **kw): - sent.append((sid, prompt)) - - agent_manager.send_message = spy - try: - asyncio.run(agent_manager.dispatch_hidden_continuation("no-such-session", "carry on", 0)) - finally: - agent_manager.send_message = real - assert sent == []