OpenHands / OpenHands/software-agent-sdk
TaskTool child remains running and task mapping is lost after Agent Server process restart
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 539
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 137
Description
TaskTool child remains running and task mapping is lost after Agent Server process restart
Bug description
If the agent-server process dies while a persisted parent is blocked in a native
TaskToolSet child, parent crash recovery works but child lifecycle recovery does
not. The restarted server emits the expected interrupted-tool AgentErrorEvent
for the parent and the parent can run again. The persisted child remains
execution_status: running indefinitely even though no execution owns it, the
new TaskManager has no tasks, and no TaskObservation or child result is
available to the parent.
This reproduces on:
- SDK / Agent Server v1.46.0,
6a1e4d0f08dcdd02786526a51b7965e1877008bc - SDK / Agent Server v1.47.0,
50080b58d35b4824fda25fca2345d80bcd08aeff - current main captured 2026-09-14,
7518827471b22e9325fec7fdd54416db68d27866
Minimal deterministic reproduction
The reproducer uses real ConversationService, EventService,
LocalConversation, TaskToolSet, filesystem persistence, and process death. It
uses only TestLLM; no provider call is required.
- Start a file-persisted parent conversation with
TaskToolSet. - Have a scripted parent issue one native
taskcall to a registered worker. - Block the worker in a deterministic fake LLM after its child conversation is
persisted and observably active. - Assert before restart:
- parent status is
running; - child status is
running; - live
TaskManager._taskscontainstask_00000001, mapped to the child UUID; - parent has no
TaskObservationyet.
- parent status is
- Send
SIGKILLto the process owningConversationService. - Start a new
ConversationServiceagainst the same persistence directory. - Observe stock crash recovery and issue one native
EventService.run()on the
existing parent. - Inspect parent, child, and the new task registry.
Actual behavior
On all three versions:
- startup changes the parent from
runningtoerrorand appends an
AgentErrorEventparented to the interrupted TaskAction; - the new
TaskManagercontains zero tasks; - one native parent run completes the parent;
- the child remains persisted as
running; - the parent has zero
TaskObservationevents for the interrupted child; - the child has no terminal result/error event;
- no native mapping from the parent TaskAction/task ID to the child UUID is
reconstructed.
The child LocalConversation can be loaded and run on a copy only if diagnostic
code manually supplies its nested persistence path and UUID. That is not a
native parent recovery path.
Expected behavior
After process restart, native TaskTool recovery should produce one of these
minimal safe outcomes:
- terminalize the orphaned child as interrupted/error and return an explicit
interrupted task result to the parent, allowing the parent to decide whether
to delegate again; or - reconstruct/resume the child and eventually return its result to the parent.
A persisted child must not continue to report running when no execution owns
it, and the durable task/child relationship must not disappear silently.
Root-cause hypothesis
TaskManager._tasks is an in-memory dictionary. _ensure_parent() selects and
creates the durable subagents/ directory but does not load it. _create_task()
generates the task ID and child UUID, then stores their relationship only in
_tasks. _run_task() owns completion/error assignment and emits the eventual
TaskObservation through TaskExecutor; hard process death prevents its
finally path from running.
On restart, ConversationService hydrates top-level running parents and
EventService correctly recovers the interrupted parent action. It does not scan
or reconcile nested subagent state. A newly initialized TaskTool creates a fresh,
empty TaskManager. The child base state durably contains its UUID and running
status, but not a durable task-ID/tool-call mapping usable by the parent.
Acceptance criteria
- Add a process-boundary test using real file persistence and a deterministic
active TaskTool child. - After restart, no child reports
runningunless a live/reconstructed execution
owns it. - The parent receives a terminal interrupted task result, or the child is
reconstructed and its eventual result returns natively. - The task ID, child UUID, parent TaskAction/tool-call mapping, and terminal state
remain coherent across restart. - One native parent resume requires no manual task-ID lookup, child attachment,
result injection, or repository repair. - Multiple concurrent TaskTool calls are mapped unambiguously.
- Restart does not reuse
task_00000001in a way that collides with an orphaned
persisted child. - Existing lease fencing and #4487/#4488 active-branch recovery tests continue to
pass.
Relation to adjacent issues
- #4487 / merged #4488: related but different. That fix correctly attaches the
generic crash-recovery result to the interrupted parent action. It does not
reconstruct or terminalize the TaskTool child. - #3842 / open PR #3991: related process-lifecycle family, but concerns an
in-process staleEventService._run_taskand 409 wedge, not a lost TaskManager
after process restart. - #3915: concerns
DelegateExecutorresource cleanup, not TaskTool crash
persistence. - #4591: concerns orphan observation/tool-call matching, not child lifecycle.
- #4654: explicitly says process-restart persistence is outside that feature
request because task IDs are in memory. It corroborates this root cause but
does not track or resolve this defect. - #2666: documents TaskToolSet as the recommended delegation mechanism and
describes resume within TaskManager state; it does not cover process restart.
Standalone reproducer
Save the Python block below as restart_reproducer.py in a source checkout and
run it from the repository environment. It creates all persistence under the
--evidence directory, uses upstream TestLLM, requires no provider credential,
and refuses to reuse a non-empty evidence directory.
uv run --frozen --all-packages --no-dev python restart_reproducer.py controller \
--evidence /tmp/tasktool-restart-repro
Credential-free standalone reproducer
"""Deterministic process-boundary reproducer for TaskTool child restart state.
This is reproduction equipment, not a recovery implementation. The controller
starts a real ConversationService in a child process, waits until a native
TaskTool delegate is blocked inside a fake (zero-network) LLM, kills that process,
then starts a fresh ConversationService and performs exactly one native parent
run. No task ID or child result is injected into the resumed parent.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import shutil
import subprocess
import sys
import time
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from uuid import UUID
from pydantic import PrivateAttr
from openhands.agent_server.conversation_service import ConversationService
from openhands.agent_server.models import StartConversationRequest
from openhands.sdk import LLM, Agent, Tool
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.event import Event, ObservationEvent
from openhands.sdk.llm import Message, MessageToolCall, TextContent
from openhands.sdk.llm.llm_response import LLMResponse
from openhands.sdk.llm.streaming import TokenCallbackType
from openhands.sdk.subagent.registry import _reset_registry_for_tests, register_agent
from openhands.sdk.testing import TestLLM
from openhands.sdk.tool.tool import ToolDefinition
from openhands.sdk.workspace import LocalWorkspace
from openhands.tools.task import TaskToolSet
from openhands.tools.task.definition import TaskObservation
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(value, indent=2, sort_keys=True, default=str))
tmp.replace(path)
def read_json(path: Path) -> Any:
return json.loads(path.read_text())
def text_message(text: str) -> Message:
return Message(role="assistant", content=[TextContent(text=text)])
def task_call() -> Message:
return Message(
role="assistant",
content=[TextContent(text="Delegating to the worker.")],
tool_calls=[
MessageToolCall(
id="restart-repro-task-call",
name="task",
arguments=json.dumps(
{
"prompt": "Remain active until the server process is restarted.",
"subagent_type": "blocking_worker",
"description": "restart lifecycle probe",
}
),
origin="completion",
)
],
)
class BlockingTestLLM(TestLLM):
"""A deterministic fake LLM that records entry and never returns."""
_marker: str = PrivateAttr(default="")
def __init__(self, *, marker: str, **data: Any) -> None:
super().__init__(**data)
self._marker = marker
def completion(
self,
messages: list[Message],
tools: Sequence[ToolDefinition] | None = None,
add_security_risk_prediction: bool = False,
on_token: TokenCallbackType | None = None,
call_context: Any = None,
**kwargs: Any,
) -> LLMResponse:
write_json(
Path(self._marker),
{
"pid": os.getpid(),
"entered_at_unix": time.time(),
"message_count": len(messages),
"tool_count": len(tools or []),
},
)
while True:
time.sleep(60)
def placeholder_llm(usage_id: str) -> LLM:
return LLM(
usage_id=usage_id,
model="openai/gpt-4o",
)
def register_blocking_worker(marker: Path) -> None:
_reset_registry_for_tests()
worker_llm = BlockingTestLLM(
marker=str(marker),
model="deterministic-blocking-test-model",
usage_id="restart-repro-child",
scripted_responses=[],
)
register_agent(
name="blocking_worker",
factory_func=lambda inherited_llm: Agent(llm=worker_llm, tools=[]),
description="Deterministic worker used only by the restart reproducer",
)
def event_summary(conversation_dir: Path) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
events_dir = conversation_dir / "events"
if not events_dir.exists():
return result
for path in sorted(
events_dir.glob("event-*.json"),
key=lambda p: int(p.name.split("-")[1]),
):
event = Event.model_validate_json(path.read_text())
row: dict[str, Any] = {
"file": path.name,
"type": type(event).__name__,
"id": str(event.id),
"parent_id": str(event.parent_id) if event.parent_id else None,
}
for name in ("tool_name", "tool_call_id", "action_id", "error"):
value = getattr(event, name, None)
if value is not None:
row[name] = str(value)
if isinstance(event, ObservationEvent) and isinstance(
event.observation, TaskObservation
):
row["task_observation"] = event.observation.model_dump(mode="json")
result.append(row)
return result
def locate_parent(conversations_dir: Path) -> tuple[Path, dict[str, Any]]:
parents = [p for p in conversations_dir.iterdir() if (p / "meta.json").exists()]
if len(parents) != 1:
raise RuntimeError(f"expected one parent conversation, found {parents}")
parent_dir = parents[0]
return parent_dir, read_json(parent_dir / "base_state.json")
def locate_child(parent_dir: Path) -> tuple[Path, dict[str, Any]]:
children = [
p
for p in (parent_dir / "subagents").iterdir()
if (p / "base_state.json").exists()
]
if len(children) != 1:
raise RuntimeError(f"expected one child conversation, found {children}")
child_dir = children[0]
return child_dir, read_json(child_dir / "base_state.json")
def manager_snapshot(conversation: Any) -> dict[str, Any]:
try:
task_tool = conversation.agent.tools_map["task"]
manager = task_tool.executor._manager
return {
"available": True,
"task_ids": sorted(manager._tasks),
"tasks": {
task_id: {
"status": str(task.status),
"conversation_id": str(task.conversation_id),
"has_live_conversation": task.conversation is not None,
"result": task.result,
"error": task.error,
}
for task_id, task in manager._tasks.items()
},
"persistence_dir": str(manager._persistence_dir),
}
except Exception as exc:
return {"available": False, "error": repr(exc)}
async def phase1(evidence: Path) -> None:
conversations = evidence / "state" / "conversations"
workspace = evidence / "workspace"
workspace.mkdir(parents=True, exist_ok=True)
marker = evidence / "child_llm_entered.json"
register_blocking_worker(marker)
parent_llm = TestLLM.from_messages(
[task_call(), text_message("This response must not be reached before restart.")],
model="deterministic-parent-test-model",
usage_id="restart-repro-parent-before",
)
request = StartConversationRequest(
agent=Agent(
llm=placeholder_llm("restart-repro-placeholder"),
tools=[Tool(name=TaskToolSet.name)],
),
workspace=LocalWorkspace(working_dir=str(workspace)),
autotitle=False,
)
# Do not use a context manager: the controller deliberately kills this
# process, bypassing graceful EventService/TaskManager cleanup.
service = ConversationService(conversations_dir=conversations)
await service.__aenter__()
info, _ = await service.start_conversation(request)
event_service = await service.get_event_service(info.id)
assert event_service is not None
conversation = event_service.get_conversation()
conversation.switch_llm(parent_llm)
await event_service.send_message(
Message(role="user", content=[TextContent(text="Run the delegated probe.")]),
run=False,
)
write_json(
evidence / "phase1_started.json",
{"pid": os.getpid(), "parent_conversation_id": str(info.id)},
)
await event_service.run()
# The child writes its marker only after native TaskTool construction and
# entry into its fake LLM. Snapshot the live in-memory registry before the
# controller kills this process.
while not marker.exists():
await asyncio.sleep(0.01)
write_json(
evidence / "phase1_runtime_snapshot.json",
{
"parent_execution_status": conversation.state.execution_status.value,
"manager": manager_snapshot(conversation),
},
)
while True:
await asyncio.sleep(60)
async def phase2(evidence: Path) -> None:
conversations = evidence / "state" / "conversations"
workspace = evidence / "workspace"
marker = evidence / "phase2-unused-child-marker.json"
register_blocking_worker(marker)
parent_dir, parent_base = locate_parent(conversations)
parent_id = UUID(parent_base["id"])
async with ConversationService(conversations_dir=conversations) as service:
recovered = await service.get_event_service(parent_id)
assert recovered is not None
conversation = recovered.get_conversation()
# ConversationService.start() has already performed its stock crash
# recovery. Install a deterministic parent response, as upstream tests do.
conversation.switch_llm(
TestLLM.from_messages(
[text_message("Parent completed one native post-restart run.")],
model="deterministic-parent-test-model",
usage_id="restart-repro-parent-after",
)
)
conversation._ensure_agent_ready()
manager_before = manager_snapshot(conversation)
status_before = conversation.state.execution_status.value
events_before = event_summary(parent_dir)
# The sole native resume action in this reproducer.
await recovered.run()
run_task = recovered._run_task
if run_task is not None:
await asyncio.wait_for(asyncio.shield(run_task), timeout=30)
parent_dir, parent_after = locate_parent(conversations)
child_dir, child_after = locate_child(parent_dir)
write_json(
evidence / "phase2_result.json",
{
"pid": os.getpid(),
"native_resume_calls": 1,
"parent_id": str(parent_id),
"parent_status_before_resume": status_before,
"parent_status_after_resume": parent_after["execution_status"],
"manager_before_resume": manager_before,
"manager_after_resume": manager_snapshot(conversation),
"events_before_resume": events_before,
"events_after_resume": event_summary(parent_dir),
"child_id": child_after["id"],
"child_status_after_resume": child_after["execution_status"],
"child_observability_metadata": child_after.get(
"observability_metadata"
),
"child_events_after_resume": event_summary(child_dir),
"child_result_observations": [
row
for row in event_summary(child_dir)
if row["type"] in {"ObservationEvent", "AgentErrorEvent"}
],
},
)
def controller(evidence: Path) -> None:
evidence.mkdir(parents=True, exist_ok=True)
if any(evidence.iterdir()):
raise RuntimeError(f"refusing to reuse non-empty evidence dir: {evidence}")
phase1_log = (evidence / "phase1.log").open("w", encoding="utf-8")
proc = subprocess.Popen(
[sys.executable, __file__, "phase1", "--evidence", str(evidence)],
stdout=phase1_log,
stderr=subprocess.STDOUT,
text=True,
)
try:
marker = evidence / "phase1_runtime_snapshot.json"
deadline = time.monotonic() + 60
while time.monotonic() < deadline and not marker.exists():
if proc.poll() is not None:
raise RuntimeError(f"phase1 exited early with code {proc.returncode}")
time.sleep(0.05)
if not marker.exists():
raise TimeoutError("active child registry snapshot was not written")
parent_dir, parent_before = locate_parent(evidence / "state" / "conversations")
child_dir, child_before = locate_child(parent_dir)
write_json(
evidence / "before_restart.json",
{
"phase1_pid": proc.pid,
"parent_id": parent_before["id"],
"parent_execution_status": parent_before["execution_status"],
"task_registry": read_json(marker)["manager"],
"parent_events": event_summary(parent_dir),
"child_id": child_before["id"],
"child_execution_status": child_before["execution_status"],
"child_observability_metadata": child_before.get(
"observability_metadata"
),
"child_events": event_summary(child_dir),
},
)
proc.kill()
proc.wait(timeout=10)
write_json(
evidence / "restart.json",
{
"method": "SIGKILL of the process owning ConversationService",
"phase1_returncode": proc.returncode,
"killed_at_unix": time.time(),
},
)
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=10)
phase1_log.close()
phase2_log = (evidence / "phase2.log").open("w", encoding="utf-8")
resumed = subprocess.run(
[sys.executable, __file__, "phase2", "--evidence", str(evidence)],
stdout=phase2_log,
stderr=subprocess.STDOUT,
text=True,
timeout=60,
check=False,
)
phase2_log.close()
write_json(evidence / "phase2_exit.json", {"returncode": resumed.returncode})
if resumed.returncode != 0:
raise RuntimeError(f"phase2 failed with code {resumed.returncode}")
before = read_json(evidence / "before_restart.json")
after = read_json(evidence / "phase2_result.json")
write_json(
evidence / "summary.json",
{
"before_restart": {
"parent_status": before["parent_execution_status"],
"child_status": before["child_execution_status"],
"parent_task_observation_count": sum(
1 for row in before["parent_events"] if "task_observation" in row
),
},
"after_restart_before_resume": {
"parent_status": after["parent_status_before_resume"],
"manager_task_ids": after["manager_before_resume"].get("task_ids"),
"parent_has_generic_recovery_error": any(
row["type"] == "AgentErrorEvent"
for row in after["events_before_resume"]
),
},
"after_one_native_resume": {
"parent_status": after["parent_status_after_resume"],
"child_status": after["child_status_after_resume"],
"manager_task_ids": after["manager_after_resume"].get("task_ids"),
"parent_task_observation_count": sum(
1
for row in after["events_after_resume"]
if "task_observation" in row
),
"child_result_observation_count": len(
after["child_result_observations"]
),
"native_resume_calls": after["native_resume_calls"],
},
},
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=("controller", "phase1", "phase2"))
parser.add_argument("--evidence", required=True, type=Path)
args = parser.parse_args()
if args.mode == "controller":
controller(args.evidence)
elif args.mode == "phase1":
asyncio.run(phase1(args.evidence))
else:
asyncio.run(phase2(args.evidence))
if __name__ == "__main__":
main()
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the credential-free restart_reproducer.py against file persistence first. Read TaskManager._ensure_parent(), _create_task(), and _run_task(), then follow ConversationService startup and EventService.run() recovery. Done means the process-boundary test preserves task/child mapping and prevents an unowned child from remaining running, while producing a native terminal result or resumed result for the parent.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, distributed-systems, testing-qa
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100