fix(runtime): defer host cancellation through the whole terminal finalization (#6914)

* fix(runtime): defer host cancellation through the whole terminal finalization

Cancelling run_agent() during an early terminal-finalization await (e.g.
the delivery-receipt write) escaped the surrounding `except Exception`
guards, because asyncio.CancelledError derives from BaseException. The
cancellation then skipped the remaining terminal tail: terminal status
persistence, the completion hook, task-stop notification, and the stream
END marker.

Add `_await_despite_host_cancellation`, which shields each terminal step
so the started awaitable keeps running under host cancellation, defers the
interruption (uncancelling the task), and re-awaits the shield until the
step completes. Apply it to the durable finalization stages; the original
interruption is still re-raised after publish_end.

* fix(runtime): bound terminal deferral and shield abort-path cancellation

* fix(runtime): retain owned finalization through cancellation

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
万家良 2026-10-16 07:25:19 +08:00 committed by GitHub
parent 33a3970c6b
commit 4f1c848744
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 732 additions and 360 deletions

View File

@ -2281,6 +2281,8 @@ async with runtime:
`SubagentRuntime.stop()` waits for its owned batch service to finish before propagating caller cancellation, including repeated cancellation. This drain has no timeout; repository operations and child cleanup must terminate. If service shutdown also fails or is cancelled, the first caller cancellation is preserved with the service failure as its cause.
Gateway run finalization also retains ownership through caller cancellation: it finishes terminal persistence, stream completion, and resource cleanup before propagating the interruption. Backend I/O must terminate or enforce its own timeout. Gateway shutdown includes workers still finalizing after their terminal status commits; an expired shutdown drain budget is logged and does not cancel their outstanding writes.
The factory still does not load YAML or create SQL infrastructure: the caller supplies the config snapshot, repository, and lifecycle. Because it accepts a caller-owned `system_prompt`, direct integrations also own any model-visible wording about those limits; the default middleware enforces the runtime limits regardless. The factory does not mount the Gateway owner-scoped HTTP routes or Web UI, so direct applications must expose their own result API/UI if they need those surfaces. For ordinary delegation only, `SubagentRuntime(...)` needs no asynchronous startup.
Administrators can add, edit, disable, and delete reusable worker definitions from **Settings → Subagents**. Built-in and `config.yaml` definitions remain visible there as read-only entries. The default Lead Agent can use every enabled runtime sub-agent; each page-created Custom Agent can instead allow all, none, or a selected set. That selection is enforced both in the model-visible directory and by the server-side `task` tool. Managed definitions are deployment-wide in this version and follow `agent_storage.backend`: atomic files for a local deployment or the shared application database for multiple instances.

View File

@ -151,7 +151,7 @@ The first `RunManager.list_by_thread()` hydration page uses a 100-row floor or
the number of required IDs, whichever is larger; missing exact runs use targeted
`get()` calls.
**Terminal run cleanup explicitly breaks graph-scoped references while preserving the existing `RunRecord` grace period.** Every `agent.astream()` iterator is closed in `_stream_once`, including abort/exception/early-break paths. A close failure after an abort is warning-only and cannot replace the user-requested `interrupted` outcome; normal-completion close failures still surface, and an in-flight stream exception remains authoritative over a secondary close failure. Journal construction and cancellable preflight work (including MCP task projection and the prior-finalization wait) live inside the worker's guarded body, so cancellation before agent startup still terminalizes the run and closes its stream. `run_agent()` wraps the complete terminal-finalization sequence in an outer teardown guard, so cancellation or failure from any terminal-stage await cannot skip `RunJournal.close()`, removal of the journal, `__pregel_runtime`, and internal runtime-context values from every runnable config, or release of local graph/payload references. That guard schedules bridge cleanup, run-record cleanup, and cyclic GC even when interruption happens before the terminal stream marker or terminal publication itself fails, so neither a cancelled observer nor a delivery-backend outage can strand process-local run state. A non-`Exception` `BaseException` caught while awaiting the completion hook or task-stop notification (including host-task cancellation) is deferred through the ordinary remaining finalization, with the first interruption preserved and every caught host-task `CancelledError` balanced by calling `Task.uncancel()` until the current task’s cumulative cancellation count is clear. Task-stop fan-out runs in one child task and every host wait uses `shield`, so repeated cancellation of the worker cannot cancel that fan-out or skip later observers; the worker keeps awaiting the same child task. A rogue observer that raises its own `CancelledError` remains contained by the extension dispatcher and distinguishable from host cancellation. This guarantee applies only to cancellation caught during those hook stages: clearing the finalizing barrier and publishing END remain direct awaits, so another cancellation in the subsequent critical tail retains forceful-termination semantics instead of creating an unbounded shield. If that tail completes without another interruption, the first deferred interruption is re-raised after END; a barrier-clear failure prevents END publication, while an END failure is raised after the barrier is clear. `RunJournal.flush()` clears its `_pending_progress_task` after awaiting or cancelling it; ordinary `close()` detaches the event store/progress reporter and clears callback bookkeeping only after that flush succeeds, preserving the buffer for retry on a transient store failure. A fenced worker instead calls `close(flush=False)`, which cancels pending journal work and detaches without initiating another event-store write after lease ownership is lost; its final detach runs even if a second cancellation interrupts pending-task shutdown. `RunManager.cleanup(run_id)` retains the process-local `RunRecord`, completed task, and request payload for its default 300-second local join/status window before releasing them, and evicts only when a `RunStore` backs the manager — without one there is no hydration fallback, so the record is retained rather than silently dropping the run's history. Durable history remains in `RunStore`; `StreamBridge` data keeps its separate 60-second late-subscriber window, and both cleanup coroutines run in a fresh empty `contextvars.Context`. A contextless full cyclic-GC pass, coalesced to at most once every 10 seconds and dispatched through the default executor, bounds the lifetime of unreachable LangGraph callback/loop cycles without synchronously walking the heap in the event-loop timer; passes taking at least 100 ms are logged at INFO because CPython GC may still impose interpreter-level pauses.
**Terminal run cleanup explicitly breaks graph-scoped references while preserving the existing `RunRecord` grace period.** Every `agent.astream()` iterator is closed in `_stream_once`, including abort/exception/early-break paths. A close failure after an abort is warning-only and cannot replace the user-requested `interrupted` outcome; normal-completion close failures still surface, and an in-flight stream exception remains authoritative over a secondary close failure. Journal construction and cancellable preflight work (including MCP task projection and the prior-finalization wait) live inside the worker's guarded body, so cancellation before agent startup still terminalizes the run and closes its stream. `run_agent()` wraps the complete terminal-finalization sequence in an outer teardown guard, so cancellation or failure from any terminal-stage await cannot skip `RunJournal.close()`, removal of the journal, `__pregel_runtime`, and internal runtime-context values from every runnable config, or release of local graph/payload references. That guard schedules bridge cleanup, run-record cleanup, and cyclic GC even when interruption happens before the terminal stream marker or terminal publication itself fails, so neither a cancelled observer nor a delivery-backend outage can strand process-local run state. The registered worker owns a named `run-finalization-<run_id>` task through the whole terminal sequence and resource teardown. Every host wait shields and drains that same task across repeated cancellation; there is no per-step cancellation deadline that can detach a write. Backend operations must provide their own I/O bounds. The first host cancellation stays in the worker, independent of step results, and is re-raised after finalization settles; an escaping finalizer failure is retained as its cause. The optional completion hook runs in an owned child task and still receives host cancellation, including one deferred before the hook starts; the finalizer awaits its settlement before task-stop observers and END. Task-stop fan-out retains its existing observer timeout and rogue-cancellation containment. Clearing the finalizing barrier and publishing END preserve their ordering: a barrier-clear failure prevents END, while END failure surfaces after the barrier is clear. Shutdown includes every terminal worker whose task is still active, even after its durable status commits. Its existing bounded drain logs pending workers and retains their ownership; expiry is not permission to cancel or retire a finalizer. `RunJournal.flush()` clears its `_pending_progress_task` after awaiting or cancelling it; ordinary `close()` detaches the event store/progress reporter and clears callback bookkeeping only after that flush succeeds, preserving the buffer for retry on a transient store failure. A fenced worker instead calls `close(flush=False)`, which cancels pending journal work and detaches without initiating another event-store write after lease ownership is lost; its final detach runs even if a second cancellation interrupts pending-task shutdown. `RunManager.cleanup(run_id)` retains the process-local `RunRecord`, completed task, and request payload for its default 300-second local join/status window before releasing them, and evicts only when a `RunStore` backs the manager — without one there is no hydration fallback, so the record is retained rather than silently dropping the run's history. Durable history remains in `RunStore`; `StreamBridge` data keeps its separate 60-second late-subscriber window, and both cleanup coroutines run in a fresh empty `contextvars.Context`. A contextless full cyclic-GC pass, coalesced to at most once every 10 seconds and dispatched through the default executor, bounds the lifetime of unreachable LangGraph callback/loop cycles without synchronously walking the heap in the event-loop timer; passes taking at least 100 ms are logged at INFO because CPython GC may still impose interpreter-level pauses.
**Checkpoint channel benchmark**: `scripts/benchmark/checkpoint/bench_channels.py`
runs paired `full`/`delta` message-only StateGraphs in a fresh child process per

View File

@ -85,10 +85,13 @@ writes. `RunRecord.terminal_commit_pending` keeps `_renew_leases()` renewing tha
still-active row until the commit is attempted; a renewal rejected by the worker's
own commit is confirmed by re-reading the row, while a peer claim fences the run.
Never select runs for renewal by local status alone. Shutdown's drain treats the
same state as active: such runs are awaited (never cancelled — cancellation would
skip their terminal tail) so the duration checkpoint and terminal commit land
before checkpointer teardown. A drain timeout leaves staged statuses and commit
barriers intact without persisting `interrupted`. A finalizer that raises is
same state as active, and also awaits committed terminal workers still executing
hooks, END or resource cleanup. The registered worker shields and drains one owned
finalizer across repeated host cancellation, preserving the first interruption even
if a step fails. There is no deadline that cancels or detaches writes; backend I/O
must be bounded by its backend. Only the optional completion hook receives host
cancellation, and its child task is drained before the terminal tail continues.
A drain timeout leaves staged statuses and commit barriers intact without persisting `interrupted`. A finalizer that raises is
logged with its run ID, staged status and exception while the other runs continue
draining. Coverage: `tests/test_gateway_run_drain_shutdown.py`.

View File

@ -2552,11 +2552,10 @@ class RunManager:
during the drain keeps its real terminal status instead of being
blanket-overwritten. Runs whose terminal status is staged in memory with
a deferred commit are still writing their final duration checkpoint and
then commit that status, so they are awaited in the same bounded drain —
without cancellation, which would skip their terminal tail (#5542),
exactly as ``_renew_leases`` keeps them active through
``_awaits_terminal_commit``. The whole drain, including the trailing status
persistence, is bounded by ``timeout`` so a run stuck in cleanup (or a
then commit that status. Committed terminal workers may still be running
hooks, publishing END or closing resources. Both remain visible in this
bounded drain and are awaited without cancellation (#5542). The whole
drain, including the trailing status persistence, is bounded by ``timeout`` so a run stuck in cleanup (or a
slow store under DB pressure) cannot hang worker shutdown — the
precondition for the signal-reentrancy deadlock guarded by
``app.gateway.app._SHUTDOWN_HOOK_TIMEOUT_SECONDS``. Runs still active
@ -2567,12 +2566,11 @@ class RunManager:
async with self._lock:
inflight = [record for record in self._runs.values() if record.status in (RunStatus.pending, RunStatus.running) and record.task is not None and not record.task.done()]
# A run whose terminal status is staged in memory (deferred commit)
# keeps writing the duration checkpoint before committing that
# status. Drain it too — but never cancel it — so the write lands
# while the checkpointer is still open. ``_renew_leases`` already
# treats these runs as active through ``_awaits_terminal_commit``.
finalizing = [record for record in self._runs.values() if self._awaits_terminal_commit(record) and record.status not in (RunStatus.pending, RunStatus.running) and record.task is not None and not record.task.done()]
# Terminal workers still own finalization, including after the
# durable commit (hooks, END and journal/sandbox cleanup). Await them
# without cancellation so resource teardown cannot overtake a worker
# that settles within this drain's budget.
finalizing = [record for record in self._runs.values() if record.status not in (RunStatus.pending, RunStatus.running) and record.task is not None and not record.task.done()]
finalizing_ids = {record.run_id for record in finalizing}
for record in inflight:
record.abort_action = "interrupt"

View File

@ -890,6 +890,35 @@ async def _await_task_stop_after_host_cancellation(
deferred = _defer_finalization_interrupt(deferred, exc)
async def _drain_run_finalization(task: asyncio.Task[None], *, on_host_cancel: Callable[[], None] | None = None) -> None:
"""Retain ownership through repeated cancellation, including failed cleanup.
The registered worker stays alive until its finalizer settles, keeping it
visible to shutdown. There is deliberately no timeout: abandoning an owned
write would let resource teardown race it. Backends must bound their own I/O.
"""
deferred: BaseException | None = None
while True:
try:
await asyncio.shield(task)
break
except asyncio.CancelledError as exc:
host = asyncio.current_task()
if host is None or not host.cancelling():
if deferred is not None:
raise deferred from exc
raise
deferred = _defer_finalization_interrupt(deferred, exc)
if on_host_cancel is not None:
on_host_cancel()
except BaseException as exc:
if deferred is not None:
raise deferred from exc
raise
if deferred is not None:
raise deferred
async def run_agent(
bridge: StreamBridge,
run_manager: RunManager,
@ -1648,367 +1677,375 @@ async def run_agent(
)
finally:
try:
if record.ownership_lost:
logger.warning(
"Skipping durable finalization for run %s because this worker no longer owns its lease",
run_id,
)
completion_hook_task: asyncio.Task[None] | None = None
host_interrupted = False
if not record.ownership_lost and _is_edit_replay_run(record) and record.status != RunStatus.success:
if not record.finalizing:
await run_manager.set_finalizing(run_id, True)
try:
if not checkpoint_rollback_completed:
checkpoint_rollback_completed = await _rollback_to_pre_run_checkpoint(
accessor=accessor,
checkpointer=checkpointer,
thread_id=thread_id,
run_id=run_id,
rollback_point=rollback_point,
snapshot_capture_failed=snapshot_capture_failed,
)
if checkpoint_rollback_completed:
await _publish_restored_checkpoint_values(
bridge=bridge,
run_id=run_id,
accessor=accessor,
thread_id=thread_id,
)
logger.info("Run %s edit replay restored pre-run checkpoint %s", run_id, pre_run_checkpoint_id)
except Exception:
logger.warning("Run %s edit replay rollback failed", run_id, exc_info=True)
def interrupt_completion_hook() -> None:
nonlocal host_interrupted
host_interrupted = True
if completion_hook_task is not None:
completion_hook_task.cancel()
# Persist any subagent step events still buffered (#3779) — including on
# abort/exception paths, where the stream loop broke before its own flush.
if not record.ownership_lost and subagent_events is not None:
await subagent_events.flush()
async def finalize() -> None:
nonlocal completion_hook_task
nonlocal accessor, agent, agent_result, cancel_action, checkpoint_rollback_completed
nonlocal completion_data, deferred_finalization_interrupt, delivery_content, goal_completion, goal_evaluator_model
nonlocal graph_input, pre_run_workspace_snapshot, produced_output_paths, rollback_point, runtime
nonlocal runtime_ctx, subagent_events, task_info, task_store
if not record.ownership_lost and event_store is not None and pre_run_workspace_snapshot is not None:
try:
await record_workspace_changes(
event_store,
thread_id,
try:
if record.ownership_lost:
logger.warning(
"Skipping durable finalization for run %s because this worker no longer owns its lease",
run_id,
pre_run_workspace_snapshot,
user_id=workspace_changes_user_id,
extra_excluded_dir_names=workspace_excluded_dir_names,
)
except Exception:
logger.warning("Failed to record workspace changes for run %s", run_id, exc_info=True)
# Flush buffered journal events before the terminal receipt. The
# receipt uses a run-scoped idempotent write shared with recovery, then
# the staged terminal status is persisted. This ordering closes the
# crash window where a terminal run could otherwise outlive its receipt.
# A fenced worker leaves receipt recovery to the peer that claimed it.
if not record.ownership_lost and journal is not None:
try:
await journal.flush()
except Exception:
logger.warning("Failed to flush journal for run %s", run_id, exc_info=True)
if not record.ownership_lost and _is_edit_replay_run(record) and record.status != RunStatus.success:
if not record.finalizing:
await run_manager.set_finalizing(run_id, True)
try:
if not checkpoint_rollback_completed:
checkpoint_rollback_completed = await _rollback_to_pre_run_checkpoint(
accessor=accessor,
checkpointer=checkpointer,
thread_id=thread_id,
run_id=run_id,
rollback_point=rollback_point,
snapshot_capture_failed=snapshot_capture_failed,
)
if checkpoint_rollback_completed:
await _publish_restored_checkpoint_values(
bridge=bridge,
run_id=run_id,
accessor=accessor,
thread_id=thread_id,
)
logger.info("Run %s edit replay restored pre-run checkpoint %s", run_id, pre_run_checkpoint_id)
except Exception:
logger.warning("Run %s edit replay rollback failed", run_id, exc_info=True)
if delivery_content is None:
if produced_output_paths is None:
produced_output_paths = await _produced_output_paths(
# Persist any subagent step events still buffered (#3779) — including on
# abort/exception paths, where the stream loop broke before its own flush.
if not record.ownership_lost and subagent_events is not None:
await subagent_events.flush()
if not record.ownership_lost and event_store is not None and pre_run_workspace_snapshot is not None:
try:
await record_workspace_changes(
event_store,
thread_id,
run_id,
pre_run_workspace_snapshot,
thread_id=thread_id,
user_id=workspace_changes_user_id,
extra_excluded_dir_names=workspace_excluded_dir_names,
)
delivery_content = _delivery_content_with_outputs(journal.get_delivery_content(), produced_output_paths)
receipt_persisted = await _persist_delivery_receipt(
event_store,
thread_id=thread_id,
run_id=run_id,
content=delivery_content,
)
if produced_output_paths and record.status == RunStatus.success and not receipt_persisted:
await run_manager.set_status(
run_id,
RunStatus.error,
error=_DELIVERY_RECEIPT_FAILED_ERROR,
persist=False,
)
if not record.ownership_lost and journal is not None and persist_completion:
try:
# Advance the final completion fields and timestamp without
# terminalizing the durable row. That active row continues to
# fence peer checkpoint writers through the duration write.
completion_data = journal.get_completion_data()
await run_manager.update_finalizing_progress(run_id, **completion_data)
except Exception:
logger.warning("Failed to persist finalizing run progress for %s (non-fatal)", run_id, exc_info=True)
# Keep the durable run row active through its final duration checkpoint
# write. A peer Gateway admits history migration from the durable row,
# not this worker's staged terminal status; terminalizing first would
# let that migration read an unfinished lifetime and race this write.
if started and not record.ownership_lost and checkpointer is not None and record.status == RunStatus.success:
try:
created = datetime.fromisoformat(record.created_at.replace("Z", "+00:00"))
updated = datetime.fromisoformat(record.updated_at.replace("Z", "+00:00"))
# Match legacy history semantics: turn_duration is the whole
# RunRecord lifetime in integer seconds, including admission
# delay. Persist zero for sub-second successful turns.
duration = max(0, int((updated - created).total_seconds()))
await _persist_run_duration(
checkpointer=checkpointer,
thread_id=thread_id,
run_id=run_id,
duration_seconds=duration,
)
except Exception:
logger.debug("Failed to persist run duration for thread %s run %s (non-fatal)", thread_id, run_id)
if scheduled_goal is not None and not record.ownership_lost and checkpointer is not None:
if record.abort_event.is_set() and record.abort_action == "rollback" and not checkpoint_rollback_completed:
await _finish_cancellation("rollback")
# Scheduled goals are occurrence-scoped. Keep the real run slot
# active through cleanup so a peer's next user turn cannot
# inherit this goal. Ordinary goals retain terminal-first cleanup.
cleanup_task = asyncio.create_task(
_clear_scheduled_goal(goal=scheduled_goal, record=record, run_manager=run_manager, bridge=bridge, checkpointer=checkpointer, owned_admission=True),
name=f"scheduled-goal-cleanup-{run_id}",
)
try:
deferred_finalization_interrupt = await _await_task_stop_after_host_cancellation(cleanup_task, deferred_finalization_interrupt)
except Exception:
logger.warning("Could not remove scheduled goal for run %s", run_id, exc_info=True)
await run_manager.set_status(run_id, RunStatus.error, error="Scheduled goal cleanup failed", persist=False)
if not record.ownership_lost and (event_store is not None or scheduled_goal is not None):
try:
# Even after bounded receipt retries are exhausted, persist the
# real worker outcome. Leaving a successful row inflight would
# let lease recovery rewrite it as an error with a synthetic
# zero receipt.
if record.abort_event.is_set():
if scheduled_goal is not None:
# A cancel may arrive while scheduled cleanup was
# awaiting its saver. The local status may still be
# staged success; apply the accepted action, including
# rollback, before committing any terminal outcome.
await _finish_cancellation(record.abort_action, restore_checkpoint=not checkpoint_rollback_completed)
await run_manager.persist_current_status(run_id)
else:
cancel_action = await run_manager.set_status_if_not_cancelled(
run_id,
record.status,
error=record.error,
stop_reason=record.stop_reason,
goal_verdict=record.goal_verdict,
)
if cancel_action is not None:
await _finish_cancellation(cancel_action)
await run_manager.persist_current_status(run_id)
except Exception:
logger.warning("Failed to persist terminal status for run %s after delivery receipt attempts", run_id, exc_info=True)
# The deferred commit has been attempted. A failed write is left to
# completion fallback or lease recovery, as before.
record.terminal_commit_pending = False
if not record.ownership_lost and journal is not None and persist_completion:
try:
# Persist token usage + convenience fields to RunStore
completion_data = completion_data or journal.get_completion_data()
await run_manager.update_run_completion(run_id, status=record.status.value, **completion_data)
except Exception:
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
if scheduled_goal is not None and not record.ownership_lost:
if not await run_manager.persist_current_status(run_id):
await run_manager._mark_ownership_lost(record, reason="Scheduled run terminal status could not be confirmed after goal cleanup.", require_active=False)
# Late cancellation can still drain a rollback after goal
# cleanup. Retain admission/heartbeat protection through that
# rollback and the actual durable terminal acknowledgement.
record.scheduled_goal_cleanup_pending = False
# A satisfied evaluator is only a candidate until artifact delivery,
# receipt persistence and durable cancellation arbitration have ended.
# Status writes are best-effort in single-worker mode: confirm the
# existing outcome before deleting recoverable goal state.
if scheduled_goal is None and goal_completion is not None and record.status == RunStatus.success and not record.abort_event.is_set() and not record.ownership_lost:
try:
if await run_manager.persist_current_status(run_id):
await _clear_completed_goal(
candidate=goal_completion,
record=record,
run_manager=run_manager,
bridge=bridge,
accessor=accessor,
checkpointer=checkpointer,
)
except Exception:
logger.warning("Could not finalize satisfied goal for thread %s", thread_id, exc_info=True)
if started and not record.ownership_lost and checkpointer is not None and record.status == RunStatus.interrupted and not _is_edit_replay_run(record):
try:
await run_manager.wait_for_prior_finalizing(thread_id, run_id)
if not await run_manager.has_later_started_run(thread_id, run_id):
await _ensure_interrupted_title(checkpointer=checkpointer, thread_id=thread_id, app_config=ctx.app_config, graph_input=graph_input)
except Exception:
logger.debug("Failed to generate interrupted title for thread %s (non-fatal)", thread_id)
# Sync title from checkpoint to threads_meta.display_name
if started and not record.ownership_lost and checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
if ckpt_tuple is not None:
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
title = ckpt.get("channel_values", {}).get("title")
if title:
await thread_store.update_display_name(thread_id, title)
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
# Update threads_meta status based on run outcome
if started and not record.ownership_lost and thread_store is not None:
try:
final_status = "idle" if record.status == RunStatus.success else record.status.value
await thread_store.update_status(thread_id, final_status)
except Exception:
logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id)
if started and not record.ownership_lost and accessor is not None and record.status == RunStatus.success and not record.abort_event.is_set() and record.human_input_request is None:
try:
from deerflow.agents.human_input import extract_unanswered_human_input_request
record.human_input_request = extract_unanswered_human_input_request(await _materialized_checkpoint_messages(accessor, thread_id))
except Exception:
logger.debug("Failed to inspect trailing human_input_request for run %s (non-fatal)", run_id, exc_info=True)
if not record.ownership_lost and not getattr(record, "terminal_status_persisted", True):
try:
await run_manager.persist_current_status(run_id)
except Exception:
logger.warning("Failed to confirm terminal status for run %s before completion hook", run_id, exc_info=True)
if not record.ownership_lost and getattr(record, "terminal_status_persisted", True) and ctx.on_run_completed is not None:
try:
await ctx.on_run_completed(record)
except Exception:
logger.warning("Run completion hook failed for %s (non-fatal)", run_id, exc_info=True)
except BaseException as exc:
# A terminal hook must not leave replacement runs blocked or
# stream consumers waiting indefinitely.
deferred_finalization_interrupt = _defer_finalization_interrupt(
deferred_finalization_interrupt,
exc,
)
logger.warning(
"Run completion hook interrupted for %s; completing finalization first",
run_id,
)
if task_info is not None and task_store is not None:
# Keep the finalizing barrier held until stop observers finish, so
# a same-thread replacement cannot overlap this task's lifecycle.
task_stop = asyncio.create_task(
notify_task_stop(
extensions,
task_store,
task_info,
lead_task_outcome(
aborted=(record.abort_event.is_set() or record.status == RunStatus.interrupted),
succeeded=record.status == RunStatus.success,
),
timeout=_EXTENSION_TASK_NOTIFY_TIMEOUT_SECONDS,
),
name=f"extension-task-stop-{run_id}",
)
try:
deferred_finalization_interrupt = await _await_task_stop_after_host_cancellation(
task_stop,
deferred_finalization_interrupt,
)
except Exception:
logger.warning(
"Extension task-stop notification failed for run %s (non-fatal)",
run_id,
exc_info=True,
)
except BaseException as exc:
# Cancellation here must not strand the finalizing barrier or
# leave stream consumers waiting for the end frame.
deferred_finalization_interrupt = _defer_finalization_interrupt(
deferred_finalization_interrupt,
exc,
)
logger.warning(
"Extension task-stop notification interrupted for run %s; completing cleanup first",
run_id,
)
if record.finalizing:
await run_manager.set_finalizing(run_id, False)
await bridge.publish_end(run_id)
if deferred_finalization_interrupt is not None:
raise deferred_finalization_interrupt
finally:
try:
if journal is not None:
try:
await journal.close(flush=not record.ownership_lost)
except Exception:
logger.warning("Failed to close journal for run %s", run_id, exc_info=True)
logger.warning("Failed to record workspace changes for run %s", run_id, exc_info=True)
# Flush buffered journal events before the terminal receipt. The
# receipt uses a run-scoped idempotent write shared with recovery, then
# the staged terminal status is persisted. This ordering closes the
# crash window where a terminal run could otherwise outlive its receipt.
# A fenced worker leaves receipt recovery to the peer that claimed it.
if not record.ownership_lost and journal is not None:
try:
await journal.flush()
except Exception:
logger.warning("Failed to flush journal for run %s", run_id, exc_info=True)
if delivery_content is None:
if produced_output_paths is None:
produced_output_paths = await _produced_output_paths(pre_run_workspace_snapshot, thread_id=thread_id, user_id=workspace_changes_user_id, extra_excluded_dir_names=workspace_excluded_dir_names)
delivery_content = _delivery_content_with_outputs(journal.get_delivery_content(), produced_output_paths)
receipt_persisted = await _persist_delivery_receipt(event_store, thread_id=thread_id, run_id=run_id, content=delivery_content)
if produced_output_paths and record.status == RunStatus.success and not receipt_persisted:
await run_manager.set_status(run_id, RunStatus.error, error=_DELIVERY_RECEIPT_FAILED_ERROR, persist=False)
if not record.ownership_lost and journal is not None and persist_completion:
try:
# Advance the final completion fields and timestamp without
# terminalizing the durable row. That active row continues to
# fence peer checkpoint writers through the duration write.
completion_data = journal.get_completion_data()
await run_manager.update_finalizing_progress(run_id, **completion_data)
except Exception:
logger.warning("Failed to persist finalizing run progress for %s (non-fatal)", run_id, exc_info=True)
# Keep the durable run row active through its final duration checkpoint
# write. A peer Gateway admits history migration from the durable row,
# not this worker's staged terminal status; terminalizing first would
# let that migration read an unfinished lifetime and race this write.
if started and not record.ownership_lost and checkpointer is not None and record.status == RunStatus.success:
try:
created = datetime.fromisoformat(record.created_at.replace("Z", "+00:00"))
updated = datetime.fromisoformat(record.updated_at.replace("Z", "+00:00"))
# Match legacy history semantics: turn_duration is the whole
# RunRecord lifetime in integer seconds, including admission
# delay. Persist zero for sub-second successful turns.
duration = max(0, int((updated - created).total_seconds()))
await _persist_run_duration(checkpointer=checkpointer, thread_id=thread_id, run_id=run_id, duration_seconds=duration)
except Exception:
logger.debug("Failed to persist run duration for thread %s run %s (non-fatal)", thread_id, run_id)
if scheduled_goal is not None and not record.ownership_lost and checkpointer is not None:
if record.abort_event.is_set() and record.abort_action == "rollback" and not checkpoint_rollback_completed:
await _finish_cancellation("rollback")
# Scheduled goals are occurrence-scoped. Keep the real run slot
# active through cleanup so a peer's next user turn cannot
# inherit this goal. Ordinary goals retain terminal-first cleanup.
cleanup_task = asyncio.create_task(
_clear_scheduled_goal(goal=scheduled_goal, record=record, run_manager=run_manager, bridge=bridge, checkpointer=checkpointer, owned_admission=True),
name=f"scheduled-goal-cleanup-{run_id}",
)
try:
deferred_finalization_interrupt = await _await_task_stop_after_host_cancellation(cleanup_task, deferred_finalization_interrupt)
except Exception:
logger.warning("Could not remove scheduled goal for run %s", run_id, exc_info=True)
await run_manager.set_status(run_id, RunStatus.error, error="Scheduled goal cleanup failed", persist=False)
if not record.ownership_lost and (event_store is not None or scheduled_goal is not None):
try:
# Even after bounded receipt retries are exhausted, persist the
# real worker outcome. Leaving a successful row inflight would
# let lease recovery rewrite it as an error with a synthetic
# zero receipt.
if record.abort_event.is_set():
if scheduled_goal is not None:
# A cancel may arrive while scheduled cleanup was
# awaiting its saver. The local status may still be
# staged success; apply the accepted action, including
# rollback, before committing any terminal outcome.
await _finish_cancellation(record.abort_action, restore_checkpoint=not checkpoint_rollback_completed)
await run_manager.persist_current_status(run_id)
else:
cancel_action = await run_manager.set_status_if_not_cancelled(run_id, record.status, error=record.error, stop_reason=record.stop_reason, goal_verdict=record.goal_verdict)
if cancel_action is not None:
await _finish_cancellation(cancel_action)
await run_manager.persist_current_status(run_id)
except Exception:
logger.warning("Failed to persist terminal status for run %s after delivery receipt attempts", run_id, exc_info=True)
# The deferred commit has been attempted. A failed write is left to
# completion fallback or lease recovery, as before.
record.terminal_commit_pending = False
if not record.ownership_lost and journal is not None and persist_completion:
try:
# Persist token usage + convenience fields to RunStore
completion_data = completion_data or journal.get_completion_data()
await run_manager.update_run_completion(run_id, status=record.status.value, **completion_data)
except Exception:
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
if scheduled_goal is not None and not record.ownership_lost:
status_confirmed = await run_manager.persist_current_status(run_id)
if not status_confirmed:
await run_manager._mark_ownership_lost(record, reason="Scheduled run terminal status could not be confirmed after goal cleanup.", require_active=False)
# Late cancellation can still drain a rollback after goal
# cleanup. Retain admission/heartbeat protection through that
# rollback and the actual durable terminal acknowledgement.
record.scheduled_goal_cleanup_pending = False
# A satisfied evaluator is only a candidate until artifact delivery,
# receipt persistence and durable cancellation arbitration have ended.
# Status writes are best-effort in single-worker mode: confirm the
# existing outcome before deleting recoverable goal state.
if scheduled_goal is None and goal_completion is not None and record.status == RunStatus.success and not record.abort_event.is_set() and not record.ownership_lost:
try:
status_confirmed = await run_manager.persist_current_status(run_id)
if status_confirmed:
await _clear_completed_goal(
candidate=goal_completion,
record=record,
run_manager=run_manager,
bridge=bridge,
accessor=accessor,
checkpointer=checkpointer,
)
except Exception:
logger.warning("Could not finalize satisfied goal for thread %s", thread_id, exc_info=True)
if started and not record.ownership_lost and checkpointer is not None and record.status == RunStatus.interrupted and not _is_edit_replay_run(record):
try:
await run_manager.wait_for_prior_finalizing(thread_id, run_id)
if not await run_manager.has_later_started_run(thread_id, run_id):
await _ensure_interrupted_title(checkpointer=checkpointer, thread_id=thread_id, app_config=ctx.app_config, graph_input=graph_input)
except Exception:
logger.debug("Failed to generate interrupted title for thread %s (non-fatal)", thread_id)
# Sync title from checkpoint to threads_meta.display_name
if started and not record.ownership_lost and checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
if ckpt_tuple is not None:
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
title = ckpt.get("channel_values", {}).get("title")
if title:
await thread_store.update_display_name(thread_id, title)
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
# Update threads_meta status based on run outcome
if started and not record.ownership_lost and thread_store is not None:
try:
final_status = "idle" if record.status == RunStatus.success else record.status.value
await thread_store.update_status(thread_id, final_status)
except Exception:
logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id)
if started and not record.ownership_lost and accessor is not None and record.status == RunStatus.success and not record.abort_event.is_set() and record.human_input_request is None:
try:
from deerflow.agents.human_input import extract_unanswered_human_input_request
record.human_input_request = extract_unanswered_human_input_request(await _materialized_checkpoint_messages(accessor, thread_id))
except Exception:
logger.debug("Failed to inspect trailing human_input_request for run %s (non-fatal)", run_id, exc_info=True)
if not record.ownership_lost and not getattr(record, "terminal_status_persisted", True):
try:
await run_manager.persist_current_status(run_id)
except Exception:
logger.warning("Failed to confirm terminal status for run %s before completion hook", run_id, exc_info=True)
if not record.ownership_lost and getattr(record, "terminal_status_persisted", True) and ctx.on_run_completed is not None:
try:
# Preserve hook liveness without forwarding cancellation
# to the finalizer's durable writes or resource cleanup.
async def invoke_completion_hook() -> None:
await ctx.on_run_completed(record)
completion_hook_task = asyncio.create_task(invoke_completion_hook(), name=f"run-completion-hook-{run_id}")
if host_interrupted:
await asyncio.sleep(0)
completion_hook_task.cancel()
await completion_hook_task
except Exception:
logger.warning("Run completion hook failed for %s (non-fatal)", run_id, exc_info=True)
except BaseException as exc:
# A terminal hook must not leave replacement runs blocked or
# stream consumers waiting indefinitely.
deferred_finalization_interrupt = _defer_finalization_interrupt(
deferred_finalization_interrupt,
exc,
)
logger.warning(
"Run completion hook interrupted for %s; completing finalization first",
run_id,
)
finally:
completion_hook_task = None
if task_info is not None and task_store is not None:
# Keep the finalizing barrier held until stop observers finish, so
# a same-thread replacement cannot overlap this task's lifecycle.
task_stop = asyncio.create_task(
notify_task_stop(
extensions,
task_store,
task_info,
lead_task_outcome(
aborted=(record.abort_event.is_set() or record.status == RunStatus.interrupted),
succeeded=record.status == RunStatus.success,
),
timeout=_EXTENSION_TASK_NOTIFY_TIMEOUT_SECONDS,
),
name=f"extension-task-stop-{run_id}",
)
try:
deferred_finalization_interrupt = await _await_task_stop_after_host_cancellation(
task_stop,
deferred_finalization_interrupt,
)
except Exception:
logger.warning(
"Extension task-stop notification failed for run %s (non-fatal)",
run_id,
exc_info=True,
)
except BaseException as exc:
# Cancellation here must not strand the finalizing barrier or
# leave stream consumers waiting for the end frame.
deferred_finalization_interrupt = _defer_finalization_interrupt(
deferred_finalization_interrupt,
exc,
)
logger.warning(
"Extension task-stop notification interrupted for run %s; completing cleanup first",
run_id,
)
if record.finalizing:
await run_manager.set_finalizing(run_id, False)
await bridge.publish_end(run_id)
if deferred_finalization_interrupt is not None:
raise deferred_finalization_interrupt
finally:
lease_cleanup_interrupt: BaseException | None = None
try:
from deerflow.sandbox.lease import release_sandbox_execution_lease_async
await release_sandbox_execution_lease_async(runtime_ctx)
except Exception:
logger.warning("Failed to release sandbox execution lease for run %s", run_id, exc_info=True)
except BaseException as exc:
# release_async completes the underlying cleanup before it
# re-raises cancellation. Defer that interruption until the
# worker has dropped all other run-scoped references too.
lease_cleanup_interrupt = exc
logger.warning(
"Sandbox execution lease cleanup was interrupted for run %s; completing local cleanup first",
run_id,
)
if journal is not None:
try:
await journal.close(flush=not record.ownership_lost)
except Exception:
logger.warning("Failed to close journal for run %s", run_id, exc_info=True)
finally:
_release_run_scoped_references(
runnable_configs,
runtime_ctx,
journal,
)
# Drop graph and per-run payload references before the terminal
# worker task itself becomes collectable.
agent = None
agent_result = None
accessor = None
runtime = None
runtime_ctx = None
rollback_point = None
subagent_events = None
goal_evaluator_model = None
goal_completion = None
task_store = None
task_info = None
pre_run_workspace_snapshot = None
delivery_content = None
produced_output_paths = None
graph_input = {}
lease_cleanup_interrupt: BaseException | None = None
try:
from deerflow.sandbox.lease import release_sandbox_execution_lease_async
# Durable finalization and terminal publication may depend on
# external backends, but local housekeeping must always run.
record.scheduled_goal_cleanup_pending = False
_create_contextless_task(bridge.cleanup(run_id, delay=60))
# Preserve the existing five-minute grace period for local
# join/status paths, then release the terminal record, completed
# task, and request payload. Durable run history remains available
# through RunStore.
_create_contextless_task(run_manager.cleanup(run_id))
_schedule_terminal_cycle_collection()
await release_sandbox_execution_lease_async(runtime_ctx)
except Exception:
logger.warning("Failed to release sandbox execution lease for run %s", run_id, exc_info=True)
except BaseException as exc:
# release_async completes the underlying cleanup before it
# re-raises cancellation. Defer that interruption until the
# worker has dropped all other run-scoped references too.
lease_cleanup_interrupt = exc
logger.warning(
"Sandbox execution lease cleanup was interrupted for run %s; completing local cleanup first",
run_id,
)
finally:
_release_run_scoped_references(
runnable_configs,
runtime_ctx,
journal,
)
# Drop graph and per-run payload references before the terminal
# worker task itself becomes collectable.
agent = None
agent_result = None
accessor = None
runtime = None
runtime_ctx = None
rollback_point = None
subagent_events = None
goal_evaluator_model = None
goal_completion = None
task_store = None
task_info = None
pre_run_workspace_snapshot = None
delivery_content = None
produced_output_paths = None
graph_input = {}
if lease_cleanup_interrupt is not None:
raise lease_cleanup_interrupt
# Durable finalization and terminal publication may depend on
# external backends, but local housekeeping must always run.
record.scheduled_goal_cleanup_pending = False
_create_contextless_task(bridge.cleanup(run_id, delay=60))
# Preserve the existing five-minute grace period for local
# join/status paths, then release the terminal record, completed
# task, and request payload. Durable run history remains available
# through RunStore.
_create_contextless_task(run_manager.cleanup(run_id))
_schedule_terminal_cycle_collection()
if lease_cleanup_interrupt is not None:
raise lease_cleanup_interrupt
# The registered worker remains the owner until every terminal and
# resource-cleanup await settles. Never cancel or detach this task.
finalizer = asyncio.create_task(finalize(), name=f"run-finalization-{run_id}")
await _drain_run_finalization(finalizer, on_host_cancel=interrupt_completion_hook)
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,332 @@
"""Host cancellation must preserve the terminal lifecycle and owned cleanup."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import deerflow.runtime.runs.worker as worker_module
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.journal import RunJournal
from deerflow.runtime.runs.manager import ConflictError, RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.runs.worker import RunContext, run_agent
from deerflow.runtime.stream_bridge import MemoryStreamBridge
class DummyAgent:
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
yield {"type": "task_started", "task_id": "verification-subtask", "description": "offline verification"}
async def start_run(monkeypatch, *, event_store, thread_store=None, run_store=None, bridge=None):
run_store = run_store or MemoryRunStore()
manager = RunManager(store=run_store)
record = await manager.create_or_reject("verify-6914")
bridge = bridge or MemoryStreamBridge()
completed = AsyncMock()
cleanup_tasks = []
original_create = worker_module._create_contextless_task
def track_cleanup(coro):
task = original_create(coro)
cleanup_tasks.append(task)
return task
monkeypatch.setattr(worker_module, "_create_contextless_task", track_cleanup)
worker = asyncio.create_task(
run_agent(
bridge,
manager,
record,
ctx=RunContext(checkpointer=None, event_store=event_store, thread_store=thread_store, on_run_completed=completed),
agent_factory=lambda *, config: DummyAgent(),
graph_input={},
config={},
stream_modes=["custom"],
)
)
record.task = worker
return SimpleNamespace(worker=worker, manager=manager, record=record, bridge=bridge, completed=completed, run_store=run_store, event_store=event_store, cleanup_tasks=cleanup_tasks)
async def snapshot(run):
record = run.record
stored = await run.run_store.get(record.run_id)
events = await run.event_store.list_events(record.thread_id, record.run_id)
try:
await run.manager.create_or_reject(record.thread_id)
replacement_admitted = True
except ConflictError:
replacement_admitted = False
return {
"cancelled": run.worker.cancelled(),
"local_status": record.status.value,
"stored_status": stored["status"],
"terminal_commit_pending": record.terminal_commit_pending,
"finalizing": record.finalizing,
"receipt_count": sum(e["event_type"] == "run.delivery" for e in events),
"subagent_count": sum(e["event_type"] == "subagent.start" for e in events),
"end": run.bridge._streams[record.run_id].ended,
"completion_hooks": run.completed.await_count,
"replacement_admitted": replacement_admitted,
}
async def reap(run):
if not run.worker.done():
run.worker.cancel()
await asyncio.gather(run.worker, return_exceptions=True)
for task in run.cleanup_tasks:
task.cancel()
await asyncio.gather(*run.cleanup_tasks, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("stage", ["receipt", "subagent_flush", "thread_status", "terminal_status", "journal_close", "end"])
async def test_cancellation_preserves_terminal_lifecycle(monkeypatch, stage):
entered, release, closed = asyncio.Event(), asyncio.Event(), asyncio.Event()
async def gate():
entered.set()
await release.wait()
class GatedEventStore(MemoryRunEventStore):
async def put_if_absent(self, **kwargs):
if stage == "receipt" and kwargs.get("event_type") == "run.delivery":
await gate()
return await super().put_if_absent(**kwargs)
async def put_batch(self, items):
if stage == "subagent_flush" and any(i.get("event_type") == "subagent.start" for i in items):
await gate()
return await super().put_batch(items)
async def update_status(thread_id, status):
if stage == "thread_status" and status != "running":
await gate()
class GatedRunStore(MemoryRunStore):
async def update_status(self, run_id, status, **kwargs):
if stage == "terminal_status" and status not in ("pending", "running"):
await gate()
return await super().update_status(run_id, status, **kwargs)
class GatedBridge(MemoryStreamBridge):
async def publish_end(self, run_id):
if stage == "end":
await gate()
await super().publish_end(run_id)
original_close = RunJournal.close
async def gated_close(journal, **kwargs):
if stage == "journal_close":
await gate()
await original_close(journal, **kwargs)
closed.set()
monkeypatch.setattr(RunJournal, "close", gated_close)
run = await start_run(monkeypatch, event_store=GatedEventStore(), thread_store=SimpleNamespace(update_status=update_status), run_store=GatedRunStore(), bridge=GatedBridge())
try:
await asyncio.wait_for(entered.wait(), 5)
run.worker.cancel("verification cancellation")
await asyncio.sleep(0)
for _ in range(3):
run.worker.cancel("repeated finalization cancellation")
await asyncio.sleep(0)
assert not run.worker.done()
assert not run.cleanup_tasks
release.set()
done, _ = await asyncio.wait({run.worker}, timeout=5)
assert done, "worker failed to settle after the operation was released"
with pytest.raises(asyncio.CancelledError, match="verification cancellation"):
await run.worker
assert closed.is_set(), "worker exited before journal cleanup finished"
state = await snapshot(run)
assert state["cancelled"], state
assert state["end"], state
assert state["receipt_count"] == 1, state
assert state["subagent_count"] == 1, state
assert state["stored_status"] == state["local_status"], state
assert not state["terminal_commit_pending"], state
assert not state["finalizing"], state
assert state["completion_hooks"] == 1, state
assert state["replacement_admitted"], state
finally:
release.set()
await reap(run)
@pytest.mark.asyncio
async def test_cancel_is_preserved_when_guarded_flush_fails(monkeypatch):
entered, release = asyncio.Event(), asyncio.Event()
original_flush = RunJournal.flush
first_flush = True
async def failing_flush(journal):
nonlocal first_flush
if first_flush:
first_flush = False
entered.set()
await release.wait()
raise OSError("injected finalization flush failure")
return await original_flush(journal)
monkeypatch.setattr(RunJournal, "flush", failing_flush)
run = await start_run(monkeypatch, event_store=MemoryRunEventStore())
try:
await asyncio.wait_for(entered.wait(), 5)
run.worker.cancel("original host cancellation")
await asyncio.sleep(0)
release.set()
done, _ = await asyncio.wait({run.worker}, timeout=5)
assert done
state = await snapshot(run)
assert state["cancelled"], state
assert state["end"], state
finally:
release.set()
await reap(run)
@pytest.mark.asyncio
async def test_failed_finalizer_preserves_host_cancellation_as_primary():
entered, release = asyncio.Event(), asyncio.Event()
failure = OSError("finalizer failed during cancellation drain")
async def finalize():
entered.set()
await release.wait()
raise failure
finalizer = asyncio.create_task(finalize())
host = asyncio.create_task(worker_module._drain_run_finalization(finalizer))
try:
await asyncio.wait_for(entered.wait(), 5)
host.cancel("first host cancellation")
await asyncio.sleep(0)
host.cancel("second host cancellation")
await asyncio.sleep(0)
assert not finalizer.done()
release.set()
with pytest.raises(asyncio.CancelledError, match="first host cancellation") as caught:
await host
assert caught.value.__cause__ is failure
assert finalizer.done()
assert finalizer.exception() is failure
finally:
release.set()
await asyncio.gather(host, finalizer, return_exceptions=True)
@pytest.mark.asyncio
async def test_finalizer_cancellation_is_not_mistaken_for_host_cancellation():
async def finalize():
raise asyncio.CancelledError("finalizer interrupted")
finalizer = asyncio.create_task(finalize())
with pytest.raises(asyncio.CancelledError):
await worker_module._drain_run_finalization(finalizer)
assert asyncio.current_task().cancelling() == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("stage", ["thread_status", "journal_close", "end"])
async def test_shutdown_drains_terminal_worker_after_durable_commit(monkeypatch, stage):
entered, release, shutdown_entered = asyncio.Event(), asyncio.Event(), asyncio.Event()
async def gate():
entered.set()
await release.wait()
async def update_status(thread_id, status):
if stage == "thread_status" and status != "running":
await gate()
original_close = RunJournal.close
async def close(journal, **kwargs):
if stage == "journal_close":
await gate()
await original_close(journal, **kwargs)
class GatedBridge(MemoryStreamBridge):
async def publish_end(self, run_id):
if stage == "end":
await gate()
await super().publish_end(run_id)
monkeypatch.setattr(RunJournal, "close", close)
run = await start_run(monkeypatch, event_store=MemoryRunEventStore(), thread_store=SimpleNamespace(update_status=update_status), bridge=GatedBridge())
shutdown = None
try:
await asyncio.wait_for(entered.wait(), 5)
assert (await run.run_store.get(run.record.run_id))["status"] == "success"
assert not run.record.terminal_commit_pending
original_stop = run.manager.stop_heartbeat
async def stop_heartbeat(**kwargs):
await original_stop(**kwargs)
shutdown_entered.set()
monkeypatch.setattr(run.manager, "stop_heartbeat", stop_heartbeat)
# A short drain budget is allowed to expire, but must not cancel or
# retire the still-owned finalizer. A subsequent drain can finish it.
await run.manager.shutdown(timeout=0.01)
assert not run.worker.done()
shutdown_entered.clear()
shutdown = asyncio.create_task(run.manager.shutdown(timeout=5))
await asyncio.wait_for(shutdown_entered.wait(), 5)
await asyncio.sleep(0)
assert not shutdown.done(), "shutdown skipped a committed run still finalizing"
assert not run.cleanup_tasks
release.set()
await asyncio.wait_for(shutdown, 5)
assert run.worker.done()
assert not run.worker.cancelled()
state = await snapshot(run)
assert state["end"] and state["completion_hooks"] == 1, state
assert state["stored_status"] == "success", state
finally:
release.set()
if shutdown is not None:
await asyncio.gather(shutdown, return_exceptions=True)
await reap(run)
@pytest.mark.asyncio
async def test_earlier_cancellation_does_not_leave_completion_hook_blocked(monkeypatch):
receipt_entered, release_receipt, hook_stopped = asyncio.Event(), asyncio.Event(), asyncio.Event()
class GatedEventStore(MemoryRunEventStore):
async def put_if_absent(self, **kwargs):
if kwargs.get("event_type") == "run.delivery":
receipt_entered.set()
await release_receipt.wait()
return await super().put_if_absent(**kwargs)
async def completion_hook(record):
try:
await asyncio.Event().wait()
finally:
hook_stopped.set()
run = await start_run(monkeypatch, event_store=GatedEventStore())
run.completed.side_effect = completion_hook
try:
await asyncio.wait_for(receipt_entered.wait(), 5)
run.worker.cancel("cancellation before the completion hook")
await asyncio.sleep(0)
release_receipt.set()
done, _ = await asyncio.wait({run.worker}, timeout=5)
assert done, "an earlier deferred interruption left the completion hook blocked"
with pytest.raises(asyncio.CancelledError, match="cancellation before the completion hook"):
await run.worker
assert hook_stopped.is_set()
assert run.bridge._streams[run.record.run_id].ended
finally:
release_receipt.set()
await reap(run)