diff --git a/tests/tools/test_subagent_process_handoff.py b/tests/tools/test_subagent_process_handoff.py index 2b409fa047..a251ea71aa 100644 --- a/tests/tools/test_subagent_process_handoff.py +++ b/tests/tools/test_subagent_process_handoff.py @@ -14,7 +14,7 @@ import pytest from tools.delegate_tool import _register_subagent, _unregister_subagent from tools.process_registry import ProcessRegistry, process_registry, _handle_process -from tools.process_registry_notifications import format_process_notification +from tools.process_registry_notifications import _process_accounting_lines, format_process_notification class _Parent: @@ -100,6 +100,19 @@ def test_handoff_refuses_exited_foreign_or_non_child_callers(clean_queue): assert "error" in json.loads(_handle_process( {"action": "handoff", "session_id": done.id, "data": "x"}, task_id=sid)) + # A notify process that exited while the child was alive and was never read is reported to the parent; + # the one the child waited on (read) is not. + unread = process_registry.spawn_local("echo UNREAD_RESULT", task_id=sid, owner_task_id=sid) + unread.notify_on_complete = True + deadline = time.time() + 10 + while not unread.exited and time.time() < deadline: + time.sleep(0.05) + ids = [s.id for s in process_registry.unread_completions_owned_by(sid)] + assert ids == [unread.id] + assert "UNREAD_RESULT" in _process_accounting_lines( + {"unread_completions": [{"session_id": unread.id, "command": "echo", "exit_code": 0, + "output_tail": unread.output_buffer}]})[0] + foreign = process_registry.spawn_local("sleep 30", task_id=other, owner_task_id=other) assert "error" in json.loads(_handle_process( {"action": "handoff", "session_id": foreign.id, "data": "x"}, task_id=sid)) diff --git a/tools/AGENTS.md b/tools/AGENTS.md index 175851d0f0..e6af2fd704 100644 --- a/tools/AGENTS.md +++ b/tools/AGENTS.md @@ -93,7 +93,7 @@ subagent_auto_approve, inherit_mcp_toolsets, max_iterations`. **Child processes: processes are killed at its teardown and their notices are suppressed in the parent; `process_manage(action="handoff")` (children only) flips `ProcessSession.owner_task_id` to the parent under the registry lock (`process_registry.transfer_ownership`) so the completion routes and reaps by the new owner; un-handed leftovers land on -the result as `orphaned_processes` (`_ChildRun.account_background_processes`, before `cleanup` kills them). **Durability:** background +the result as `orphaned_processes`, exited-but-never-read notify processes as `unread_completions` (`_ChildRun.account_background_processes`, before `cleanup` kills them). **Durability:** background delegation is process-local; work that must survive restart uses `cronjob` or `terminal(background=True, notify_on_complete=True)`. API: `website/docs/developer-guide/subagent-lifecycle-api.md`. diff --git a/tools/delegate_tool_child_run.py b/tools/delegate_tool_child_run.py index cccd28f237..6fc38b059f 100644 --- a/tools/delegate_tool_child_run.py +++ b/tools/delegate_tool_child_run.py @@ -749,12 +749,17 @@ class _ChildRun: if handed: entry["handed_off_processes"] = handed with _quiet(None): - from tools.process_registry import process_registry + from tools.process_registry import process_registry, _output_tail leftover = process_registry.running_owned_by(self.child_task_id) if leftover: entry["orphaned_processes"] = [ {"session_id": s.id, "command": s.command[:200], "runtime_seconds": round(time.time() - s.started_at)} for s in leftover] + unread = process_registry.unread_completions_owned_by(self.child_task_id) + if unread: + entry["unread_completions"] = [ + {"session_id": s.id, "command": s.command[:200], "exit_code": s.exit_code, + "output_tail": _output_tail(s, 600)} for s in unread] def emit_complete(self, result: Dict[str, Any], entry: Dict[str, Any], duration: float) -> None: """Fire ``subagent.complete`` with the per-branch observability payload (tokens, cost, files touched, diff --git a/tools/process_registry.py b/tools/process_registry.py index 289b618cbb..9580eb2a54 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1864,6 +1864,14 @@ class ProcessRegistry(ProcessCheckpointMixin): with self._lock: return [s for s in self._running.values() if s.owner_task_id == owner_task_id and not s.exited] + def unread_completions_owned_by(self, owner_task_id: str) -> List[ProcessSession]: + """Exited ``notify_on_complete`` processes of ``owner_task_id`` whose result nobody read (no wait/log/poll). + A child's completion notice is suppressed in the parent, so an unread exit is otherwise lost silently.""" + with self._lock: + return [s for s in self._finished.values() + if s.owner_task_id == owner_task_id and s.notify_on_complete + and s.id not in self._completion_consumed and s.id not in self._poll_observed] + def transfer_ownership(self, session_id: str, *, from_owner: str, to_owner: str, to_task_id: str, to_session_key: str, note: str = "") -> Optional[ProcessSession]: """Move a RUNNING process from one owner to another under the registry lock. Ownership is the ``owner_task_id`` diff --git a/tools/process_registry_notifications.py b/tools/process_registry_notifications.py index 98985dbf67..6f012e3089 100644 --- a/tools/process_registry_notifications.py +++ b/tools/process_registry_notifications.py @@ -229,6 +229,9 @@ def _process_accounting_lines(r: dict) -> list: "(subagent process notices never reach you): " + "; ".join(f"{o.get('session_id')} `{o.get('command', '')[:100]}` ({o.get('runtime_seconds')}s)" for o in orphans) + ". Re-launch in this session anything you still need.") + for u in r.get("unread_completions") or []: + lines.append(f"Child's process {u.get('session_id')} `{u.get('command', '')[:100]}` finished (exit code " + f"{u.get('exit_code')}) but the child never read its result; output tail:\n{u.get('output_tail', '')}") return lines diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index ec8484680a..33c32ad69d 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -197,7 +197,7 @@ A subagent's background processes are also **killed when the subagent finishes** - **kill** — `process_manage(action="kill", ...)`; - **hand off** — `process_manage(action="handoff", session_id=..., data="")`. The runtime transfers ownership to the parent under the registry lock (up to 3 per child; only a running process the child owns is accepted, anything else is a tool error). The parent's completion notice then arrives in the parent chat with `Handed off to you by a subagent… Purpose: …`, and the parent can poll/log/kill it like its own. -Whatever the child neither waited on, killed, nor handed off is named on its result (`orphaned_processes`) and in the parent's delegation notice as terminated, so the parent hears from the runtime, never from the child's prose, that "the watcher is running" is no longer true. For CI watchers the better pattern is still: the child returns the fact (PR number, SHA) and the parent launches its own watcher. +A process that finishes while the child is still running needs no handoff: the child reads it (`poll`/`wait`/`log`) and reports it. If the child never reads it, the exit code and output tail are attached to its result as `unread_completions` and shown to the parent. Whatever is still running and was neither killed nor handed off is named on its result (`orphaned_processes`) and in the parent's delegation notice as terminated, so the parent hears from the runtime, never from the child's prose, that "the watcher is running" is no longer true. For CI watchers the better pattern is still: the child returns the fact (PR number, SHA) and the parent launches its own watcher. ## Model Override