{"record":{"id":"fbb392e0cd4d0a93","repo":"langchain-ai/deepagents","slug":"pending-graph-work-remained-on-thread-thread-id","errorCode":null,"errorMessage":"Pending graph work remained on thread {thread_id} after clearing checkpoint state","messagePattern":"Pending graph work remained on thread (.+?) after clearing checkpoint state","errorType":"exception","errorClass":"RuntimeError","httpStatus":null,"severity":"error","filePath":"libs/code/deepagents_code/client/remote_client.py","lineNumber":753,"sourceCode":"        await _cancel_active_runs(self._get_graph(), thread_id)\n        state = await self.aget_state(prepared)\n        cancelled = await asyncio.to_thread(\n            _cancelled_tool_messages, getattr(state, \"values\", None)\n        )\n        if cancelled:\n            # `create_agent` names the tool step \"tools\", but only adds the node\n            # when the agent has tools. A toolless graph therefore rejects this\n            # update with `InvalidUpdateError` -- and could not have produced a\n            # dangling tool call in the first place, so the branch is dead\n            # there. The caller reports the failure rather than compacting.\n            await self.aupdate_state(prepared, {\"messages\": cancelled}, as_node=\"tools\")\n        await self.aupdate_state(prepared, None, as_node=\"__end__\")\n        if state_has_pending_work(await self.aget_state(prepared)):\n            msg = (\n                f\"Pending graph work remained on thread {thread_id} after \"\n                \"clearing checkpoint state\"\n            )\n            raise RuntimeError(msg)\n\n    async def aput_store_item(\n        self,\n        namespace: tuple[str, ...],\n        key: str,\n        value: dict[str, Any],\n    ) -> None:\n        \"\"\"Write an item to the server-side LangGraph Store.\n\n        Args:\n            namespace: Store namespace.\n            key: Item key within `namespace`.\n            value: JSON-serializable item value.\n\n        Notes:\n            A failed write is logged at debug and re-raised. The re-raise is\n            load-bearing: callers (`awrite_approval_mode` and its callers)\n            depend on the failure propagating so they can fail closed — drop","sourceCodeStart":735,"sourceCodeEnd":771,"githubUrl":"https://github.com/langchain-ai/deepagents/blob/a1af029e6e73cb17c36bff823d227747b28e91e1/libs/code/deepagents_code/client/remote_client.py#L735-L771","documentation":"`aabandon_pending_work` cancels active runs, writes terminal tool results for dangling tool calls, and sets the thread's state to `__end__` to discard checkpointed work. It then re-reads the state and, if `state_has_pending_work` still reports a queued node (`next`), task, or interrupt, raises this RuntimeError — the post-clear verification failed, so the thread was not safely cleaned up.","triggerScenarios":"After cancel + `aupdate_state(None, as_node='__end__')`, the fresh `StateSnapshot` still has non-empty `next`, `tasks`, or `interrupts` — e.g. the run was not actually cancelled, the state update did not land (409 retry also failed), or an interrupt was re-materialized.","commonSituations":"A server that ignored the cancellation and kept the run queued; the `__end__` update rejected or conflicting so the checkpoint still points at the `tools` node; leftover interrupts persisted by the checkpointer that the end-write did not clear.","solutions":["Check server logs to confirm the active run was actually cancelled (`_cancel_active_runs` is best-effort); re-run `acancel_active_runs` and then `aabandon_pending_work` again.","Inspect `aget_state(config)` — see whether `next`/`tasks`/`interrupts` remain and which one keeps the thread pending.","Retry the recovery on the same thread; a transient 409 or cancelled-wait timeout can leave work behind on the first attempt.","If an interrupt persists, resume/resolve it explicitly (or clear it via `aupdate_state`) before abandoning; if the server keeps re-queuing runs, restart the server and retry."],"exampleFix":null,"handlingStrategy":"retry","validationCode":"# After abandonment, verify the thread is clean yourself\nsnapshot = await agent.aget_state(config)\nassert not (snapshot.next or snapshot.tasks or snapshot.interrupts), \"thread still has pending work\"","typeGuard":"def is_thread_clean(state: object) -> bool:\n    return not (\n        getattr(state, \"next\", None)\n        or getattr(state, \"tasks\", None)\n        or getattr(state, \"interrupts\", None)\n    )","tryCatchPattern":"try:\n    await agent.aabandon_pending_work(config)\nexcept RuntimeError as exc:\n    if \"Pending graph work remained\" in str(exc):\n        logging.warning(\"Recovery verification failed; cancelling again and retrying\")\n        await agent.acancel_active_runs(config)\n        await agent.aabandon_pending_work(config)\n    else:\n        raise","preventionTips":["Confirm cancellations actually took effect in server logs before abandoning work.","Resolve outstanding interrupts before attempting to abandon a thread.","After any recovery, snapshot the state and assert `next`/`tasks`/`interrupts` are empty."],"tags":["langgraph","state","cancellation","recovery"],"backgroundTag":"pending-work-not-cleared","analyzedSha":"a1af029e6e73cb17c36bff823d227747b28e91e1","analyzedAt":"2026-08-29T11:43:24.718Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}