mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 11:17:44 +02:00
[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:
co-authored by
Claude Opus 5
parent
ea04b1da11
commit
332ae7d183
@@ -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 == []
|
||||
Reference in New Issue
Block a user