Skip to content

fix(gateway): interrupt every in-flight API turn on shutdown, not just /v1/runs - #79881

Closed
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/gateway-shutdown-interrupt-all-api-turns-63963
Closed

fix(gateway): interrupt every in-flight API turn on shutdown, not just /v1/runs#79881
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/gateway-shutdown-interrupt-all-api-turns-63963

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

supersedes #63963

@necoweb3 found this bug and got the architecture right. Two parts of #63963 are kept here essentially as written, with credit:

  • the duck-typed GatewayRunner._interrupt_api_server_runs() hook, called from _interrupt_running_agents() — an adapter without the hook is skipped rather than raising mid-shutdown;
  • folding _active_api_run_count() into the 5s settle window after the interrupt.

What this PR adds is the coverage teknium1's review asked for. #63963 has been open 23 days with that one review unanswered, so rather than let the fix sit, this ships the superset.

What does this PR do?

The shutdown drain accounts for API-server work but never interrupts it.

_drain_active_agents() folds _active_api_run_count() into both its wait loop and its timed_out verdict. _interrupt_running_agents() iterates self._running_agents only — a dict no API turn ever enters, because the API server owns its own agent lifecycle. The repo states the gap against itself, in gateway/run.py directly above the drain loop:

# API-server / desk sessions have the same structural gap (#63529).

User-visible symptom: every gateway restart (/restart, /update, SIGUSR1 — they all funnel through the same stop()) with a live API or desktop turn burns the full drain timeout, then runs _kill_tool_subprocesses("post-interrupt"), which amputates the turn's tool subprocesses. No cooperative interrupt, no chance to unwind. "It hung, then I lost my work."

Seven entry points, not one

# Entry point Counted by Agent reachable via Covered by #63963 Covered here
1 POST /v1/runs (_handle_runs) _active_run_tasks _active_run_agents[run_id]
2 POST /api/sessions/{id}/chat (_handle_session_chat) _inflight_agent_runs
3 POST /api/sessions/{id}/chat/stream (_handle_session_chat_stream) _inflight_agent_runs
4 POST /v1/chat/completions, streaming (_handle_chat_completions) _inflight_agent_runs caller-local agent_ref list
5 POST /v1/chat/completions, non-streaming _inflight_agent_runs
6 POST /v1/responses, streaming (_handle_responses) _inflight_agent_runs caller-local agent_ref list
7 POST /v1/responses, non-streaming _inflight_agent_runs

Rows 2–7 are the six _run_agent() callers. _inflight_agent_runs += 1 / -= 1 wraps every one of them, so they all hold the drain open — and none of them has a run_id, so the run_id-keyed _active_run_agents structurally cannot reach them. Only rows 4 and 6 pass agent_ref, and that lands in a caller-local one-element list, never a registry, so it is not a usable hook either.

That is broader than the review note, which named the synchronous session-chat path specifically.

The approach: one registration site

Rather than touch six call sites, register at the single unconditional agent-creation point inside _run_agent's inner _run, immediately beside the existing _publish_turn_process_ownership(agent, effective_task_id) call, and unregister in the same existing finally that already calls _clear_turn_process_ownership(agent). One symmetric pair, six callers covered, mirroring a lifecycle idiom already accepted in that exact function — and the same "handle it here, once, covers every _run_agent() caller" reasoning the _ProviderAuthResolutionError branch a few lines below already uses.

The new registry is deliberately not _active_run_agents:

  • _active_run_agents is run_id-keyed and scoped to the public POST /v1/runs/{run_id}/stop API; shutdown needs everything, and six of the seven paths have no run_id.
  • Keeping them separate also avoids colliding with feat(api-server): support approval and stop on session chat stream #58856, which relocates _active_run_agents[run_id] = agent into _run_agent for its own purposes. interrupt_active_runs() dedupes by object identity, so if that lands and an agent ends up in both registries, it is still interrupted exactly once.

_pending_agent_requests is deliberately not covered, and the docstring says so: it counts admitted requests that have not constructed an agent yet, so there is no object to interrupt.

Cron is out of scope here — cron agents live in cron/scheduler.py's own module-level registry keyed by job_id, entirely outside this adapter, and #43085 covers that surface.

teknium1's ### Suggested changes on #63963, as an acceptance checklist

  • Track interruptable agents for every _run_agent() path included in _inflight_agent_runs, and interrupt that complete adapter-owned set during shutdown.

✅ One registration inside _run_agent covers all six callers by construction — _inflight_agent_runs and the registry are incremented/populated around the same body, so they cannot drift. interrupt_active_runs() walks that registry unioned with _active_run_agents, deduped by identity.

  • Add a timeout regression for a non-/v1/runs API turn that verifies agent.interrupt() is called.

✅ Two of them, both end-to-end through the real aiohttp handlers with the turn genuinely parked inside run_conversation on an executor thread: test_chat_completions_turn_is_interrupted and test_session_chat_sse_turn_is_interrupted. Each asserts the drain sees the turn (_active_api_run_count() == 1), that it is not in _running_agents, and then that agent.interrupt is called exactly once with the shutdown reason.

(request_hard_interrupt() is used rather than a bare agent.interrupt(reason), matching _interrupt_running_agents and the /v1/runs stop handler. For an unspecced mock it falls through to agent.interrupt(reason), so the assertion above is on the exact ABI named in the review.)

Related Issue

Context: #63529 (closed — its accounting half landed; this is the interrupt half). Not auto-closing it.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/platforms/api_server.py
    • __init__: add _shutdown_interruptible_agents, an adapter-owned registry of agents currently inside _run_agent(), keyed by id(). It holds a strong reference for the life of the turn, so an id() cannot be recycled while registered.
    • _run_agent() inner _run(): register beside _publish_turn_process_ownership(); unregister in the existing finally beside _clear_turn_process_ownership(). pop(..., None) is a no-op if _create_agent succeeded but the turn never reached registration.
    • new interrupt_active_runs(reason) -> int: walks _active_run_agents_shutdown_interruptible_agents, dedupes by object identity, calls request_hard_interrupt per agent, swallows and logs per-agent failures so one torn-down agent cannot strand the rest, and returns the number that accepted an interrupt.
  • gateway/run.py
  • tests/gateway/test_api_server_active_work_drain.py — 9 new tests.

How to Test

pytest tests/gateway/test_api_server_active_work_drain.py -v

14 passed (5 pre-existing + 9 new).

Red-before / green-after. Every hunk was reverted independently against clean origin/main and the tests re-run in their final location:

Production state Result
both prod files at origin/main 9 failed, 5 passed — all 9 new tests red, no pre-existing test disturbed
api_server.py fixed, run.py at origin/main 4 failed, 10 passed
run.py fixed, api_server.py at origin/main 7 failed, 7 passed
full fix, settle-window hunk alone reverted 1 failed, 13 passed
full fix 14 passed

The last row but one is the point: the settle-window change is independently load-bearing, not a cosmetic tag-along. test_settle_window_waits_for_interrupted_api_work drives the real runner.stop() down its drain-timeout path and asserts, via a deterministic poll counter rather than any wall-clock measurement, that the post-interrupt tool kill ran only after the interrupted API work had settled.

Adjacent suites, all green on this branch:

pytest tests/gateway/test_gateway_shutdown.py tests/gateway/test_cron_active_work_drain.py \
       tests/gateway/test_restart_resume_pending.py tests/gateway/test_clean_shutdown_marker.py \
       tests/agent/test_interrupt_compat.py          # 52 passed
pytest tests/gateway/test_api_server.py tests/gateway/test_session_api.py \
       tests/gateway/test_api_server_runs.py         # 127 passed

Manual: start the gateway with the API server enabled, send a long-running turn to POST /v1/chat/completions (non-streaming) or to the desktop app, then /restart. Before: the drain runs to its full timeout and the turn's tool subprocesses are killed from under it. After: the turn is interrupted cooperatively as soon as the drain times out, and the settle window lets it unwind before the tool kill.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — ran the touched file plus the adjacent gateway/API/shutdown suites (179 tests, all green); the full suite is not green on a stock local checkout, so I did not claim it
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.11.15

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — docstrings only; no user-facing docs describe this internal shutdown path
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A, no new config
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure in-process Python (dict bookkeeping, asyncio); no paths, no signals, no subprocess behavior touched
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Related / Positioning

…t /v1/runs

The shutdown drain ACCOUNTS for API-server work but never INTERRUPTS it.
`_drain_active_agents()` folds `_active_api_run_count()` into both its wait
loop and its `timed_out` verdict, while `_interrupt_running_agents()` iterates
`self._running_agents` only -- a dict no API turn ever enters, because the
API server owns its own agent lifecycle. `gateway/run.py` states the gap
against itself: "API-server / desk sessions have the same structural gap
(NousResearch#63529)."

The user-visible result is that every gateway restart with a live API or
desktop turn burns the full drain timeout and then runs
`_kill_tool_subprocesses("post-interrupt")`, which amputates the turn's tool
subprocesses with no cooperative interrupt and no resume marker.

There are seven API agent-entry points. Six funnel through `_run_agent()`
(both session-chat routes, and `/v1/chat/completions` + `/v1/responses` in
streaming and non-streaming form) and are counted by `_inflight_agent_runs`;
the seventh, `/v1/runs`, runs its own lifecycle and is counted through
`_active_run_tasks`. None of the six has a run_id, so the run_id-keyed
`_active_run_agents` cannot reach them, and only two pass `agent_ref` -- which
lands in a caller-local list, not a registry.

So register once at the single unconditional creation site inside
`_run_agent`, beside the existing `_publish_turn_process_ownership()` call,
and unregister in the same `finally` that already clears it. That one
symmetric pair covers all six callers. The registry is adapter-owned and
keyed by object identity, kept separate from `_active_run_agents` because
that dict is run_id-keyed and scoped to the public `/v1/runs` stop API.

`interrupt_active_runs()` then walks both registries, deduped by identity, so
the interrupt set matches the set the drain waits on. The settle window after
the interrupt now polls API work as well: the interrupt is cooperative, and
without this the window closes the instant `_running_agents` is empty -- which
it always is for API turns -- and the tool kill lands on a turn that was asked
to stop microseconds earlier.
Copilot AI lite review requested due to automatic review settings August 6, 2026 03:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a gateway shutdown/restart hang-and-amputate failure mode by ensuring all API-server in-flight agent turns (not only POST /v1/runs) are cooperatively interrupted when the shutdown drain times out, and by extending the post-interrupt settle window to wait for API work to unwind before tool subprocesses are killed.

Changes:

  • Add an adapter-owned registry for _run_agent()-created agents and expose APIServerAdapter.interrupt_active_runs() to interrupt /v1/runs agents plus all other API turn shapes.
  • Add a duck-typed GatewayRunner._interrupt_api_server_runs() hook and call it from _interrupt_running_agents().
  • Expand the settle window to poll _active_api_run_count() and add end-to-end regression tests covering interruption + settle behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
gateway/platforms/api_server.py Track _run_agent() agents for shutdown interruption and implement interrupt_active_runs() across API entrypoints.
gateway/run.py Add API-server interrupt hook and include API work in the post-interrupt settle window.
tests/gateway/test_api_server_active_work_drain.py Add regression tests proving non-/v1/runs API turns are interrupted and the settle window waits for API work to clear.
Suppressed comments (3)

gateway/platforms/api_server.py:1520

  • interrupt_active_runs() snapshots _shutdown_interruptible_agents with list(self._shutdown_interruptible_agents.values()). Because registrations/unregistrations happen in an executor thread, this can throw at runtime if the dict is mutated concurrently. Snapshot the values under a lock before iterating (and ensure writers also take the same lock).
        for agent in list(self._shutdown_interruptible_agents.values()):
            if agent is not None:
                # Dedupe by object identity — the two registries are disjoint
                # today (/v1/runs runs its own lifecycle, not _run_agent), but
                # an agent published to both must still be interrupted once.

gateway/platforms/api_server.py:6105

  • Registration into _shutdown_interruptible_agents happens inside the _run_agent() executor thread. This needs to take the same lock used by interrupt_active_runs() snapshots to avoid concurrent-mutation errors during shutdown.
                    # the _ProviderAuthResolutionError handler below lives here
                    # rather than in each route.  Only two callers pass
                    # ``agent_ref``, and only /v1/runs has a run_id, so neither
                    # is a usable hook for the rest.
                    self._shutdown_interruptible_agents[id(agent)] = agent

gateway/platforms/api_server.py:6239

  • Unregistration from _shutdown_interruptible_agents happens in the _run_agent() executor thread. This should also take the lock so shutdown-time snapshots/interrupts cannot race with removal and throw RuntimeError.
                        # Symmetric with the registration above: the turn is
                        # over, so it must not be interrupted by a later
                        # shutdown.  pop() is a no-op when _create_agent
                        # succeeded but the turn never reached registration.
                        self._shutdown_interruptible_agents.pop(id(agent), None)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1457 to 1461
# the dict holds a strong reference for the life of the turn, so an
# id() can never be recycled while it is still registered.
self._shutdown_interruptible_agents: Dict[int, Any] = {}
# Back-reference to the owning GatewayRunner (set by gateway/run.py)
# so /api/platforms/{platform}/events can resolve sibling adapters.

@briandevans briandevans Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The threading premise is right — _run() is submitted via loop.run_in_executor(None, _run), so registration and removal genuinely happen on an executor thread while interrupt_active_runs() reads from the event loop thread. The conclusion doesn't follow, though, and the contrast with the lock two lines away is the reason.

_publish_turn_process_ownership (api_server.py:767) holds _TURN_PROCESS_EPOCH_LOCK because it does a compound operation — epoch = next(_TURN_PROCESS_EPOCH_COUNTER) then _TURN_PROCESS_EPOCHS[task_id] = epoch — and _clear_turn_process_ownership (:787) does a check-then-act: if _TURN_PROCESS_EPOCHS.get(task_id) == epoch: del .... Both span two operations and would tear without a lock.

This registry has no compound invariant. Every access is a single operation:

  • self._shutdown_interruptible_agents[id(agent)] = agent — one atomic store.
  • self._shutdown_interruptible_agents.pop(id(agent), None) — one atomic delete.
  • list(self._shutdown_interruptible_agents.values()) — one atomic snapshot.

That last one is the specific concern raised, so to be concrete about why it can't raise RuntimeError: dictionary changed size during iteration: list(view) is a single C-level call that never re-enters the interpreter, so the eval loop has no bytecode boundary at which to switch threads inside it. The values are borrowed PyObject* refs and the key type is int, whose __hash__/__eq__ are C-level and cannot call back into Python either. This is why list(...) snapshotting is the established idiom here rather than an oversight — _interrupt_running_agents (run.py:9189) does list(self._running_agents.items()), and _sweep_orphaned_runs_once does the same with list(self._run_streams_created.items()) (api_server.py:6971 on this branch). I also ran 6 writer threads against 3 snapshotting threads on this exact access pattern for 6s: 0 RuntimeErrors.

Two threads also never contend for the same key: id(agent) is unique per live object, and the dict holds a strong reference for the life of the turn, so an id can't be recycled while it's registered.

On "miss registrations mid-shutdown" — a turn that registers after the snapshot is outside what a lock can fix; it's ordered by the drain, not by mutual exclusion. By the time _interrupt_running_agents() runs, _draining is set and _draining_response() is already rejecting new API turns with 503 (test_drain_refuses_every_agent_start_endpoint covers all five entry routes), and _drain_active_agents() has waited on _active_api_run_count() first.

So I'd rather not add the lock: held across request_hard_interrupt() it would block registering/unregistering executor threads from a shutdown path for the duration of an agent callback, and held only around the snapshot it would be equivalent to what list() already does. Happy to reconsider if there's a specific interleaving I've missed.

@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 6, 2026
kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Aug 7, 2026
…window exit

Review follow-up for the salvaged NousResearch#79881/NousResearch#63963 stack: the shutdown
interrupt fires exactly once, but work can materialize AFTER that one
shot on BOTH sibling paths:

- a /v1/runs task admitted before the drain populates
  _active_run_agents only once _create_agent returns
  (queued-before-agent window);
- a _running_agents entry claimed as _AGENT_PENDING_SENTINEL is
  promoted to the real agent by track_agent() on its own schedule,
  after the one-shot walk skipped the sentinel.

Either way the settle loop waited on work nothing signaled, and the
turn went straight to the post-interrupt tool-subprocess kill — the
exact amputation the fix exists to avoid, in a rarer window.

If any work is still live when the settle loop exits, re-invoke
_interrupt_running_agents (which already skips sentinels and folds in
the API-server helper) so late-materializing agents on either path get
the cooperative interrupt. Regression test drives the real stop() path
with an accelerated loop clock and asserts exactly two interrupt
signals.
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Superseded by #80917 (merging via auto-rebase) — your commit is cherry-picked there with authorship preserved, stacked on @necoweb3's #63963 base commit so both contributions survive in git history. The salvage re-resolved the cherry-pick conflicts hunk-by-hunk against current main (your branch predated the pinned-sessions/hygiene/heartbeat work, so a whole-file resolution would have reverted them), kept necoweb3's five tests alongside yours, and added one follow-up: re-signaling interrupts at settle-window exit for agents that materialize after the one-shot interrupt (the queued-before-agent window your PR's docstring called out, plus the _AGENT_PENDING_SENTINEL promotion sibling). Excellent work — the chokepoint registry, the mutation-grade e2e tests, and the entry-point table made this the rare PR that reviews itself. Thanks!

kshitijk4poor added a commit that referenced this pull request Aug 7, 2026
…window exit

Review follow-up for the salvaged #79881/#63963 stack: the shutdown
interrupt fires exactly once, but work can materialize AFTER that one
shot on BOTH sibling paths:

- a /v1/runs task admitted before the drain populates
  _active_run_agents only once _create_agent returns
  (queued-before-agent window);
- a _running_agents entry claimed as _AGENT_PENDING_SENTINEL is
  promoted to the real agent by track_agent() on its own schedule,
  after the one-shot walk skipped the sentinel.

Either way the settle loop waited on work nothing signaled, and the
turn went straight to the post-interrupt tool-subprocess kill — the
exact amputation the fix exists to avoid, in a rarer window.

If any work is still live when the settle loop exits, re-invoke
_interrupt_running_agents (which already skips sentinels and folds in
the API-server helper) so late-materializing agents on either path get
the cooperative interrupt. Regression test drives the real stop() path
with an accelerated loop clock and asserts exactly two interrupt
signals.
ma1138569845 pushed a commit to ma1138569845/dechnicAuditor-agent that referenced this pull request Aug 10, 2026
…window exit

Review follow-up for the salvaged NousResearch#79881/NousResearch#63963 stack: the shutdown
interrupt fires exactly once, but work can materialize AFTER that one
shot on BOTH sibling paths:

- a /v1/runs task admitted before the drain populates
  _active_run_agents only once _create_agent returns
  (queued-before-agent window);
- a _running_agents entry claimed as _AGENT_PENDING_SENTINEL is
  promoted to the real agent by track_agent() on its own schedule,
  after the one-shot walk skipped the sentinel.

Either way the settle loop waited on work nothing signaled, and the
turn went straight to the post-interrupt tool-subprocess kill — the
exact amputation the fix exists to avoid, in a rarer window.

If any work is still live when the settle loop exits, re-invoke
_interrupt_running_agents (which already skips sentinels and folds in
the API-server helper) so late-materializing agents on either path get
the cooperative interrupt. Regression test drives the real stop() path
with an accelerated loop clock and asserts exactly two interrupt
signals.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…window exit

Review follow-up for the salvaged NousResearch#79881/NousResearch#63963 stack: the shutdown
interrupt fires exactly once, but work can materialize AFTER that one
shot on BOTH sibling paths:

- a /v1/runs task admitted before the drain populates
  _active_run_agents only once _create_agent returns
  (queued-before-agent window);
- a _running_agents entry claimed as _AGENT_PENDING_SENTINEL is
  promoted to the real agent by track_agent() on its own schedule,
  after the one-shot walk skipped the sentinel.

Either way the settle loop waited on work nothing signaled, and the
turn went straight to the post-interrupt tool-subprocess kill — the
exact amputation the fix exists to avoid, in a rarer window.

If any work is still live when the settle loop exits, re-invoke
_interrupt_running_agents (which already skips sentinels and folds in
the API-server helper) so late-materializing agents on either path get
the cooperative interrupt. Regression test drives the real stop() path
with an accelerated loop clock and asserts exactly two interrupt
signals.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…window exit

Review follow-up for the salvaged NousResearch#79881/NousResearch#63963 stack: the shutdown
interrupt fires exactly once, but work can materialize AFTER that one
shot on BOTH sibling paths:

- a /v1/runs task admitted before the drain populates
  _active_run_agents only once _create_agent returns
  (queued-before-agent window);
- a _running_agents entry claimed as _AGENT_PENDING_SENTINEL is
  promoted to the real agent by track_agent() on its own schedule,
  after the one-shot walk skipped the sentinel.

Either way the settle loop waited on work nothing signaled, and the
turn went straight to the post-interrupt tool-subprocess kill — the
exact amputation the fix exists to avoid, in a rarer window.

If any work is still live when the settle loop exits, re-invoke
_interrupt_running_agents (which already skips sentinels and folds in
the API-server helper) so late-materializing agents on either path get
the cooperative interrupt. Regression test drives the real stop() path
with an accelerated loop clock and asserts exactly two interrupt
signals.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants