diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 5e97b0b8..d5f52e53 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -64,6 +64,13 @@ 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: @@ -350,7 +357,10 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr p_cont = session.pending_continuation_prompt or "Continue." session.pending_continuation = False session.pending_continuation_prompt = None - session.status = "completed" + # 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" 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 b74ee0f5..2e05e847 100644 --- a/backend/apps/agents/manager/SessionControl.py +++ b/backend/apps/agents/manager/SessionControl.py @@ -43,6 +43,17 @@ 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 new file mode 100644 index 00000000..7bc690ca --- /dev/null +++ b/backend/tests/test_stop_kills_continuations.py @@ -0,0 +1,85 @@ +"""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 == []