355af84fb1
Combines the 2026-05-09 hard-switch decommissioning of the LLM
implementation/pr-review supervisors with the Phase 2/3/4/5b
implementer parity work (prefetch + preclone + telemetry + operator-
status comments) and the third/fourth-round critique cleanup.
Removed
- .opencode/agents/implementation-supervisor.md (340 LoC)
- .opencode/agents/pr-review-supervisor.md (348 LoC)
- _dispatch_runtime.assert_no_legacy_supervisor +
detect_legacy_supervisor_sessions and the SUPERVISOR_TAGS /
SUPERVISOR_OVERRIDE_ENV plumbing in both dispatchers, along with
the five supervisor-coexistence tests in test_dispatch_runtime.py
- _watchdog_helpers.parse_truthy_env + watchdog_check.py
--check-env mode + their dedicated unit tests (the legacy
DISPATCHERS_RUNNING gate had no callers after the watchdog
rewrite became unconditional)
Added
- tools/_implementer_prefetch.py — pre-dispatch Forgejo fetches
(PR/issue body, diff, CI status, comments, reviews, linked
issues, Epic) per work group
- tools/_implementer_prompt.py — pure-function prompt assembly
with UNTRUSTED CONTENT fences and shared
PR_COMPLIANCE_CHECKLIST / OUTPUT_CONTRACT
- tools/_phase4_telemetry.py — extractor + JSONL sink for the
Phase 4 plan metrics
- tools/_status_comments.py — per-fingerprint operator-status
comment substrate, namespaced for reviewer + implementer
- _dispatch_runtime.SessionContext dataclass + SIGTERM/SIGINT
cooperative claim release with synchronous handler
- TestSupervisorAgentsDecommissioned and
TestAutoAgentsMdIsWatchdogOnly anti-regression lints (glob over
*supervisor*.md in .opencode/agents/, plus body keyword bans
and bash allow-list lint)
- pyproject.toml `slow` marker registration for the subprocess
SIGTERM smoke test
- tests/auto_agents/fixtures/{phase4-acceptance.yaml,
phase4-session-output-sample.txt}
Rewritten
- .opencode/agents/auto-agents.md from supervisor-fleet manager
(~545 LoC) to dispatcher heartbeat watchdog (~184 LoC); host
init system / process manager (systemd / runit / docker) is now
the explicit restart authority instead of "host-level process
supervisor"
- AGENTS.md production-launch story (Shells A-D) reflects the
deterministic-Python orchestration boundary; the bot-identity
fork-mode paragraph reads from FORGEJO_OWNER / FORGEJO_REPO
env vars instead of the deleted hard-coded supervisor flags
- tools/launch_fork.sh header documents three host-level entry
points (dispatchers-launcher.sh, opencode-builder.sh,
merge_drive.py)
- worker self-descriptions (implementation-worker.md,
pr-review-worker.md) refer to the dispatcher / merge driver
instead of the deleted supervisors; session-health-quick-util.md
and async-agent-util.md treat -SUP-suffixed sessions as
flag-and-escalate signals
Tests: 1006 passed, 3 skipped, 0 failed under tests/auto_agents/.
Lint: zero new ruff errors on touched files; three pre-existing
errors in tools/_pr_diff.py at lines blamed to 2026-05-07.
Operator note: the only in-process rollback knob for prefetch
issues is IMPLEMENTER_DISPATCHER_PREFETCH=0 (and the matching
IMPLEMENTER_DISPATCHER_PRECLONE=0). Anything beyond that is git
revert of this commit. Residual doc surface in the
auto-agents-system and supervised-workers skill READMEs is
documentation-only; the agent files those READMEs reference no
longer exist.
Co-authored-by: Cursor <cursoragent@cursor.com>
1266 lines
49 KiB
Python
1266 lines
49 KiB
Python
"""Shared runtime for deterministic review / implementation dispatchers.
|
|
|
|
This module is intentionally small: it reuses the Phase A
|
|
``_claim_runtime.py`` and ``_opencode_worker.py`` primitives, runs the
|
|
existing ``list_prs_*`` TypeScript wrappers, claims PR work before dispatch,
|
|
and records one SQLite row per dispatched item. The LLM workers still make
|
|
the review / implementation decisions; Python owns only orchestration.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Make ``tools/`` importable so we can pull the shared sibling loader by
|
|
# regular import rather than reimplementing the file-load dance inline.
|
|
# Both ``python tools/...`` invocation (sys.path[0] is already tools/) and
|
|
# pytest's ``spec_from_file_location`` loader (no implicit tools/ on path)
|
|
# work after this idempotent insert.
|
|
_TOOLS_DIR = str(Path(__file__).resolve().parent)
|
|
if _TOOLS_DIR not in sys.path:
|
|
sys.path.insert(0, _TOOLS_DIR)
|
|
from _loader import ( # noqa: E402 type: ignore[import-not-found]
|
|
load_sibling as _load_sibling,
|
|
)
|
|
|
|
logger = logging.getLogger("dispatch_runtime")
|
|
|
|
|
|
_claim_runtime = _load_sibling("_claim_runtime", "_claim_runtime.py")
|
|
_forgejo_cache = _load_sibling("_forgejo_cache", "_forgejo_cache.py")
|
|
_opencode_worker = _load_sibling("_opencode_worker", "_opencode_worker.py")
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SCRIPT_DIR = REPO_ROOT / ".opencode" / "skills" / "auto-agents-system" / "scripts"
|
|
|
|
API_BASE = _claim_runtime.API_BASE
|
|
REPO_OWNER = _claim_runtime.REPO_OWNER
|
|
REPO_NAME = _claim_runtime.REPO_NAME
|
|
|
|
CLAIM_COMMENT_MARKER = "<!-- claim_pr.ts: do-not-edit -->"
|
|
|
|
# Cap on the worker raw-response excerpt we quote into a Forgejo claim
|
|
# release comment. Worker output can be arbitrarily long and may contain
|
|
# tool-call traces, escape sequences, or partial Markdown that would
|
|
# break out of the surrounding code fence; truncate before sanitizing.
|
|
_RELEASE_DETAIL_MAX_CHARS = 500
|
|
|
|
|
|
def _sanitize_release_detail(raw: str | None) -> str:
|
|
"""Make a best-effort safe excerpt of a worker's raw response for
|
|
inclusion in a Forgejo release comment.
|
|
|
|
The release comment is operator-facing (and lands in a public
|
|
Forgejo timeline), so we want a deterministic, readable, fence-safe
|
|
excerpt rather than a verbatim dump. This:
|
|
|
|
1. Returns ``""`` when the input is empty / None.
|
|
2. Truncates to ``_RELEASE_DETAIL_MAX_CHARS`` *before* the rest of
|
|
the cleanup so we never spend cycles on multi-MB inputs.
|
|
3. Strips control characters except newline and tab — a stray
|
|
carriage return or escape sequence could otherwise corrupt the
|
|
comment renderer.
|
|
4. Replaces backtick runs of length >= 3 so a worker emitting a
|
|
triple-backtick code fence cannot close out the surrounding
|
|
code fence in the release comment template.
|
|
|
|
Note: sanitization is operator-facing hygiene, not a security
|
|
control. The comment author identity is the bot, not the worker;
|
|
nothing in the excerpt is interpreted as code.
|
|
"""
|
|
if not raw:
|
|
return ""
|
|
excerpt = raw[:_RELEASE_DETAIL_MAX_CHARS]
|
|
cleaned = "".join(
|
|
ch
|
|
for ch in excerpt
|
|
if ch in ("\n", "\t") or (0x20 <= ord(ch) < 0x7F) or ord(ch) > 0x7F
|
|
)
|
|
# Single replace is sufficient: the substitute (modifier-letter
|
|
# apostrophes) contains no backticks, so it can never produce a
|
|
# new "```" run for a second pass to find.
|
|
return cleaned.replace("```", "ʼʼʼ")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorkGroup:
|
|
name: str
|
|
script_name: str
|
|
item_kind: str
|
|
claim_kind: str | None
|
|
worker_agent: str
|
|
tag_prefix: str
|
|
prompt_factory: Callable[[DispatchConfig, dict[str, Any], WorkGroup], str]
|
|
# Optional hook invoked by ``dispatch_one`` after the worker
|
|
# session completes (and before the claim release). Positional
|
|
# arguments: ``cfg``, ``item``, the parsed JSON the session
|
|
# emitted (or None), the raw response text, the dispatcher-
|
|
# derived ``terminal_state`` ("completed" / "timeout" /
|
|
# "transport-error" / "dry-run"). Plus a single keyword-only
|
|
# ``session_context: SessionContext`` carrying the work-group
|
|
# name and the ISO-8601 timestamps + wall-clock that bracket
|
|
# ``run_session_blocking`` (see :class:`SessionContext`).
|
|
#
|
|
# Hooks that don't need the context accept ``**_`` to absorb
|
|
# the keyword. The dispatcher always passes ``session_context``;
|
|
# ``**_kwargs`` only exists for back-compat with hooks written
|
|
# against an earlier API shape.
|
|
#
|
|
# Returns a dict that gets merged into the dispatch outcome
|
|
# under ``post_session_result``. Errors must be caught by the
|
|
# action — exceptions propagate and would orphan the claim
|
|
# release. The reviewer dispatcher uses this hook to POST the
|
|
# worker's review verdict to Forgejo; the implementer dispatcher
|
|
# uses it for cleanup + telemetry + status comments.
|
|
post_session_action: (
|
|
Callable[..., dict[str, Any]] | None
|
|
) = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SessionContext:
|
|
"""Per-session context the dispatcher captures around
|
|
``run_session_blocking`` and threads into
|
|
:meth:`WorkGroup.post_session_action`.
|
|
|
|
Packing these four kwargs into a single dataclass replaces the
|
|
earlier "pile of keyword-only arguments" call shape. The dataclass
|
|
is intentionally frozen + slot-light: the dispatcher owns
|
|
construction and the post-session action treats it as a read-only
|
|
record.
|
|
|
|
Fields:
|
|
|
|
- ``work_group_name`` — canonical group name (one of
|
|
``failing_ci_pr`` / ``request_changes_pr`` / ``new_issue`` for
|
|
the implementer; the reviewer-side groups follow the same
|
|
convention). Equal to ``WorkGroup.name``.
|
|
- ``session_started_at`` / ``session_completed_at`` — ISO-8601 UTC
|
|
timestamps bracketing :func:`_opencode_worker.run_session_blocking`.
|
|
Phase 4 telemetry's ``start_ts`` / ``end_ts`` keys derive from
|
|
these so they reflect cycle wall-clock.
|
|
- ``session_wallclock_seconds`` —
|
|
``SessionResult.wallclock_seconds`` echoed through; redundant
|
|
with ``end - start`` but kept for cross-checking against the
|
|
worker's self-reported timing.
|
|
"""
|
|
|
|
work_group_name: str
|
|
session_started_at: str
|
|
session_completed_at: str
|
|
session_wallclock_seconds: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DispatchConfig:
|
|
token: str
|
|
forgejo_url: str
|
|
owner: str
|
|
repo: str
|
|
server_url: str
|
|
lock_path: Path
|
|
heartbeat_path: Path
|
|
cycle_interval_seconds: int
|
|
max_items_per_cycle: int
|
|
worker_timeout_seconds: int
|
|
claim_ttl_seconds: int
|
|
api_retries: int
|
|
request_timeout_s: int
|
|
script_timeout_seconds: int
|
|
table_name: str
|
|
dry_run: bool = False
|
|
cycle_failure_budget: int = 5
|
|
|
|
|
|
def _configure_logging(env_var: str) -> None:
|
|
level_name = os.environ.get(env_var, "INFO").upper()
|
|
level = getattr(logging, level_name, logging.INFO)
|
|
if not logging.getLogger().handlers:
|
|
logging.basicConfig(
|
|
level=level,
|
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
stream=sys.stderr,
|
|
)
|
|
else:
|
|
logging.getLogger().setLevel(level)
|
|
|
|
|
|
def _read_dotenv_value(name: str) -> str | None:
|
|
for path in (REPO_ROOT / ".devcontainer" / ".env", REPO_ROOT / ".env"):
|
|
if not path.exists():
|
|
continue
|
|
for line in path.read_text().splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
continue
|
|
key, value = stripped.split("=", 1)
|
|
if key.strip() == name:
|
|
return value.strip().strip('"').strip("'")
|
|
return None
|
|
|
|
|
|
def load_secret(*names: str) -> str:
|
|
for name in names:
|
|
value = os.environ.get(name) or _read_dotenv_value(name)
|
|
if value:
|
|
return value
|
|
joined = " / ".join(names)
|
|
raise SystemExit(f"missing required token ({joined})")
|
|
|
|
|
|
def derive_forgejo_url() -> str:
|
|
explicit = os.environ.get("FORGEJO_URL")
|
|
if explicit:
|
|
return explicit.rstrip("/")
|
|
if API_BASE.endswith("/api/v1"):
|
|
return API_BASE[: -len("/api/v1")]
|
|
return API_BASE.rstrip("/")
|
|
|
|
|
|
def resolve_lock_or_heartbeat(env_var: str, basename: str) -> Path:
|
|
explicit = os.environ.get(env_var)
|
|
if explicit:
|
|
return Path(explicit)
|
|
candidates: list[Path | None] = [
|
|
Path("/var/run") / basename,
|
|
Path(os.environ.get("XDG_RUNTIME_DIR", "")) / basename
|
|
if os.environ.get("XDG_RUNTIME_DIR")
|
|
else None,
|
|
Path("/tmp") / basename,
|
|
]
|
|
for candidate in candidates:
|
|
if candidate is None:
|
|
continue
|
|
try:
|
|
candidate.parent.mkdir(parents=True, exist_ok=True)
|
|
candidate.touch(exist_ok=True)
|
|
return candidate
|
|
except (OSError, PermissionError):
|
|
continue
|
|
return Path("/tmp") / basename
|
|
|
|
|
|
def run_list_script(
|
|
script_name: str,
|
|
cfg: DispatchConfig,
|
|
*,
|
|
token: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
script_path = SCRIPT_DIR / f"{script_name}.ts"
|
|
if not script_path.exists():
|
|
raise RuntimeError(f"work-group script does not exist: {script_path}")
|
|
cmd = [
|
|
"npx",
|
|
"--yes",
|
|
"tsx",
|
|
str(script_path),
|
|
"--url",
|
|
cfg.forgejo_url,
|
|
"--pat",
|
|
token or cfg.token,
|
|
"--owner",
|
|
cfg.owner,
|
|
"--repo",
|
|
cfg.repo,
|
|
]
|
|
proc = subprocess.run(
|
|
cmd,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
timeout=cfg.script_timeout_seconds,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(
|
|
f"{script_name} exited {proc.returncode}: {proc.stderr.strip()}"
|
|
)
|
|
try:
|
|
payload = json.loads(proc.stdout or "[]")
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(
|
|
f"{script_name} emitted invalid JSON: {proc.stdout[:500]!r}"
|
|
) from exc
|
|
if not isinstance(payload, list):
|
|
raise RuntimeError(f"{script_name} emitted non-list JSON")
|
|
return [item for item in payload if isinstance(item, dict)]
|
|
|
|
|
|
def collect_candidates(
|
|
cfg: DispatchConfig,
|
|
groups: list[WorkGroup],
|
|
) -> tuple[list[tuple[WorkGroup, dict[str, Any]]], dict[str, int]]:
|
|
candidates: list[tuple[WorkGroup, dict[str, Any]]] = []
|
|
counts: dict[str, int] = {}
|
|
seen: set[tuple[str, int]] = set()
|
|
for group in groups:
|
|
items = run_list_script(group.script_name, cfg)
|
|
counts[group.name] = len(items)
|
|
for item in items:
|
|
number = _item_number(item)
|
|
if number is None:
|
|
continue
|
|
key = (group.item_kind, number)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
candidates.append((group, item))
|
|
return candidates, counts
|
|
|
|
|
|
def _item_number(item: dict[str, Any]) -> int | None:
|
|
try:
|
|
number = int(item.get("number"))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return number if number > 0 else None
|
|
|
|
|
|
def _claim_label(kind: str) -> str:
|
|
return f"auto/claimed-{kind}"
|
|
|
|
|
|
def _existing_claim_labels(
|
|
number: int, cfg: DispatchConfig
|
|
) -> tuple[list[str] | None, int]:
|
|
"""Return ``(labels, status)`` for issue/PR ``number``.
|
|
|
|
``labels`` is the list of every ``auto/claimed-*`` label currently
|
|
attached, or ``None`` when the fetch failed at a level that means we
|
|
cannot trust "no claim is present" (auth, repo-gone, persistent
|
|
5xx). ``status`` is the HTTP status code from
|
|
``_claim_runtime.idempotent_get`` (already retried for 5xx /
|
|
transport errors).
|
|
|
|
The caller refuses the claim on ``None`` instead of proceeding,
|
|
closing the latent bug where a 401 / 403 silently let us POST a
|
|
claim label we had no business attaching.
|
|
"""
|
|
response = _claim_runtime.get(
|
|
f"/repos/{cfg.owner}/{cfg.repo}/issues/{number}/labels",
|
|
cfg,
|
|
)
|
|
status = int(response.get("status") or 0)
|
|
if status != 200:
|
|
return None, status
|
|
body = response.get("body")
|
|
if not isinstance(body, list):
|
|
return None, status
|
|
return (
|
|
[
|
|
str(entry.get("name"))
|
|
for entry in body
|
|
if isinstance(entry, dict)
|
|
and isinstance(entry.get("name"), str)
|
|
and str(entry.get("name")).startswith("auto/claimed-")
|
|
],
|
|
status,
|
|
)
|
|
|
|
|
|
def claim_work_item(
|
|
number: int,
|
|
cfg: DispatchConfig,
|
|
*,
|
|
claim_kind: str,
|
|
driver_name: str,
|
|
) -> dict[str, Any]:
|
|
"""Attempt to acquire the ``auto/claimed-{claim_kind}`` label on
|
|
``number``.
|
|
|
|
Forgejo's label-add API is idempotent — it returns 200 whether the
|
|
label was newly attached or already present — so we cannot
|
|
distinguish ownership at the API layer alone. To avoid releasing a
|
|
claim we did not acquire (and thereby disrupting another worker's
|
|
in-flight session), we pre-check the issue's labels and refuse when
|
|
*any* ``auto/claimed-*`` label is already present. This still has a
|
|
TOCTOU race window (a sibling driver could attach the label between
|
|
our GET and POST), which we accept and document in the same way
|
|
``conflict_drive.py`` § 3.3.1 documents the same race; the worker
|
|
layer's idempotent claim helper deduplicates work even in the rare
|
|
racing case.
|
|
"""
|
|
if cfg.dry_run:
|
|
return {"applied": False, "dry_run": True, "number": number}
|
|
existing, label_status = _existing_claim_labels(number, cfg)
|
|
if existing is None:
|
|
logger.warning(
|
|
"skip claim of #%s — labels GET returned status=%s (cannot "
|
|
"verify ownership; treating as foreign claim)",
|
|
number,
|
|
label_status,
|
|
)
|
|
return {
|
|
"applied": False,
|
|
"number": number,
|
|
"reason": "labels-fetch-failed",
|
|
"label_fetch_status": label_status,
|
|
}
|
|
if existing:
|
|
logger.info(
|
|
"skip claim of #%s — already-claimed labels=%s",
|
|
number,
|
|
existing,
|
|
)
|
|
return {
|
|
"applied": False,
|
|
"number": number,
|
|
"reason": "already-claimed",
|
|
"existing_labels": existing,
|
|
}
|
|
label = _claim_label(claim_kind)
|
|
if not _claim_runtime._add_label(number, label, cfg):
|
|
return {"applied": False, "number": number, "reason": "label-not-found"}
|
|
ttl_until = (
|
|
datetime.now(UTC) + timedelta(seconds=cfg.claim_ttl_seconds)
|
|
).isoformat()
|
|
body = (
|
|
f"{CLAIM_COMMENT_MARKER}\n\n"
|
|
f"Claimed by `{driver_name}` (pid {os.getpid()}) until `{ttl_until}`.\n\n"
|
|
"This claim is advisory and will be released when the worker exits, "
|
|
"or after the TTL by a sibling driver's expired-claim sweep."
|
|
)
|
|
_claim_runtime.post(
|
|
f"/repos/{cfg.owner}/{cfg.repo}/issues/{number}/comments",
|
|
cfg,
|
|
{"body": body},
|
|
)
|
|
return {"applied": True, "number": number, "ttl_until": ttl_until}
|
|
|
|
|
|
# ─── In-flight-claim registry for signal-handler cleanup (Phase 5a) ─────────
|
|
#
|
|
# When the dispatcher receives SIGTERM / SIGINT mid-cycle the worker session
|
|
# can still be 30+ minutes from completion, but the operator (systemd, a
|
|
# launcher script, or a human pressing Ctrl-C) expects the ``auto/claimed-*``
|
|
# label to clear quickly so the next dispatcher invocation — or a sibling
|
|
# bot — can pick the work up. We record the in-flight claim metadata in a
|
|
# module-level slot so the signal handler can release the claim
|
|
# synchronously without waiting for the worker session to finish.
|
|
#
|
|
# Threading note: the dispatcher is single-threaded (workers run via
|
|
# blocking subprocess / blocking HTTP poll), so a plain dict + lock is
|
|
# sufficient. ``threading.Lock`` is reentrant-safe enough for the
|
|
# Python-level signal-delivery model — handlers run between bytecodes
|
|
# in the main thread, so a brief lock-then-clear-then-release sequence
|
|
# under the lock cannot deadlock against itself. We deliberately keep
|
|
# the critical section tiny (snapshot + flag flip) so the signal
|
|
# handler doesn't hold the lock during the actual HTTP call.
|
|
_INFLIGHT_CLAIM_LOCK = threading.Lock()
|
|
_INFLIGHT_CLAIM: dict[str, Any] | None = None
|
|
# Stop event registered by ``run_outer_loop`` / ``run_one_cycle``-via-CLI;
|
|
# the signal handler sets it so the outer loop exits cleanly after the
|
|
# in-flight cycle returns.
|
|
_REGISTERED_STOP_EVENT: Any | None = None
|
|
# Records whether ``install_signal_handlers`` has already run in this
|
|
# process so a second call (e.g. ``run_outer_loop`` after a one-shot
|
|
# ``run_one_cycle`` in the same process for a test) is a no-op rather
|
|
# than overwriting the previous SIGTERM handler.
|
|
_SIGNAL_HANDLERS_INSTALLED = False
|
|
|
|
|
|
def _register_inflight_claim(
|
|
*,
|
|
number: int,
|
|
cfg: DispatchConfig,
|
|
claim_kind: str,
|
|
driver_name: str,
|
|
) -> None:
|
|
"""Stamp the in-flight claim metadata so the signal handler can
|
|
release it before the dispatcher exits. Called by ``dispatch_one``
|
|
after a successful claim, paired with
|
|
:func:`_deregister_inflight_claim` in the finally block.
|
|
"""
|
|
global _INFLIGHT_CLAIM
|
|
with _INFLIGHT_CLAIM_LOCK:
|
|
_INFLIGHT_CLAIM = {
|
|
"number": number,
|
|
"cfg": cfg,
|
|
"claim_kind": claim_kind,
|
|
"driver_name": driver_name,
|
|
}
|
|
|
|
|
|
def _deregister_inflight_claim() -> dict[str, Any] | None:
|
|
"""Pop and return the in-flight claim metadata. Called by
|
|
``dispatch_one``'s finally before the regular ``release_work_item``
|
|
runs so the signal handler does not double-release. Returns the
|
|
previous registration (or ``None`` if the signal handler already
|
|
cleared it).
|
|
"""
|
|
global _INFLIGHT_CLAIM
|
|
with _INFLIGHT_CLAIM_LOCK:
|
|
previous = _INFLIGHT_CLAIM
|
|
_INFLIGHT_CLAIM = None
|
|
return previous
|
|
|
|
|
|
def _signal_release_handler(signum: int, _frame: Any) -> None:
|
|
"""SIGTERM / SIGINT handler: release any in-flight claim and request
|
|
cooperative shutdown, then re-raise as SystemExit so the outer
|
|
loop's ``finally lock.release()`` runs.
|
|
|
|
Sequence:
|
|
|
|
1. Snapshot ``_INFLIGHT_CLAIM`` under the lock and atomically clear
|
|
it. This is the slot ``dispatch_one``'s finally block
|
|
*would* otherwise read; clearing here means the finally path
|
|
sees ``None`` and does NOT post a second release comment.
|
|
2. Set the registered stop event so the outer loop exits between
|
|
cycles (or, if we re-raise into the cycle, the cycle's
|
|
exception handler counts it against the failure budget).
|
|
3. Issue the release HTTP call. We log + swallow exceptions so a
|
|
transient network blip during shutdown can't loop forever.
|
|
4. Re-raise as ``SystemExit`` with code 130 (SIGINT convention)
|
|
or 143 (SIGTERM convention) so the process exit reflects the
|
|
signal that triggered it.
|
|
|
|
Re-raising rather than returning matters because the in-flight
|
|
worker session is blocked on an HTTP poll inside
|
|
``_opencode_worker.run_session_blocking``; that loop checks the
|
|
stop event at most once per poll interval. SystemExit unwinds
|
|
through every ``try: ... finally:`` in the call chain (including
|
|
the worker poll loop's), so the dispatcher exits within seconds
|
|
rather than waiting for the worker timeout.
|
|
"""
|
|
global _INFLIGHT_CLAIM
|
|
snapshot: dict[str, Any] | None
|
|
with _INFLIGHT_CLAIM_LOCK:
|
|
snapshot = _INFLIGHT_CLAIM
|
|
# Clear the slot under the lock so a concurrent
|
|
# ``dispatch_one`` finally that runs after we drop the lock
|
|
# but before we issue the HTTP call sees ``None`` and skips
|
|
# its own release. The HTTP call itself is intentionally
|
|
# OUTSIDE the lock — it can take seconds and we don't want
|
|
# to block the lock that long. A follow-up signal during
|
|
# the release would re-snapshot ``None`` and skip cleanly.
|
|
if snapshot is not None:
|
|
_INFLIGHT_CLAIM = None
|
|
if _REGISTERED_STOP_EVENT is not None:
|
|
try:
|
|
_REGISTERED_STOP_EVENT.set()
|
|
except Exception:
|
|
logger.exception(
|
|
"signal handler: setting stop event raised; "
|
|
"continuing with claim release"
|
|
)
|
|
# Resolve the human-readable signal name once so both the
|
|
# logger output AND the Forgejo release-comment ``terminal_state``
|
|
# field show ``signal-SIGTERM`` / ``signal-SIGINT`` rather than
|
|
# the bare numeric ``signal-15`` / ``signal-2``. The ``Signals``
|
|
# enum was added in Python 3.5; falling back to the numeric
|
|
# form on platforms with bespoke signal numbers (Windows,
|
|
# mostly) keeps the dispatcher portable.
|
|
try:
|
|
signal_name = signal.Signals(signum).name
|
|
except (ValueError, AttributeError):
|
|
signal_name = f"sig{int(signum)}"
|
|
if snapshot is not None:
|
|
try:
|
|
release_work_item(
|
|
snapshot["number"],
|
|
snapshot["cfg"],
|
|
claim_kind=snapshot["claim_kind"],
|
|
driver_name=snapshot["driver_name"],
|
|
terminal_state=f"signal-{signal_name}",
|
|
detail=(
|
|
f"released by dispatcher signal handler "
|
|
f"({signal_name}, signum={signum}); cooperative "
|
|
f"shutdown initiated"
|
|
),
|
|
)
|
|
logger.warning(
|
|
"signal handler released claim for #%s (%s, signum=%s)",
|
|
snapshot["number"],
|
|
signal_name,
|
|
signum,
|
|
)
|
|
except Exception as exc:
|
|
logger.exception(
|
|
"signal handler: release_work_item failed for #%s: %s",
|
|
snapshot["number"],
|
|
exc,
|
|
)
|
|
# POSIX convention: ``128 + signum``. SIGINT (=2) → 130,
|
|
# SIGTERM (=15) → 143; everything else picks up the same rule.
|
|
raise SystemExit(128 + int(signum))
|
|
|
|
|
|
def install_signal_handlers(stop: Any) -> None:
|
|
"""Install SIGTERM / SIGINT handlers for cooperative dispatcher shutdown.
|
|
|
|
Idempotent: a second call from the same process is a no-op (the
|
|
test suite runs many ``run_outer_loop`` invocations against
|
|
different fake clocks; we don't want each to clobber the
|
|
handler from the previous test).
|
|
|
|
The handler is intentionally installed in the main thread by the
|
|
main thread — Python's ``signal`` module raises ``ValueError`` if
|
|
called from a non-main thread, so a future architecture that
|
|
wraps ``run_outer_loop`` in a thread would need to install the
|
|
handler before spawning the thread.
|
|
"""
|
|
global _REGISTERED_STOP_EVENT, _SIGNAL_HANDLERS_INSTALLED
|
|
_REGISTERED_STOP_EVENT = stop
|
|
if _SIGNAL_HANDLERS_INSTALLED:
|
|
return
|
|
try:
|
|
signal.signal(signal.SIGTERM, _signal_release_handler)
|
|
signal.signal(signal.SIGINT, _signal_release_handler)
|
|
_SIGNAL_HANDLERS_INSTALLED = True
|
|
except ValueError as exc:
|
|
# Hit when called from a non-main thread. The dispatcher's
|
|
# production paths always install from the main thread, but
|
|
# the test suite occasionally invokes ``run_outer_loop`` from
|
|
# a worker thread (e.g. when a fixture wraps it for
|
|
# parallelism). We log + continue without handlers in that
|
|
# case rather than failing the whole loop — the test would
|
|
# have asserted the handler installation explicitly if it
|
|
# cared.
|
|
logger.warning(
|
|
"install_signal_handlers: signal.signal raised %s "
|
|
"(probably called from non-main thread); continuing "
|
|
"without dispatcher signal handlers",
|
|
exc,
|
|
)
|
|
|
|
|
|
def release_work_item(
|
|
number: int,
|
|
cfg: DispatchConfig,
|
|
*,
|
|
claim_kind: str,
|
|
driver_name: str,
|
|
terminal_state: str,
|
|
detail: str = "",
|
|
) -> dict[str, Any]:
|
|
if cfg.dry_run:
|
|
return {"released": False, "dry_run": True, "number": number}
|
|
label = _claim_label(claim_kind)
|
|
removed = _claim_runtime._remove_label(number, label, cfg)
|
|
body = (
|
|
f"{CLAIM_COMMENT_MARKER}\n\n"
|
|
f"Released by `{driver_name}` (pid {os.getpid()}). "
|
|
f"terminal_state=`{terminal_state}`"
|
|
+ (f"\n\nDetail: {detail}" if detail else "")
|
|
)
|
|
_claim_runtime.post(
|
|
f"/repos/{cfg.owner}/{cfg.repo}/issues/{number}/comments",
|
|
cfg,
|
|
{"body": body},
|
|
)
|
|
return {"released": removed, "number": number}
|
|
|
|
|
|
def sweep_own_claims(cfg: DispatchConfig, claim_kind: str) -> list[int]:
|
|
return _claim_runtime.sweep_expired_claims(
|
|
cfg,
|
|
session_pr_numbers=set(),
|
|
label=_claim_label(claim_kind),
|
|
)
|
|
|
|
|
|
def dispatch_one(
|
|
cfg: DispatchConfig,
|
|
group: WorkGroup,
|
|
item: dict[str, Any],
|
|
*,
|
|
driver_name: str,
|
|
) -> dict[str, Any]:
|
|
"""Claim, dispatch, and release a single work item.
|
|
|
|
Terminal states (recorded as ``terminal_state`` in telemetry):
|
|
|
|
- ``invalid-item`` — the work-group script returned an item with no
|
|
usable ``number``.
|
|
- ``already-claimed`` — another worker already owns an
|
|
``auto/claimed-*`` label on this item; we did NOT acquire a claim
|
|
and do NOT release one.
|
|
- ``labels-fetch-failed`` — the labels GET returned non-200 (auth /
|
|
permission / repo-gone). Treated as a foreign claim: refused
|
|
rather than silently attaching a label we have no permission to
|
|
manage. The HTTP status is captured in
|
|
``claim_result.label_fetch_status`` for operator triage.
|
|
- ``claim-failed`` — Forgejo accepted the labels GET but refused
|
|
our label-add (label undefined in the repo, etc.). No release
|
|
attempted.
|
|
- ``dry-run`` — ``cfg.dry_run`` is set; no worker was dispatched.
|
|
- ``completed`` — the OpenCode session reached idle without timing
|
|
out or hitting a transport error. The worker's domain-specific
|
|
result (review submitted, code generated, etc.) is judged by side
|
|
effects, not by a JSON outcome key. Reviewer / implementer
|
|
workers do NOT emit the conflict-driver JSON schema.
|
|
- ``timeout`` / ``transport-error`` — propagated verbatim from
|
|
:class:`_opencode_worker.SessionResult.status`.
|
|
|
|
The release in the finally block runs only when ``claimed`` is
|
|
true (i.e. :func:`claim_work_item` returned ``applied=True``). This
|
|
is what makes ``already-claimed`` and ``labels-fetch-failed`` safe:
|
|
we never remove a label we did not just attach.
|
|
"""
|
|
number = _item_number(item)
|
|
if number is None:
|
|
return {"terminal_state": "invalid-item", "item": item}
|
|
claimed = False
|
|
claim_result: dict[str, Any] | None = None
|
|
started = time.monotonic()
|
|
session = None
|
|
terminal_state = "unknown"
|
|
try:
|
|
if group.claim_kind is not None:
|
|
claim_result = claim_work_item(
|
|
number,
|
|
cfg,
|
|
claim_kind=group.claim_kind,
|
|
driver_name=driver_name,
|
|
)
|
|
claimed = bool(claim_result.get("applied"))
|
|
if not claimed and not cfg.dry_run:
|
|
reason = claim_result.get("reason")
|
|
if reason == "already-claimed":
|
|
terminal_state = "already-claimed"
|
|
elif reason == "labels-fetch-failed":
|
|
terminal_state = "labels-fetch-failed"
|
|
else:
|
|
terminal_state = "claim-failed"
|
|
return {
|
|
"terminal_state": terminal_state,
|
|
"claim_result": claim_result,
|
|
"item_number": number,
|
|
}
|
|
# Register the in-flight claim so a SIGTERM / SIGINT
|
|
# handler can release it before this dispatcher exits.
|
|
# Skipped on dry-run (no real claim was acquired) and on
|
|
# the ``new_issue`` work group whose ``claim_kind`` is
|
|
# ``None`` (the outer ``if group.claim_kind is not None``
|
|
# already gates this branch correctly). The matching
|
|
# deregister fires in the finally block before
|
|
# ``release_work_item`` so the signal handler's
|
|
# synchronous release and the finally's release cannot
|
|
# both post release comments.
|
|
if claimed and not cfg.dry_run:
|
|
_register_inflight_claim(
|
|
number=number,
|
|
cfg=cfg,
|
|
claim_kind=group.claim_kind,
|
|
driver_name=driver_name,
|
|
)
|
|
prompt = group.prompt_factory(cfg, item, group)
|
|
if cfg.dry_run:
|
|
terminal_state = "dry-run"
|
|
return {
|
|
"terminal_state": terminal_state,
|
|
"claim_result": claim_result,
|
|
"item_number": number,
|
|
"prompt": prompt,
|
|
}
|
|
tag = _tag_for(group, number)
|
|
# Refresh our liveness heartbeat once per polling iteration so a
|
|
# 30-minute review session doesn't look like a hung dispatcher
|
|
# process to the launcher / systemd unit. Without this, the
|
|
# watchdog above the dispatcher would SIGTERM us mid-cycle and
|
|
# orphan both the OpenCode session and the auto/claimed-* lock
|
|
# we hold on this PR. Errors from the callback are swallowed
|
|
# inside run_session_blocking so a transient disk-full / EROFS
|
|
# on the heartbeat write cannot mask a successful worker
|
|
# completion.
|
|
heartbeat_path = cfg.heartbeat_path
|
|
|
|
def _refresh_heartbeat() -> None:
|
|
_claim_runtime.write_heartbeat(heartbeat_path)
|
|
|
|
# Bracket ``run_session_blocking`` with ISO-8601 UTC
|
|
# timestamps so the post-session hook (Phase 4 telemetry,
|
|
# specifically) can record actual cycle wall-clock instead
|
|
# of post-hoc ``now_iso()`` placeholders. The worker's
|
|
# self-reported ``session.wallclock_seconds`` is also
|
|
# threaded through for cross-checking.
|
|
session_started_at = _now()
|
|
session = _opencode_worker.run_session_blocking(
|
|
server_url=cfg.server_url,
|
|
agent=group.worker_agent,
|
|
tag=tag,
|
|
prompt=prompt,
|
|
timeout_seconds=cfg.worker_timeout_seconds,
|
|
on_poll=_refresh_heartbeat,
|
|
)
|
|
session_completed_at = _now()
|
|
if session.status == "completed":
|
|
terminal_state = "completed"
|
|
else:
|
|
terminal_state = session.status # "timeout" | "transport-error"
|
|
worker_outcome: str | None = None
|
|
if isinstance(session.parsed_json, dict):
|
|
outcome_value = session.parsed_json.get("outcome")
|
|
if isinstance(outcome_value, str) and outcome_value:
|
|
worker_outcome = outcome_value
|
|
post_session_result: dict[str, Any] | None = None
|
|
if group.post_session_action is not None:
|
|
# Pack the work-group name and session-timing context
|
|
# into a single ``SessionContext`` dataclass so the post-
|
|
# session action sees a single read-only record instead
|
|
# of a fan of four loose kwargs (and so future fields
|
|
# land in one place). Earlier iteration stamped these
|
|
# onto ``item`` directly, then a flat ``**kwargs`` —
|
|
# both made the data flow hard to follow.
|
|
session_context = SessionContext(
|
|
work_group_name=group.name,
|
|
session_started_at=session_started_at,
|
|
session_completed_at=session_completed_at,
|
|
session_wallclock_seconds=session.wallclock_seconds,
|
|
)
|
|
try:
|
|
post_session_result = group.post_session_action(
|
|
cfg,
|
|
item,
|
|
session.parsed_json,
|
|
session.raw_response,
|
|
terminal_state,
|
|
session_context=session_context,
|
|
)
|
|
except Exception as exc:
|
|
logger.exception(
|
|
"%s post_session_action raised on item #%s: %s",
|
|
group.name,
|
|
number,
|
|
exc,
|
|
)
|
|
post_session_result = {
|
|
"review_action": "failed",
|
|
"review_action_reason": (
|
|
f"post_session_action_exception: "
|
|
f"{type(exc).__name__}: {exc}"
|
|
),
|
|
}
|
|
return {
|
|
"terminal_state": terminal_state,
|
|
"session_status": session.status,
|
|
"worker_outcome": worker_outcome,
|
|
"session_id": session.session_id,
|
|
"worker_wallclock_seconds": session.wallclock_seconds,
|
|
"raw_response": session.raw_response,
|
|
"parsed_json": session.parsed_json,
|
|
"claim_result": claim_result,
|
|
"item_number": number,
|
|
"post_session_result": post_session_result,
|
|
# Surfaces ``error_kind`` from _opencode_worker so the
|
|
# cycle log can pivot on transport-create-session vs
|
|
# transport-poll-status vs startup-grace-elapsed without
|
|
# parsing free-text raw_response. ``None`` on
|
|
# ``completed``.
|
|
"session_error_kind": session.error_kind,
|
|
}
|
|
finally:
|
|
# Deregister the in-flight claim BEFORE issuing the regular
|
|
# release so a signal that fires between ``_register_inflight_claim``
|
|
# and here triggers exactly one release path, not two. The
|
|
# signal handler clears the slot and posts the release; this
|
|
# path checks the previous registration and only posts when
|
|
# the slot is still our claim (i.e. no signal raced us). If
|
|
# the snapshot is None the signal handler already released —
|
|
# we skip the duplicate release comment.
|
|
previous_registration = _deregister_inflight_claim()
|
|
if (
|
|
claimed
|
|
and group.claim_kind is not None
|
|
and previous_registration is not None
|
|
):
|
|
release_work_item(
|
|
number,
|
|
cfg,
|
|
claim_kind=group.claim_kind,
|
|
driver_name=driver_name,
|
|
terminal_state=terminal_state,
|
|
detail=_sanitize_release_detail(
|
|
session.raw_response if session is not None else ""
|
|
),
|
|
)
|
|
elapsed = time.monotonic() - started
|
|
logger.info(
|
|
"%s item #%s finished terminal_state=%s elapsed=%.1fs",
|
|
group.name,
|
|
number,
|
|
terminal_state,
|
|
elapsed,
|
|
)
|
|
|
|
|
|
def _tag_for(group: WorkGroup, number: int) -> str:
|
|
suffix = "PR" if group.item_kind == "pr" else "ISSUE"
|
|
return f"{group.tag_prefix}-{suffix}-{number}"
|
|
|
|
|
|
def run_one_cycle(
|
|
cfg: DispatchConfig,
|
|
groups: list[WorkGroup],
|
|
*,
|
|
driver_name: str,
|
|
sweep_claim_kind: str | None,
|
|
) -> dict[str, Any]:
|
|
cycle_id = str(uuid.uuid4())
|
|
started_at = _now()
|
|
# Insert the in-flight row before any work so the telemetry console
|
|
# can show this cycle as "running" the moment it starts. Without
|
|
# this, the cycle would only appear after a multi-minute worker
|
|
# session finished, leaving the dashboard looking like the
|
|
# dispatcher had stopped working. The companion ``finish_cycle`` call
|
|
# in the finally block UPDATEs the same row with aggregate counts.
|
|
begin_cycle(cfg, cycle_id=cycle_id, started_at=started_at, driver_name=driver_name)
|
|
swept: list[int] = []
|
|
candidates: list[tuple[WorkGroup, dict[str, Any]]] = []
|
|
group_counts: dict[str, int] = {}
|
|
processed: list[dict[str, Any]] = []
|
|
claims_acquired = 0
|
|
try:
|
|
swept = sweep_own_claims(cfg, sweep_claim_kind) if sweep_claim_kind else []
|
|
candidates, group_counts = collect_candidates(cfg, groups)
|
|
for group, item in candidates[: cfg.max_items_per_cycle]:
|
|
outcome = dispatch_one(cfg, group, item, driver_name=driver_name)
|
|
if (outcome.get("claim_result") or {}).get("applied"):
|
|
claims_acquired += 1
|
|
processed.append({"group": group.name, **outcome})
|
|
return {
|
|
"cycle_id": cycle_id,
|
|
"started_at": started_at,
|
|
"ended_at": None, # set by the finally block via finish_cycle
|
|
"candidates_count": len(candidates),
|
|
"group_counts": group_counts,
|
|
"claims_acquired": claims_acquired,
|
|
"swept": swept,
|
|
"processed": processed,
|
|
}
|
|
finally:
|
|
ended_at = _now()
|
|
# Use a defensive try/except so a finish-time write failure
|
|
# (disk full, transient SQLite lock contention, etc.) does not
|
|
# mask whatever exception actually drove us into the finally
|
|
# block. The in-flight row stays in place if the UPDATE fails
|
|
# — operators can spot the orphan via ``ended_at IS NULL`` and
|
|
# the next ensure_cycle_table run will not delete it.
|
|
try:
|
|
finish_cycle(
|
|
cfg,
|
|
cycle_id=cycle_id,
|
|
ended_at=ended_at,
|
|
candidates_count=len(candidates),
|
|
group_counts=group_counts,
|
|
claims_acquired=claims_acquired,
|
|
swept=swept,
|
|
processed=processed,
|
|
# Pass started_at + driver_name so the INSERT-fallback
|
|
# path (which fires only if begin_cycle's row got lost
|
|
# — e.g. SQLite WAL corruption + recovery between
|
|
# begin_cycle and finish_cycle) still records an
|
|
# accurate started_at and driver instead of synthesising
|
|
# zero-duration ``unknown`` rows. Under the normal
|
|
# UPDATE path these kwargs are unused.
|
|
started_at=started_at,
|
|
driver_name=driver_name,
|
|
)
|
|
except sqlite3.Error:
|
|
logger.exception(
|
|
"%s finish_cycle UPDATE failed for cycle_id=%s — orphan "
|
|
"in-flight row will be visible in dispatch_*_cycles",
|
|
driver_name,
|
|
cycle_id,
|
|
)
|
|
|
|
|
|
def run_outer_loop(
|
|
cfg: DispatchConfig,
|
|
groups: list[WorkGroup],
|
|
*,
|
|
driver_name: str,
|
|
sweep_claim_kind: str | None,
|
|
stop: Any | None = None,
|
|
) -> None:
|
|
"""Hold the single-instance lock and run cycles until ``stop`` fires.
|
|
|
|
Each cycle's exceptions are logged and counted against
|
|
``cfg.cycle_failure_budget``. After that many consecutive failures
|
|
we exit with code 2 — matching ``merge_drive.py`` / ``conflict_drive.py``
|
|
so an upstream supervisor (systemd, a launcher script, etc.)
|
|
restarts us with fresh state instead of letting a partial-output
|
|
work-group script wedge the loop indefinitely. A successful cycle
|
|
resets the counter.
|
|
"""
|
|
lock = _claim_runtime.SingleInstanceLock(cfg.lock_path)
|
|
if not lock.acquire():
|
|
raise SystemExit(f"another {driver_name} instance holds {cfg.lock_path}")
|
|
stop = stop or _claim_runtime.StopEvent()
|
|
# Install SIGTERM / SIGINT handlers so an operator stop (systemd,
|
|
# Ctrl-C, supervisor restart) releases any in-flight
|
|
# ``auto/claimed-*`` label before the dispatcher exits. Without
|
|
# this, the merge driver's ``sweep_expired_claims`` would only
|
|
# release the orphan label after ``claim_ttl_seconds`` (default
|
|
# 7200 s = 2 h) — long enough for a sibling dispatcher to wait
|
|
# noticeably for the next cycle. Test seam: a test that calls
|
|
# ``run_outer_loop`` with its own ``stop`` event still gets the
|
|
# handler installation, but tests that exercise the handler
|
|
# directly should call :func:`install_signal_handlers` themselves
|
|
# against a controlled stop event.
|
|
install_signal_handlers(stop)
|
|
consecutive_failures = 0
|
|
try:
|
|
while not stop.is_set():
|
|
try:
|
|
run_one_cycle(
|
|
cfg,
|
|
groups,
|
|
driver_name=driver_name,
|
|
sweep_claim_kind=sweep_claim_kind,
|
|
)
|
|
consecutive_failures = 0
|
|
except Exception:
|
|
consecutive_failures += 1
|
|
logger.exception(
|
|
"%s cycle failed (consecutive=%s/%s)",
|
|
driver_name,
|
|
consecutive_failures,
|
|
cfg.cycle_failure_budget,
|
|
)
|
|
if consecutive_failures >= cfg.cycle_failure_budget:
|
|
logger.error(
|
|
"%s exceeded cycle failure budget (%s); exiting "
|
|
"for supervisor restart",
|
|
driver_name,
|
|
cfg.cycle_failure_budget,
|
|
)
|
|
raise SystemExit(2)
|
|
_claim_runtime.write_heartbeat(cfg.heartbeat_path)
|
|
stop.sleep(cfg.cycle_interval_seconds)
|
|
finally:
|
|
lock.release()
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(UTC).isoformat()
|
|
|
|
|
|
def ensure_cycle_table(table_name: str) -> None:
|
|
"""Create or repair the per-driver cycle table.
|
|
|
|
Delegates to :func:`_forgejo_cache.ensure_dispatch_cycle_schema`
|
|
so the v5 schema (``ended_at`` nullable, UNIQUE INDEX on
|
|
``cycle_id``) is defined in exactly one place. Both this function
|
|
and ``ForgejoCache._migrate_to_v5_in_flight_rows`` import their
|
|
DDL from there, eliminating drift risk if a future migration adds
|
|
a column.
|
|
|
|
The dispatcher writes cycle rows via raw ``sqlite3.connect`` rather
|
|
than going through ``ForgejoCache``, so this function must run the
|
|
creation + migration itself; ``ForgejoCache`` may not have been
|
|
opened in this process before the first dispatcher cycle.
|
|
"""
|
|
if table_name not in _forgejo_cache.DISPATCH_CYCLE_TABLES:
|
|
raise ValueError(f"unexpected dispatch table: {table_name}")
|
|
path = _forgejo_cache.DEFAULT_CACHE_PATH
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with sqlite3.connect(path) as conn:
|
|
conn.row_factory = sqlite3.Row
|
|
_forgejo_cache.ensure_dispatch_cycle_schema(conn, table_name)
|
|
|
|
|
|
def begin_cycle(
|
|
cfg: DispatchConfig,
|
|
*,
|
|
cycle_id: str,
|
|
started_at: str,
|
|
driver_name: str,
|
|
) -> None:
|
|
"""Insert an in-flight row for ``cycle_id`` (``ended_at IS NULL``).
|
|
|
|
Pairs with :func:`finish_cycle`, which UPDATEs the same row with the
|
|
final ``ended_at`` and aggregate fields when the cycle resolves.
|
|
Inserting at start fixes the telemetry-blind-spot where a multi-
|
|
minute review session would not appear anywhere in the cycle table
|
|
until it finished — making the dispatcher look idle even though it
|
|
was actively polling an OpenCode worker.
|
|
|
|
The cycle row carries placeholder counts (``0``) and an empty raw
|
|
blob until :func:`finish_cycle` overwrites them. The UNIQUE index
|
|
on ``cycle_id`` prevents accidental double-inserts; a re-entrant
|
|
crash-loop that attempted ``begin_cycle`` twice would surface a
|
|
``sqlite3.IntegrityError`` rather than silently fork the row.
|
|
"""
|
|
ensure_cycle_table(cfg.table_name)
|
|
raw_placeholder = json.dumps(
|
|
{"group_counts": {}, "swept": [], "processed": [], "in_flight": True},
|
|
sort_keys=True,
|
|
)
|
|
with sqlite3.connect(_forgejo_cache.DEFAULT_CACHE_PATH) as conn:
|
|
conn.execute(
|
|
f"""
|
|
INSERT INTO {cfg.table_name} (
|
|
cycle_id, started_at, ended_at, driver, candidates_count,
|
|
claims_acquired, swept_count, processed_count, terminal_state,
|
|
worker_outcome, session_id, worker_wallclock_seconds, raw
|
|
) VALUES (?, ?, NULL, ?, 0, 0, 0, 0, NULL, NULL, NULL, NULL, ?)
|
|
""",
|
|
(
|
|
cycle_id,
|
|
started_at,
|
|
driver_name,
|
|
raw_placeholder,
|
|
),
|
|
)
|
|
|
|
|
|
def finish_cycle(
|
|
cfg: DispatchConfig,
|
|
*,
|
|
cycle_id: str,
|
|
ended_at: str,
|
|
candidates_count: int,
|
|
group_counts: dict[str, int],
|
|
claims_acquired: int,
|
|
swept: list[int],
|
|
processed: list[dict[str, Any]],
|
|
started_at: str | None = None,
|
|
driver_name: str | None = None,
|
|
) -> None:
|
|
"""Overwrite the in-flight row for ``cycle_id`` with the resolved
|
|
aggregate fields.
|
|
|
|
Falls back to ``INSERT`` if no row exists for ``cycle_id`` — this
|
|
handles drivers that never called :func:`begin_cycle` (e.g. test
|
|
fixtures that build a complete row in one shot) and keeps the
|
|
function compatible with the legacy ``record_cycle`` semantics.
|
|
|
|
The optional ``started_at`` and ``driver_name`` kwargs feed the
|
|
INSERT-fallback path. When provided, the synthesised row carries
|
|
accurate timestamps + driver attribution. When omitted, the row
|
|
uses ``ended_at`` as both ``started_at`` (collapsing wallclock
|
|
duration to zero) and ``"unknown"`` for the driver, and the raw
|
|
JSON blob carries a ``"synthetic_started_at": true`` marker so
|
|
cycle-time analytics can exclude the row from duration stats.
|
|
"""
|
|
ensure_cycle_table(cfg.table_name)
|
|
terminal_state = None
|
|
worker_outcome = None
|
|
session_id = None
|
|
worker_wallclock = None
|
|
if processed:
|
|
last = processed[-1]
|
|
terminal_state = str(last.get("terminal_state") or "") or None
|
|
worker_outcome = (
|
|
str(last.get("worker_outcome")) if last.get("worker_outcome") else None
|
|
)
|
|
session_id = str(last.get("session_id")) if last.get("session_id") else None
|
|
worker_wallclock = last.get("worker_wallclock_seconds")
|
|
raw = {
|
|
"group_counts": group_counts,
|
|
"swept": swept,
|
|
"processed": processed,
|
|
}
|
|
with sqlite3.connect(_forgejo_cache.DEFAULT_CACHE_PATH) as conn:
|
|
cursor = conn.execute(
|
|
f"""
|
|
UPDATE {cfg.table_name}
|
|
SET ended_at = ?,
|
|
candidates_count = ?,
|
|
claims_acquired = ?,
|
|
swept_count = ?,
|
|
processed_count = ?,
|
|
terminal_state = ?,
|
|
worker_outcome = ?,
|
|
session_id = ?,
|
|
worker_wallclock_seconds = ?,
|
|
raw = ?
|
|
WHERE cycle_id = ?
|
|
""",
|
|
(
|
|
ended_at,
|
|
candidates_count,
|
|
claims_acquired,
|
|
len(swept),
|
|
len(processed),
|
|
terminal_state,
|
|
worker_outcome,
|
|
session_id,
|
|
worker_wallclock,
|
|
json.dumps(raw, sort_keys=True),
|
|
cycle_id,
|
|
),
|
|
)
|
|
if cursor.rowcount == 0:
|
|
# No matching in-flight row — fall back to a full INSERT
|
|
# so callers that skip begin_cycle (legacy tests,
|
|
# crash-recovery paths that lost their begin_cycle row)
|
|
# still get a complete record. Use ``started_at`` /
|
|
# ``driver_name`` if the caller passed them (modern
|
|
# ``run_one_cycle`` does); otherwise stamp the row with the
|
|
# ``ended_at`` value for ``started_at`` (collapsing the
|
|
# cycle to zero duration) and ``"unknown"`` for the
|
|
# driver, plus a ``synthetic_started_at`` flag in raw so
|
|
# cycle-time analytics can spot and exclude these rows.
|
|
insert_started_at = started_at if started_at is not None else ended_at
|
|
insert_driver = driver_name if driver_name is not None else "unknown"
|
|
insert_raw = dict(raw)
|
|
if started_at is None:
|
|
insert_raw["synthetic_started_at"] = True
|
|
conn.execute(
|
|
f"""
|
|
INSERT INTO {cfg.table_name} (
|
|
cycle_id, started_at, ended_at, driver, candidates_count,
|
|
claims_acquired, swept_count, processed_count, terminal_state,
|
|
worker_outcome, session_id, worker_wallclock_seconds, raw
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
cycle_id,
|
|
insert_started_at,
|
|
ended_at,
|
|
insert_driver,
|
|
candidates_count,
|
|
claims_acquired,
|
|
len(swept),
|
|
len(processed),
|
|
terminal_state,
|
|
worker_outcome,
|
|
session_id,
|
|
worker_wallclock,
|
|
json.dumps(insert_raw, sort_keys=True),
|
|
),
|
|
)
|
|
|
|
|
|
|
|
|
|
def status_payload(cfg: DispatchConfig, *, driver_name: str) -> dict[str, Any]:
|
|
ensure_cycle_table(cfg.table_name)
|
|
return {
|
|
"driver": driver_name,
|
|
"owner": cfg.owner,
|
|
"repo": cfg.repo,
|
|
"forgejo_url": cfg.forgejo_url,
|
|
"server_url": cfg.server_url,
|
|
"lock_path": str(cfg.lock_path),
|
|
"heartbeat_path": str(cfg.heartbeat_path),
|
|
"cycle_interval_seconds": cfg.cycle_interval_seconds,
|
|
"max_items_per_cycle": cfg.max_items_per_cycle,
|
|
"worker_timeout_seconds": cfg.worker_timeout_seconds,
|
|
"claim_ttl_seconds": cfg.claim_ttl_seconds,
|
|
"cycle_failure_budget": cfg.cycle_failure_budget,
|
|
"table_name": cfg.table_name,
|
|
"dry_run": cfg.dry_run,
|
|
}
|
|
|
|
|
|
def json_line(data: Any) -> None:
|
|
print(json.dumps(data, indent=2, sort_keys=True))
|