mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 19:32:12 +00:00
fix: purge expired thread_wakeup crons (#1656)
* fix: purge expired thread_wakeup crons One-shot wakeup crons set an end_time that stops re-firing but the cron row is never deleted, so dead rows accumulate (86 in prod). Add a purge that deletes thread_wakeup crons past their end_time, called opportunistically before scheduling a new wakeup, plus a one-time backfill script. Conservative: matches only kind=thread_wakeup with a past end_time. * chore: retrigger Open SWE review --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 9e5a1924ef306269322c31342a1831e57831cfee)
This commit is contained in:
parent
0618831e92
commit
918641d3e2
3 changed files with 281 additions and 1 deletions
|
|
@ -18,6 +18,9 @@ _MIN_DELAY_SECONDS = 60
|
||||||
_MAX_DELAY_SECONDS = 86_400
|
_MAX_DELAY_SECONDS = 86_400
|
||||||
_END_TIME_PADDING_SECONDS = 90
|
_END_TIME_PADDING_SECONDS = 90
|
||||||
|
|
||||||
|
_WAKEUP_KIND = "thread_wakeup"
|
||||||
|
_PURGE_PAGE_SIZE = 100
|
||||||
|
|
||||||
_DEFAULT_WAKEUP_PROMPT = (
|
_DEFAULT_WAKEUP_PROMPT = (
|
||||||
"This is an automated re-trigger of this thread. The agent scheduled this "
|
"This is an automated re-trigger of this thread. The agent scheduled this "
|
||||||
"wakeup to poll for updates. Check the current state of whatever you were "
|
"wakeup to poll for updates. Check the current state of whatever you were "
|
||||||
|
|
@ -46,6 +49,71 @@ def _build_one_shot_cron(fire_time: datetime) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(value: Any) -> datetime | None:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def find_expired_wakeup_cron_ids(client: Any, *, now: datetime) -> list[str]:
|
||||||
|
"""Return the ids of ``thread_wakeup`` crons whose ``end_time`` has passed.
|
||||||
|
|
||||||
|
Conservative: matches solely on ``metadata.kind == "thread_wakeup"`` AND a
|
||||||
|
past ``end_time``, so analyzer/dashboard crons are never selected. Paginates
|
||||||
|
fully before returning so the result is stable to delete afterwards.
|
||||||
|
"""
|
||||||
|
expired_ids: list[str] = []
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
page = await client.crons.search(
|
||||||
|
metadata={"kind": _WAKEUP_KIND},
|
||||||
|
limit=_PURGE_PAGE_SIZE,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
for cron in page:
|
||||||
|
if not isinstance(cron, dict):
|
||||||
|
continue
|
||||||
|
end_time = _parse_iso(cron.get("end_time"))
|
||||||
|
cron_id = cron.get("cron_id")
|
||||||
|
if end_time is not None and end_time < now and isinstance(cron_id, str) and cron_id:
|
||||||
|
expired_ids.append(cron_id)
|
||||||
|
if len(page) < _PURGE_PAGE_SIZE:
|
||||||
|
break
|
||||||
|
offset += len(page)
|
||||||
|
return expired_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_expired_wakeup_crons(client: Any, *, now: datetime) -> int:
|
||||||
|
"""Delete ``thread_wakeup`` crons whose ``end_time`` has already passed.
|
||||||
|
|
||||||
|
Each wakeup is a thread-bound cron with an ``end_time`` (~90s past its fire)
|
||||||
|
that stops it re-firing, but the cron row itself is never removed, so dead
|
||||||
|
rows accumulate. This deletes only those dead rows. Returns the count deleted.
|
||||||
|
"""
|
||||||
|
expired_ids = await find_expired_wakeup_cron_ids(client, now=now)
|
||||||
|
deleted = 0
|
||||||
|
for cron_id in expired_ids:
|
||||||
|
await client.crons.delete(cron_id)
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_expired_wakeups_best_effort() -> None:
|
||||||
|
"""Opportunistically purge expired wakeup crons; never raises."""
|
||||||
|
try:
|
||||||
|
client = get_client(url=langgraph_url())
|
||||||
|
deleted = await purge_expired_wakeup_crons(client, now=datetime.now(UTC))
|
||||||
|
if deleted:
|
||||||
|
logger.info("Purged %d expired thread_wakeup cron(s)", deleted)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to purge expired thread_wakeup crons", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _create_wakeup_cron(
|
async def _create_wakeup_cron(
|
||||||
*,
|
*,
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
|
|
@ -130,6 +198,8 @@ async def schedule_thread_wakeup(delay_minutes: int, prompt: str | None = None)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
wakeup_configurable[key] = value
|
wakeup_configurable[key] = value
|
||||||
|
|
||||||
|
await _purge_expired_wakeups_best_effort()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await _create_wakeup_cron(
|
return await _create_wakeup_cron(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
|
|
|
||||||
88
scripts/purge_wakeup_crons.py
Normal file
88
scripts/purge_wakeup_crons.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""One-time backfill: delete expired ``thread_wakeup`` crons from a deployment.
|
||||||
|
|
||||||
|
One-shot wakeup crons set an ``end_time`` that stops them re-firing, but the
|
||||||
|
cron row is never removed, so dead rows accumulate. The ``schedule_thread_wakeup``
|
||||||
|
tool now purges these opportunistically; this script clears the backlog.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python scripts/purge_wakeup_crons.py --dry-run
|
||||||
|
uv run python scripts/purge_wakeup_crons.py
|
||||||
|
|
||||||
|
Resolves the deployment URL from ``--url`` or ``LANGGRAPH_URL`` / ``LANGGRAPH_URL_PROD``,
|
||||||
|
and the API key from ``LANGGRAPH_API_KEY`` / ``LANGSMITH_API_KEY`` / ``LANGSMITH_API_KEY_PROD``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
|
from agent.tools.schedule_thread_wakeup import (
|
||||||
|
find_expired_wakeup_cron_ids,
|
||||||
|
purge_expired_wakeup_crons,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv_if_available() -> None:
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_url(arg_url: str | None) -> str:
|
||||||
|
url = arg_url or os.environ.get("LANGGRAPH_URL") or os.environ.get("LANGGRAPH_URL_PROD")
|
||||||
|
if not url:
|
||||||
|
raise RuntimeError("Set --url or LANGGRAPH_URL / LANGGRAPH_URL_PROD")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_api_key() -> str | None:
|
||||||
|
return (
|
||||||
|
os.environ.get("LANGGRAPH_API_KEY")
|
||||||
|
or os.environ.get("LANGSMITH_API_KEY")
|
||||||
|
or os.environ.get("LANGSMITH_API_KEY_PROD")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(url: str, api_key: str | None, dry_run: bool) -> None:
|
||||||
|
client = get_client(url=url, api_key=api_key)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if dry_run:
|
||||||
|
expired = await find_expired_wakeup_cron_ids(client, now=now)
|
||||||
|
logger.info("[dry-run] %d expired thread_wakeup cron(s) would be deleted", len(expired))
|
||||||
|
for cron_id in expired:
|
||||||
|
logger.info(" %s", cron_id)
|
||||||
|
return
|
||||||
|
deleted = await purge_expired_wakeup_crons(client, now=now)
|
||||||
|
logger.info("Deleted %d expired thread_wakeup cron(s)", deleted)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Purge expired thread_wakeup crons.")
|
||||||
|
parser.add_argument("--url", default=None, help="Deployment URL (defaults to env).")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="List the crons that would be deleted without deleting them.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
_load_dotenv_if_available()
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
args = parse_args()
|
||||||
|
asyncio.run(_run(_resolve_url(args.url), _resolve_api_key(), args.dry_run))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -1,13 +1,67 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
wakeup_tool = importlib.import_module("agent.tools.schedule_thread_wakeup")
|
wakeup_tool = importlib.import_module("agent.tools.schedule_thread_wakeup")
|
||||||
|
|
||||||
|
# Captured before the autouse stub replaces it, for the one test that needs the real wrapper.
|
||||||
|
_real_purge_best_effort = wakeup_tool._purge_expired_wakeups_best_effort
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _stub_purge(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Keep the opportunistic purge from touching the network in every test."""
|
||||||
|
|
||||||
|
async def _noop() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(wakeup_tool, "_purge_expired_wakeups_best_effort", _noop)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCrons:
|
||||||
|
def __init__(self, crons: list[dict[str, Any]]) -> None:
|
||||||
|
self._crons = list(crons)
|
||||||
|
self.deleted: list[str] = []
|
||||||
|
self.search_calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
offset: int = 0,
|
||||||
|
**_: Any,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
self.search_calls.append({"metadata": metadata, "limit": limit, "offset": offset})
|
||||||
|
items = [
|
||||||
|
c
|
||||||
|
for c in self._crons
|
||||||
|
if not metadata
|
||||||
|
or all((c.get("metadata") or {}).get(k) == v for k, v in metadata.items())
|
||||||
|
]
|
||||||
|
return items[offset : offset + limit]
|
||||||
|
|
||||||
|
async def delete(self, cron_id: str) -> None:
|
||||||
|
self.deleted.append(cron_id)
|
||||||
|
self._crons = [c for c in self._crons if c.get("cron_id") != cron_id]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, crons: list[dict[str, Any]]) -> None:
|
||||||
|
self.crons = _FakeCrons(crons)
|
||||||
|
|
||||||
|
|
||||||
|
def _wakeup_cron(cron_id: str, end_time: datetime | None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"cron_id": cron_id,
|
||||||
|
"end_time": end_time.isoformat() if end_time else None,
|
||||||
|
"metadata": {"kind": "thread_wakeup"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _config(**overrides: Any) -> dict[str, Any]:
|
def _config(**overrides: Any) -> dict[str, Any]:
|
||||||
base: dict[str, Any] = {
|
base: dict[str, Any] = {
|
||||||
|
|
@ -241,3 +295,71 @@ def test_build_one_shot_cron_handles_month_boundary() -> None:
|
||||||
assert parts[1] == "23"
|
assert parts[1] == "23"
|
||||||
assert parts[2] == "31"
|
assert parts[2] == "31"
|
||||||
assert parts[3] == "12"
|
assert parts[3] == "12"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_purge_deletes_only_expired_wakeups() -> None:
|
||||||
|
now = datetime(2026, 6, 30, 22, 0, tzinfo=UTC)
|
||||||
|
client = _FakeClient(
|
||||||
|
[
|
||||||
|
_wakeup_cron("expired-1", now - timedelta(hours=1)),
|
||||||
|
_wakeup_cron("expired-2", now - timedelta(days=1)),
|
||||||
|
_wakeup_cron("future-1", now + timedelta(hours=1)),
|
||||||
|
_wakeup_cron("no-end", None),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await wakeup_tool.purge_expired_wakeup_crons(client, now=now)
|
||||||
|
|
||||||
|
assert deleted == 2
|
||||||
|
assert client.crons.deleted == ["expired-1", "expired-2"]
|
||||||
|
# Search is scoped to the thread_wakeup kind so other crons are never seen.
|
||||||
|
assert client.crons.search_calls[0]["metadata"] == {"kind": "thread_wakeup"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_purge_paginates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(wakeup_tool, "_PURGE_PAGE_SIZE", 2)
|
||||||
|
now = datetime(2026, 6, 30, 22, 0, tzinfo=UTC)
|
||||||
|
client = _FakeClient([_wakeup_cron(f"expired-{i}", now - timedelta(hours=1)) for i in range(3)])
|
||||||
|
|
||||||
|
deleted = await wakeup_tool.purge_expired_wakeup_crons(client, now=now)
|
||||||
|
|
||||||
|
assert deleted == 3
|
||||||
|
assert sorted(client.crons.deleted) == ["expired-0", "expired-1", "expired-2"]
|
||||||
|
# Two pages fetched (offset 0 and 2), then a short final page ends the loop.
|
||||||
|
assert [c["offset"] for c in client.crons.search_calls] == [0, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_best_effort_purge_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
async def boom(*_: Any, **__: Any) -> int:
|
||||||
|
raise RuntimeError("search failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(wakeup_tool, "purge_expired_wakeup_crons", boom)
|
||||||
|
monkeypatch.setattr(wakeup_tool, "get_client", lambda url: object())
|
||||||
|
|
||||||
|
# The real wrapper must never propagate — a purge failure can't block wakeups.
|
||||||
|
await _real_purge_best_effort()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_schedule_purges_before_creating(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def spy_purge() -> None:
|
||||||
|
calls.append("purge")
|
||||||
|
|
||||||
|
async def fake_create_wakeup_cron(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
calls.append("create")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"cron_id": "cron-1",
|
||||||
|
"scheduled_for": "",
|
||||||
|
"thread_id": kwargs["thread_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(wakeup_tool, "get_config", _config)
|
||||||
|
monkeypatch.setattr(wakeup_tool, "_purge_expired_wakeups_best_effort", spy_purge)
|
||||||
|
monkeypatch.setattr(wakeup_tool, "_create_wakeup_cron", fake_create_wakeup_cron)
|
||||||
|
|
||||||
|
result = await wakeup_tool.schedule_thread_wakeup(5)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert calls == ["purge", "create"]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue