feat(delegation): report a child's exited-but-unread notify processes to the parent

A process that finishes while the child is alive needs no handoff, but if the child never
polls/waits/logs it, the result vanished: the completion notice is suppressed in the parent
and the child's summary never mentions it. Finalization now attaches exit code + output
tail as unread_completions, rendered in the parent's delegation notice.
This commit is contained in:
Teknium
2026-09-07 12:17:19 -07:00
parent b2c394bcad
commit ef9239571d
6 changed files with 33 additions and 4 deletions

View File

@@ -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))

View File

@@ -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`.

View File

@@ -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,

View File

@@ -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``

View File

@@ -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

View File

@@ -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="<one sentence: what it is for>")`. 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