[eric] agents: Stop disarms every queued injection, so a stopped agent can never resurrect

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wtspwSFzZmjCx9UNPAorQ
This commit is contained in:
ciregenz
2026-08-20 15:50:55 -07:00
co-authored by Claude Opus 5
parent ea04b1da11
commit 332ae7d183
3 changed files with 107 additions and 1 deletions
+11 -1
View File
@@ -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))
@@ -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
@@ -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 == []