mirror of
https://github.com/usestrix/strix.git
synced 2026-10-14 04:08:06 +00:00
revert(core): drop the agent revival machinery; the engine already resumes
A user message already resumes a failed agent's lifecycle from where it stopped. `send` lifts that agent's human-wake gate, `wait_for_message` returns, the loop resets the nudge and idle-resume budgets - "real input is real progress" - and `mark_running` retires the error before the next turn. Any agent can be addressed: `_send_message` takes an arbitrary agent id, so a failed child is reachable by selecting it, exactly as a parked one is. So there was nothing here to fix. The premise behind these commits - that only the root is reachable, stranding every other agent that dies - was wrong, and each revision built a larger mechanism on it: a scan-wide revival, then an attempt budget to bound the runaway that revival introduced. What remains is the TUI, which is where the reported symptom always was: an error that had already resolved stayed on the screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ad97ccd58c
commit
b1174ed95a
2 changed files with 0 additions and 173 deletions
|
|
@ -22,15 +22,6 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REVIVED_NOTICE = {
|
||||
"from": "system",
|
||||
"type": "revived",
|
||||
"content": (
|
||||
"[Recovery] Your last turn failed and the user has since sent a message. "
|
||||
"Pick your current task back up from where it stopped."
|
||||
),
|
||||
}
|
||||
|
||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
||||
|
||||
# Why an agent parked. The user can message any agent, so this - not the agent's
|
||||
|
|
@ -129,36 +120,6 @@ class AgentCoordinator:
|
|||
},
|
||||
)
|
||||
|
||||
async def revive_failed_agents(self, *, exclude: str | None = None) -> list[str]:
|
||||
"""Lift the human-wake gate on every agent that died, on the user's word.
|
||||
|
||||
A failed agent parks until a human tells it to go again. The user can only
|
||||
realistically reach the root, so that word never arrives for anything below
|
||||
it and a child that dies stays dead for the rest of the scan - however it
|
||||
died. A message from the user is that word for all of them: it says a person
|
||||
is present and has changed something. Nothing else revives an agent, so
|
||||
there is no runaway here to bound - the person deciding whether to try again
|
||||
is the limit.
|
||||
"""
|
||||
revived: list[str] = []
|
||||
async with self._lock:
|
||||
for agent_id, status in self.statuses.items():
|
||||
if agent_id == exclude or status not in {"failed", "crashed"}:
|
||||
continue
|
||||
self.statuses[agent_id] = "waiting"
|
||||
self.errors.pop(agent_id, None)
|
||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
||||
runtime.user_wake_required = False
|
||||
# An empty mailbox parks the agent straight back where it was, so the
|
||||
# note is what actually resumes it as well as what explains the gap.
|
||||
runtime.mailbox.append(dict(_REVIVED_NOTICE))
|
||||
self.pending_counts[agent_id] = self.pending_counts.get(agent_id, 0) + 1
|
||||
runtime.wake.set()
|
||||
revived.append(agent_id)
|
||||
if revived:
|
||||
logger.info("user message revived %d failed agent(s)", len(revived))
|
||||
return revived
|
||||
|
||||
async def reset_budget_stops(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -321,10 +282,6 @@ class AgentCoordinator:
|
|||
from_user = message.get("from") == "user"
|
||||
if from_user and self._budget_paused:
|
||||
await self.resume_from_budget_pause(exclude=target_agent_id)
|
||||
if from_user:
|
||||
# The gate below only unsticks the agent addressed. Everything else that
|
||||
# died is waiting on the same human, who has just spoken.
|
||||
await self.revive_failed_agents(exclude=target_agent_id)
|
||||
async with self._lock:
|
||||
if target_agent_id not in self.statuses:
|
||||
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
"""A user's message revives every agent that died, not just the one addressed.
|
||||
|
||||
A failed agent parks until a human tells it to go again, and the user can only
|
||||
realistically reach the root. Drawn from a run where a provider outage failed
|
||||
eighteen agents in thirteen seconds and only the root ever came back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.core.agents import AgentCoordinator
|
||||
|
||||
|
||||
async def _fleet(children: int = 3) -> AgentCoordinator:
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
for index in range(children):
|
||||
await coordinator.register(f"child{index}", f"hunter {index}", parent_id="root")
|
||||
return coordinator
|
||||
|
||||
|
||||
def _user_message(text: str = "credits are back, keep going") -> dict[str, str]:
|
||||
return {"from": "user", "content": text, "type": "instruction"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_user_message_revives_the_whole_fleet() -> None:
|
||||
"""The reported scenario: everything is down and only the root is reachable."""
|
||||
coordinator = await _fleet()
|
||||
for agent_id in ("root", "child0", "child1", "child2"):
|
||||
await coordinator.set_status(agent_id, "failed", error="provider said no")
|
||||
assert all(coordinator.runtimes[f"child{i}"].user_wake_required for i in range(3))
|
||||
|
||||
await coordinator.send("root", _user_message())
|
||||
|
||||
for index in range(3):
|
||||
child = f"child{index}"
|
||||
assert coordinator.statuses[child] == "waiting", f"{child} was left for dead"
|
||||
assert coordinator.runtimes[child].user_wake_required is False
|
||||
assert coordinator.pending_counts[child] == 1
|
||||
assert child not in coordinator.errors
|
||||
# The addressed agent is woken by the user's own message, not a notice.
|
||||
assert coordinator.runtimes["root"].user_wake_required is False
|
||||
assert coordinator.runtimes["root"].mailbox == [_user_message()]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_revived_agent_wakes_on_the_notice() -> None:
|
||||
"""An empty mailbox parks the agent straight back where it was."""
|
||||
coordinator = await _fleet(children=1)
|
||||
await coordinator.set_status("child0", "failed", error="down")
|
||||
|
||||
await coordinator.send("root", _user_message())
|
||||
|
||||
assert await coordinator.wait_for_message("child0", timeout=0.1) is True
|
||||
count, _items = await coordinator.consume_pending("child0")
|
||||
assert count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revival_is_not_rationed() -> None:
|
||||
"""A human deciding to try again is the only limit there needs to be."""
|
||||
coordinator = await _fleet(children=1)
|
||||
|
||||
for attempt in range(6):
|
||||
await coordinator.set_status("child0", "failed", error="down again")
|
||||
await coordinator.send("root", _user_message())
|
||||
assert coordinator.statuses["child0"] == "waiting", f"attempt {attempt} was refused"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["failed", "crashed"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_revival_does_not_care_how_the_agent_died(status: str) -> None:
|
||||
coordinator = await _fleet(children=1)
|
||||
await coordinator.set_status("child0", status, error="whatever it was")
|
||||
|
||||
await coordinator.send("root", _user_message())
|
||||
|
||||
assert coordinator.statuses["child0"] == "waiting"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["running", "waiting", "completed", "stopped", "budget_paused"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_agents_that_did_not_die_are_left_alone(status: str) -> None:
|
||||
"""A deliberate stop - budget, user request - is not a failure to recover from."""
|
||||
coordinator = await _fleet(children=1)
|
||||
await coordinator.set_status("child0", status)
|
||||
|
||||
await coordinator.send("root", _user_message())
|
||||
|
||||
assert coordinator.statuses["child0"] == status
|
||||
assert coordinator.pending_counts["child0"] == 0
|
||||
assert coordinator.runtimes["child0"].mailbox == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messaging_a_child_revives_its_failed_siblings() -> None:
|
||||
"""A person is present either way; which agent they addressed is incidental."""
|
||||
coordinator = await _fleet(children=2)
|
||||
await coordinator.set_status("child0", "failed", error="down")
|
||||
await coordinator.set_status("child1", "failed", error="down")
|
||||
|
||||
await coordinator.send("child0", _user_message("look at this instead"))
|
||||
|
||||
assert coordinator.statuses["child1"] == "waiting"
|
||||
assert coordinator.runtimes["child0"].user_wake_required is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_agent_message_revives_nobody() -> None:
|
||||
"""Only a human clears the gate; agents talking among themselves must not."""
|
||||
coordinator = await _fleet(children=2)
|
||||
await coordinator.set_status("child1", "failed", error="down")
|
||||
|
||||
await coordinator.send("child0", {"from": "root", "content": "status?"})
|
||||
|
||||
assert coordinator.statuses["child1"] == "failed"
|
||||
assert coordinator.runtimes["child1"].user_wake_required is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_message_to_an_unknown_agent_still_revives_the_fleet() -> None:
|
||||
"""The user spoke; a stale target id should not swallow that."""
|
||||
coordinator = await _fleet(children=1)
|
||||
await coordinator.set_status("child0", "failed", error="down")
|
||||
|
||||
assert await coordinator.send("gone", _user_message()) is False
|
||||
|
||||
assert coordinator.statuses["child0"] == "waiting"
|
||||
Loading…
Add table
Reference in a new issue