Files
hermes-agent/tests/tools/test_async_batch_task_failure_notice.py
T
Teknium 6767c06d34 feat(delegation): a failed child of a still-running detached fan-out is surfaced to the parent immediately
Batches join on the slowest sibling before ONE consolidated block re-enters
(the design: one results block per fan-out). Failure is the case that
should not wait. In the 1,393-agent refactor run every wave-1 child died in
the 08:29 401 storm; the parent learned of it at 09:36, when the batch's
"unknown outcome" block finally arrived: 66 minutes of a dead wave with
nothing running, the single largest idle gap of the run.

_run_children_parallel, for DETACHED batches only (the sync path prints a
completion line the parent is already watching), pushes ONE
type="async_delegation" event with task_failure_notice=True and a
single-entry results list when a child ends in a failure status while
siblings are still pending. Same event shape and routing fields as the
batch result, so every drain/ownership/format path treats it identically;
the batch record is not finalized and its consolidated result still
arrives unchanged. The formatter renders
"[ASYNC DELEGATION TASK FAILED — <id>, task i/n]" with the task, status,
error and live transcript path, and says the batch result is still coming.
The TUI dedup key distinguishes a notice from the final result and from a
sibling's notice.

Live through the real detached dispatch (3 children, one fails at 0.1 s,
two succeed at 8 s): notice drained at t+0.3 s, batch final at t+8.4 s. On
main the parent hears nothing until t+8.4 s.

Tests (2): the notice reaches the queue with the record's routing fields
while the record stays running, and formats as an early warning; no notice
for a finished batch; dedup key differs from the final result.
Delegation/registry/notification suites (129 files) 1,344 passed.
2026-09-05 01:55:22 -07:00

49 lines
2.8 KiB
Python

"""A failed child of a still-running detached fan-out is surfaced to the parent immediately.
Batches join on the slowest sibling before ONE consolidated block re-enters. In a 1,393-agent run every wave-1
child died in a 401 storm at 08:29 and the parent learned of it at 09:36 from the batch's "unknown outcome"
block: 66 minutes of a dead wave with nothing running.
"""
import queue
from unittest.mock import patch
from tools import async_delegation as ad
from tools.process_registry_notifications import format_process_notification
from tui_gateway.session_notifications import _notification_event_dedup_key
def _record(status="running"):
return {"delegation_id": "deleg_x", "status": status, "is_batch": True, "goals": ["a", "b", "c"], "goal": "a",
"session_key": "sk", "origin_ui_session_id": "ui", "origin_session_id": "", "parent_session_id": "root",
"dispatched_at": 1.0, "role": "leaf", "model": "m", "context": None, "toolsets": None}
def test_failure_notice_reaches_the_queue_while_the_batch_keeps_running_and_formats_as_early_warning():
q = queue.Queue()
entry = {"task_index": 1, "status": "error", "error": "401 authentication_error: key invalid", "duration_seconds": 12.5,
"live_transcript": "/tmp/live/task-1.log"}
with patch.object(ad, "_records", {"deleg_x": _record()}), \
patch("tools.process_registry.process_registry") as reg:
reg.completion_queue = q
ad.push_task_failure_notice("deleg_x", entry, n_tasks=3)
assert ad._records["deleg_x"]["status"] == "running" # not finalized
evt = q.get_nowait()
assert evt["type"] == "async_delegation" and evt["task_failure_notice"] is True
assert (evt["session_key"], evt["origin_ui_session_id"], evt["parent_session_id"]) == ("sk", "ui", "root")
text = format_process_notification(evt)
assert text.startswith("[ASYNC DELEGATION TASK FAILED — deleg_x, task 2/3]")
assert "Task: b" in text and "401 authentication_error" in text and "/tmp/live/task-1.log" in text
assert "consolidated results will still arrive" in text
def test_notice_is_not_sent_for_a_finished_batch_and_does_not_dedup_against_the_final_result():
q = queue.Queue()
with patch.object(ad, "_records", {"deleg_x": _record(status="completed")}), \
patch("tools.process_registry.process_registry") as reg:
reg.completion_queue = q
ad.push_task_failure_notice("deleg_x", {"task_index": 0, "status": "error"}, n_tasks=3)
assert q.empty()
notice = {"type": "async_delegation", "delegation_id": "deleg_x", "task_failure_notice": True, "results": [{"task_index": 2}]}
final = {"type": "async_delegation", "delegation_id": "deleg_x", "is_batch": True, "results": []}
assert _notification_event_dedup_key(notice) != _notification_event_dedup_key(final)