Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
"""Session-boundary gating for background-process completion delivery.
|
||||
|
||||
Plain ``type=completion`` events historically carried only ``session_key``
|
||||
(chat/thread routing), so a background process spawned in session A whose
|
||||
completion fired after ``/new`` was injected into the chat's NEW session.
|
||||
The fix stamps the spawning conversation's session-db id on the watcher at
|
||||
spawn time and routes stamped events through the SAME pre-flight policy the
|
||||
async-delegation path already uses (``_classify_completion_target``):
|
||||
|
||||
- terminal (user boundary such as /new) -> drop with a log
|
||||
- retry (transient DB uncertainty) -> watcher re-polls
|
||||
- deliver (live / idle-ended parent) -> proceed as today
|
||||
|
||||
Unstamped legacy events keep today's deliver-always behavior.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.run import GatewayRunner
|
||||
from tools.process_registry import ProcessRegistry, ProcessSession
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_registry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import tools.process_registry as pr_module
|
||||
|
||||
monkeypatch.setattr(pr_module, "CHECKPOINT_PATH", tmp_path / "processes.json")
|
||||
registry = pr_module.ProcessRegistry()
|
||||
monkeypatch.setattr(pr_module, "process_registry", registry)
|
||||
return registry
|
||||
|
||||
|
||||
class _SessionDB:
|
||||
def __init__(self, row, tip=None):
|
||||
self._row = row
|
||||
self._tip = tip
|
||||
|
||||
async def get_session(self, session_id):
|
||||
return self._row
|
||||
|
||||
async def get_compression_tip(self, session_id):
|
||||
return self._tip
|
||||
|
||||
|
||||
def _runner(adapter, *, session_db=...):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner.session_store = SimpleNamespace(
|
||||
_ensure_loaded=lambda: None,
|
||||
_entries={},
|
||||
)
|
||||
runner._session_source_cache = {}
|
||||
runner._completion_delivery_lock = __import__("threading").Lock()
|
||||
runner._completion_deliveries_inflight = set()
|
||||
runner._completion_deliveries_delivered = OrderedDict()
|
||||
runner._completion_delivery_retention = 2048
|
||||
if session_db is not ...:
|
||||
runner._session_db = session_db
|
||||
return runner
|
||||
|
||||
|
||||
def _finished_session(registry, session_id="proc_boundary", **kwargs):
|
||||
session = ProcessSession(
|
||||
id=session_id,
|
||||
command="echo done",
|
||||
task_id="task",
|
||||
started_at=1234.5,
|
||||
output_buffer="done\n",
|
||||
exited=True,
|
||||
exit_code=0,
|
||||
notify_on_complete=True,
|
||||
**kwargs,
|
||||
)
|
||||
registry._finished[session.id] = session
|
||||
return session
|
||||
|
||||
|
||||
def _watcher(session_id, parent_session_id=None):
|
||||
watcher = {
|
||||
"session_id": session_id,
|
||||
"check_interval": 0,
|
||||
"session_key": "agent:main:telegram:dm:123",
|
||||
"platform": "telegram",
|
||||
"chat_type": "dm",
|
||||
"chat_id": "123",
|
||||
"notify_on_complete": True,
|
||||
}
|
||||
if parent_session_id is not None:
|
||||
watcher["parent_session_id"] = parent_session_id
|
||||
return watcher
|
||||
|
||||
|
||||
def _run_watcher(monkeypatch, runner, watcher):
|
||||
async def _instant_sleep(*_a, **_kw):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
|
||||
asyncio.run(runner._run_process_watcher(watcher))
|
||||
|
||||
|
||||
def _completion_evt(parent_session_id=None, session_id="proc_x"):
|
||||
evt = {
|
||||
"type": "completion",
|
||||
"session_id": session_id,
|
||||
"session_key": "agent:main:telegram:dm:123",
|
||||
"platform": "telegram",
|
||||
"chat_type": "dm",
|
||||
"chat_id": "123",
|
||||
"started_at": 1234.5,
|
||||
"command": "echo done",
|
||||
"exit_code": 0,
|
||||
"completion_reason": "exited",
|
||||
"output": "done\n",
|
||||
}
|
||||
if parent_session_id is not None:
|
||||
evt["parent_session_id"] = parent_session_id
|
||||
return evt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The stamp is threaded from the watcher into the completion event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_watcher_stamps_parent_session_id_on_completion_event(
|
||||
monkeypatch, isolated_registry,
|
||||
):
|
||||
_finished_session(isolated_registry)
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(adapter)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def _capture(_text, evt):
|
||||
captured.update(evt)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(runner, "_deliver_completion_notification", _capture)
|
||||
_run_watcher(
|
||||
monkeypatch, runner, _watcher("proc_boundary", "sess-spawner"),
|
||||
)
|
||||
|
||||
assert captured.get("type") == "completion"
|
||||
assert captured.get("parent_session_id") == "sess-spawner"
|
||||
|
||||
|
||||
def test_watcher_falls_back_to_process_session_stamp(
|
||||
monkeypatch, isolated_registry,
|
||||
):
|
||||
"""Watchers recovered without the stamp still pick it up off the
|
||||
ProcessSession (spawn-time stamp survives checkpoint/restore there)."""
|
||||
_finished_session(
|
||||
isolated_registry, parent_session_id="sess-from-registry",
|
||||
)
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(adapter)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def _capture(_text, evt):
|
||||
captured.update(evt)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(runner, "_deliver_completion_notification", _capture)
|
||||
_run_watcher(monkeypatch, runner, _watcher("proc_boundary"))
|
||||
|
||||
assert captured.get("parent_session_id") == "sess-from-registry"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight verdicts on stamped completion events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_completion_from_user_closed_session_is_dropped(
|
||||
monkeypatch, isolated_registry,
|
||||
):
|
||||
"""/new closed the spawning session -> the stamped completion must NOT
|
||||
land in the chat's new session."""
|
||||
_finished_session(isolated_registry)
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(
|
||||
adapter,
|
||||
session_db=_SessionDB(
|
||||
{"ended_at": 1786288000.0, "end_reason": "session_reset"}
|
||||
),
|
||||
)
|
||||
|
||||
_run_watcher(
|
||||
monkeypatch, runner, _watcher("proc_boundary", "sess-closed"),
|
||||
)
|
||||
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
def test_completion_after_idle_end_still_delivers(
|
||||
monkeypatch, isolated_registry,
|
||||
):
|
||||
"""Idle/timeout ends are the relay-plane norm — the chat stays routable
|
||||
and the completion must deliver."""
|
||||
_finished_session(isolated_registry)
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(
|
||||
adapter,
|
||||
session_db=_SessionDB(
|
||||
{"ended_at": 1786288000.0, "end_reason": "idle_timeout"}
|
||||
),
|
||||
)
|
||||
|
||||
_run_watcher(
|
||||
monkeypatch, runner, _watcher("proc_boundary", "sess-idle"),
|
||||
)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
def test_completion_from_live_session_delivers(monkeypatch, isolated_registry):
|
||||
_finished_session(isolated_registry)
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(adapter, session_db=_SessionDB({"ended_at": None}))
|
||||
|
||||
_run_watcher(
|
||||
monkeypatch, runner, _watcher("proc_boundary", "sess-live"),
|
||||
)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
def test_unstamped_legacy_completion_delivers(monkeypatch, isolated_registry):
|
||||
"""Events without the spawn-time stamp keep today's behavior even when a
|
||||
session DB is present."""
|
||||
_finished_session(isolated_registry)
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(adapter, session_db=_SessionDB(None))
|
||||
|
||||
_run_watcher(monkeypatch, runner, _watcher("proc_boundary"))
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
def test_retry_verdict_returns_false_for_watcher_repoll():
|
||||
"""No session DB yet -> transient uncertainty -> retryable False, and no
|
||||
adapter injection happens."""
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(adapter, session_db=None)
|
||||
|
||||
result = asyncio.run(
|
||||
runner._deliver_completion_notification(
|
||||
"text", _completion_evt("sess-uncertain"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result is False
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
def test_terminal_verdict_returns_none_without_injection():
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(adapter, session_db=_SessionDB(None))
|
||||
|
||||
result = asyncio.run(
|
||||
runner._deliver_completion_notification(
|
||||
"text", _completion_evt("sess-gone"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result is None
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The async-delegation path is unaffected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_async_delegation_gate_unchanged():
|
||||
"""A stamped async_delegation event still routes through the existing
|
||||
delegation-owned gate (terminal verdict -> None), proving the completion
|
||||
branch did not fork or shadow the delegation policy."""
|
||||
adapter = SimpleNamespace(handle_message=AsyncMock())
|
||||
runner = _runner(adapter, session_db=_SessionDB(None))
|
||||
|
||||
evt = {
|
||||
"type": "async_delegation",
|
||||
"delegation_id": "",
|
||||
"session_key": "agent:main:telegram:dm:12345:678",
|
||||
"parent_session_id": "sess-gone",
|
||||
"status": "completed",
|
||||
}
|
||||
result = asyncio.run(runner._deliver_completion_notification("text", evt))
|
||||
|
||||
assert result is None
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The stamp survives checkpoint/restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parent_session_id_survives_checkpoint_recovery(tmp_path, monkeypatch):
|
||||
import tools.process_registry as pr_module
|
||||
|
||||
checkpoint = tmp_path / "processes.json"
|
||||
checkpoint.write_text(json.dumps([{
|
||||
"session_id": "proc_recovered",
|
||||
"command": "sleep 999",
|
||||
"pid": 4242,
|
||||
"pid_scope": "host",
|
||||
"host_start_time": 111.0,
|
||||
"started_at": 1234.5,
|
||||
"task_id": "task",
|
||||
"session_key": "agent:main:telegram:dm:123",
|
||||
"watcher_platform": "telegram",
|
||||
"watcher_chat_id": "123",
|
||||
"watcher_interval": 5,
|
||||
"notify_on_complete": True,
|
||||
"parent_session_id": "sess-spawner",
|
||||
}]), encoding="utf-8")
|
||||
monkeypatch.setattr(pr_module, "CHECKPOINT_PATH", checkpoint)
|
||||
|
||||
registry = ProcessRegistry()
|
||||
monkeypatch.setattr(registry, "_host_pid_is_ours", lambda *_a: True)
|
||||
monkeypatch.setattr(
|
||||
registry, "_write_checkpoint", lambda *_a, **_kw: None,
|
||||
)
|
||||
|
||||
assert registry.recover_from_checkpoint() == 1
|
||||
assert registry.get("proc_recovered").parent_session_id == "sess-spawner"
|
||||
assert len(registry.pending_watchers) == 1
|
||||
assert registry.pending_watchers[0]["parent_session_id"] == "sess-spawner"
|
||||
Reference in New Issue
Block a user