fix: preserve successor ownership of reused Agents

(cherry picked from commit e4037f1d785aa86074e7cc84025942da5c0cc13a)
This commit is contained in:
Frank Song committed 2026-09-23 11:56:10 +08:00
1 parent acdcddf5c9
commit 861a10ec34
3 files changed
+91 -23

No files matched your search

+34 -14
View File
@@ -9792,6 +9792,7 @@ def _run_agent_streaming(
_restore_streaming_skill_home_modules = False _restore_streaming_skill_home_modules = False
_acquired_streaming_skill_home_patch_lock = False _acquired_streaming_skill_home_patch_lock = False
def _register_agent_if_current(candidate, cache_signature=None): def _register_agent_if_current(candidate, cache_signature=None):
nonlocal agent
# Every constructor, including both credential self-heal branches, must # Every constructor, including both credential self-heal branches, must
# share the Stop publication edge. CANCEL_FLAGS may already be detached; # share the Stop publication edge. CANCEL_FLAGS may already be detached;
# the worker-retained event and stream membership remain authoritative. # the worker-retained event and stream membership remain authoritative.
@@ -9815,6 +9816,15 @@ def _run_agent_streaming(
logger.debug("Lifecycle register_agent failed for session %s", session_id, exc_info=True) logger.debug("Lifecycle register_agent failed for session %s", session_id, exc_info=True)
if not cancelled: if not cancelled:
return True return True
# A cache hit is a borrowed reusable object, not this worker's private
# candidate. Stop may already have let a successor reuse it. Rejected
# cache-hit admission must not interrupt that other turn.
if cache_signature is None and not ephemeral:
if agent is candidate:
agent = None
return False
# Newly constructed candidates were never published on this failed
# admission; they cannot have been borrowed by a successor from us.
# No Agent call, session persistence or SSE write under registry locks. # No Agent call, session persistence or SSE write under registry locks.
try: try:
candidate.interrupt("Cancelled before start") candidate.interrupt("Cancelled before start")
@@ -9823,30 +9833,40 @@ def _run_agent_streaming(
return False return False
def _agent_can_invoke(candidate): def _agent_can_invoke(candidate):
nonlocal agent
# Prompt preparation and LRU cleanup can yield after registration. Admit # Prompt preparation and LRU cleanup can yield after registration. Admit
# each invocation again at its point of use; never hold registry locks # each invocation again at its point of use; never hold registry locks
# across run_conversation or interrupt. A later Stop interrupts the # across run_conversation or interrupt. A later Stop interrupts the
# already-admitted invocation through the registered Agent as usual. # already-admitted invocation through the registered Agent as usual.
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK from api.config import (
SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK,
SESSION_WRITEBACK_OWNERS, SESSION_WRITEBACK_OWNERS_LOCK,
)
with STREAMS_LOCK: with STREAMS_LOCK:
current = (not cancel_event.is_set() and stream_id in STREAMS current = (not cancel_event.is_set() and stream_id in STREAMS
and AGENT_INSTANCES.get(stream_id) is candidate) and AGENT_INSTANCES.get(stream_id) is candidate)
if not current: if not current:
if AGENT_INSTANCES.get(stream_id) is candidate: if AGENT_INSTANCES.get(stream_id) is candidate:
AGENT_INSTANCES.pop(stream_id, None) AGENT_INSTANCES.pop(stream_id, None)
with SESSION_AGENT_CACHE_LOCK: # Agent identity is not turn identity: a successor can reuse the
entry = SESSION_AGENT_CACHE.get(session_id) # same cached object. Use the existing cancellation-surviving
if entry and entry[0] is candidate: # ownership record, holding its lock through cache retirement.
SESSION_AGENT_CACHE.pop(session_id, None) # Missing ownership also fails closed (successor may have ended).
# Dirty memory segments retain their own owner; clear with SESSION_WRITEBACK_OWNERS_LOCK:
# only this still-current reusable future-turn handle. if SESSION_WRITEBACK_OWNERS.get(session_id) == stream_id:
from api.session_lifecycle import unregister_agent with SESSION_AGENT_CACHE_LOCK:
unregister_agent(session_id) entry = SESSION_AGENT_CACHE.get(session_id)
if not current: if entry and entry[0] is candidate:
try: SESSION_AGENT_CACHE.pop(session_id, None)
candidate.interrupt("Cancelled before invocation") # Dirty memory segments keep their original owner.
except Exception: from api.session_lifecycle import unregister_agent
logger.debug("Failed to interrupt unstarted candidate agent") unregister_agent(session_id)
# This invocation never started. Stop owns any required interrupt of the
# running Agent; a late duplicate interrupt could hit its new borrower.
# Drop our local borrowed handle too: final Steer drain must not reach
# the successor through the old worker's fallback `agent` reference.
if not current and agent is candidate:
agent = None
return current return current
# Initialised here (before any code that may raise) so the outer `finally` # Initialised here (before any code that may raise) so the outer `finally`
@@ -44,8 +44,17 @@ after a cancellation check does not protect that check-to-publication gap.
After prompt preparation and immediately before each initial/self-heal invocation, After prompt preparation and immediately before each initial/self-heal invocation,
revalidate the retained cancel event, stream membership and exact registered revalidate the retained cancel event, stream membership and exact registered
Agent. If Stop already won, remove only that Agent's matching reusable entry Agent. If Stop already won, retire a matching reusable entry only while the
and settle cancellation. Never clear a successor's cache or lifecycle handle. existing `SESSION_WRITEBACK_OWNERS` record still equals this exact stream.
Hold that ownership lock through cache/lifecycle retirement. Object identity is
insufficient because successors can reuse the same Agent; absent ownership is
not permission either, since a completed successor clears its record. Never
clear a successor's cache or lifecycle handle, and do not issue another Agent
interrupt for an invocation that never started. Rejected cache-hit registration
likewise must not interrupt the borrowed Agent; only a never-published newly
constructed candidate may receive construction-cancellation cleanup. Drop the
old worker's local borrowed handle as well, so final pending-Steer drain cannot
reach a successor through an object-reference fallback.
Stop after invocation admission uses the existing Agent interrupt mechanism; Stop after invocation admission uses the existing Agent interrupt mechanism;
registry locks must not span provider or tool execution. LRU eviction/close stays registry locks must not span provider or tool execution. LRU eviction/close stays
outside the stream lock and retains the existing active-worker policy. outside the stream lock and retains the existing active-worker policy.
+46 -7
View File
@@ -15,7 +15,7 @@ from tests.test_steer_worker_boundaries import worker_scene as worker_scene
@pytest.mark.parametrize("path", ["initial", "cached", "returned", "exception"]) @pytest.mark.parametrize("path", ["initial", "cached", "returned", "exception"])
@pytest.mark.parametrize("cancellation", ["stop", "event-only", "detached-only", "successor", "none"]) @pytest.mark.parametrize("cancellation", ["stop", "event-only", "detached-only", "successor", "same-agent-successor", "same-agent-completed", "none"])
def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkeypatch, path, cancellation): def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkeypatch, path, cancellation):
scene = worker_scene scene = worker_scene
reached, release = threading.Event(), threading.Event() reached, release = threading.Event(), threading.Event()
@@ -23,10 +23,20 @@ def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkey
worker_id = [None] worker_id = [None]
paused = [False] paused = [False]
successor = object() successor = object()
stop_interrupt_seen = threading.Event()
successor_interrupt_count = None
monkeypatch.setattr(session_lifecycle, "_sessions", {}) monkeypatch.setattr(session_lifecycle, "_sessions", {})
monkeypatch.setattr(config, "SESSION_WRITEBACK_OWNERS", {})
config.register_session_writeback_owner("original", "run")
def on_init(): def on_init():
created.append(scene.agent) created.append(scene.agent)
interrupt = scene.agent.interrupt
def observed_interrupt(reason):
result = interrupt(reason)
stop_interrupt_seen.set()
return result
scene.agent.interrupt = observed_interrupt
def on_run(): def on_run():
assert scene.lock.owner != threading.get_ident(), "provider invocation under stream lock" assert scene.lock.owner != threading.get_ident(), "provider invocation under stream lock"
@@ -49,6 +59,8 @@ def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkey
scene.session.pending_user_message = "Do the task." scene.session.pending_user_message = "Do the task."
scene.session.pending_started_at = 2.0 scene.session.pending_started_at = 2.0
scene.session.save() scene.session.save()
config.register_session_writeback_owner("original", "run")
stop_interrupt_seen.clear()
class ObservedCache(OrderedDict): class ObservedCache(OrderedDict):
def __setitem__(self, key, value): def __setitem__(self, key, value):
@@ -100,16 +112,32 @@ def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkey
try: try:
assert reached.wait(8), "worker never released target registration lock" assert reached.wait(8), "worker never released target registration lock"
retained = config.CANCEL_FLAGS["run"] retained = config.CANCEL_FLAGS["run"]
if cancellation in ("stop", "successor"): if cancellation in ("stop", "successor", "same-agent-successor", "same-agent-completed"):
stop_future = pool.submit(streaming.cancel_stream, "run") stop_future = pool.submit(streaming.cancel_stream, "run")
assert retained.wait(5), "Stop did not publish cancellation" assert retained.wait(5), "Stop did not publish cancellation"
assert stop_interrupt_seen.wait(5), "Stop has not finished its own interrupt"
with config.STREAMS_LOCK: with config.STREAMS_LOCK:
assert "run" not in config.STREAMS assert "run" not in config.STREAMS
if cancellation == "successor": if cancellation in ("successor", "same-agent-successor", "same-agent-completed"):
if cancellation.startswith("same-agent"):
successor = created[-1]
config.register_session_writeback_owner("original", "successor")
with config.SESSION_AGENT_CACHE_LOCK: with config.SESSION_AGENT_CACHE_LOCK:
OrderedDict.__setitem__(cache, "original", (successor, "new-signature")) OrderedDict.__setitem__(cache, "original", (successor, "new-signature"))
session_lifecycle.register_agent("original", successor) session_lifecycle.register_agent("original", successor)
config.AGENT_INSTANCES["successor"] = successor scene.session.active_stream_id = "successor"
scene.session.pending_user_message = "Successor request"
if cancellation == "same-agent-completed":
config.clear_session_writeback_owner_if_owned("original", "successor")
scene.session.active_stream_id = None
scene.session.pending_user_message = None
else:
config.AGENT_INSTANCES["successor"] = successor
config.register_active_run("successor", session_id="original", backend="legacy")
if cancellation == "same-agent-successor":
with successor.pending_lock:
successor.pending.append("Successor-only guidance")
successor_interrupt_count = scene.calls.count("interrupt")
elif cancellation == "event-only": elif cancellation == "event-only":
with config.STREAMS_LOCK: with config.STREAMS_LOCK:
retained.set() retained.set()
@@ -131,12 +159,21 @@ def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkey
else: else:
assert scene.calls.count("run") == prior_runs, "cancelled candidate was invoked" assert scene.calls.count("run") == prior_runs, "cancelled candidate was invoked"
cached = config.SESSION_AGENT_CACHE.get("original") cached = config.SESSION_AGENT_CACHE.get("original")
assert not cached or cached[0] is not created[-1], "cancelled candidate stayed reusable" if not cancellation.startswith("same-agent"):
assert not cached or cached[0] is not created[-1], "cancelled candidate stayed reusable"
assert finalized, "cancellation did not reach terminal settlement" assert finalized, "cancellation did not reach terminal settlement"
if cancellation == "successor": if successor_interrupt_count is not None:
assert scene.calls.count("interrupt") == successor_interrupt_count, "stale worker interrupted its successor"
assert config.SESSION_AGENT_CACHE["original"][0] is successor assert config.SESSION_AGENT_CACHE["original"][0] is successor
assert session_lifecycle._sessions["original"]["agent"] is successor assert session_lifecycle._sessions["original"]["agent"] is successor
assert config.AGENT_INSTANCES["successor"] is successor if cancellation == "same-agent-successor":
assert successor.pending == ["Successor-only guidance"], "stale terminal drain stole successor guidance"
if cancellation == "same-agent-completed":
assert scene.session.active_stream_id is None
else:
assert config.AGENT_INSTANCES["successor"] is successor
assert scene.session.active_stream_id == "successor"
assert scene.session.pending_user_message == "Successor request"
assert all(held and live for _, held, live in publications), ( assert all(held and live for _, held, live in publications), (
"Agent cache publication escaped the stream/Stop admission boundary", publications) "Agent cache publication escaped the stream/Stop admission boundary", publications)
assert "run" not in config.AGENT_INSTANCES assert "run" not in config.AGENT_INSTANCES
@@ -150,6 +187,8 @@ def test_cache_publication_and_registration_exclude_competing_stop(worker_scene,
interrupted_state = [] interrupted_state = []
interrupt_observed = threading.Event() interrupt_observed = threading.Event()
monkeypatch.setattr(session_lifecycle, "_sessions", {}) monkeypatch.setattr(session_lifecycle, "_sessions", {})
monkeypatch.setattr(config, "SESSION_WRITEBACK_OWNERS", {})
config.register_session_writeback_owner("original", "run")
class PublicationBarrier(OrderedDict): class PublicationBarrier(OrderedDict):
def __setitem__(self, key, value): def __setitem__(self, key, value):