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:
@@ -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))
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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``
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user