Files
cleveragents-core/tools/_opencode_worker.py
T
drew 703a7c9090 feat(auto-agents): Phase A — conflict_drive.py deterministic conflict-resolution driver
Implement Tier 1.5 conflict resolution as a peer driver to merge_drive.py
that honours the same hard invariant: every commit on master came from a SHA
whose CI passed against the exact current master.

New components
- tools/conflict_drive.py: deterministic driver that picks PRs labelled
  auto/needs-conflict-resolution, claims them via the shared
  auto/claimed-merge label, attempts a deterministic rebase with
  deepen-on-demand fallback, dispatches conflict-resolver-worker on
  conflict, force-pushes with --force-with-lease, and clears the label
  for merge_drive to re-pick. Includes 24h retry budget, escalation to
  auto/needs-implementer, single-instance lock + heartbeat, opt-in
  TOCTOU mitigations (CONFLICT_DRIVER_CYCLE_JITTER_SECONDS,
  CONFLICT_DRIVER_VERIFY_CLAIM), startup TTL constraint and required-
  labels assertions, and full SQLite telemetry.
- tools/_claim_runtime.py: shared HTTP/lock/heartbeat/claim primitives
  extracted from merge_drive.py (driver-aware claim/release markers,
  injectable op_label_map). merge_drive re-exports for back-compat.
- tools/_opencode_worker.py: blocking Python client for the OpenCode
  HTTP API with O(N) string-aware bracket matcher for tolerant JSON
  extraction from worker output.
- .opencode/agents/conflict-resolver-worker.md: subagent definition
  with tight permissions and "always finish, never abort, never --skip"
  doctrine.
- tools/inject_synthetic_conflict.py: CLI that creates PRs on a test
  fork with guaranteed conflicts (trivial / multi-commit / unresolvable)
  for end-to-end testing.

Telemetry & dashboard
- tools/_forgejo_cache.py: new conflict_drive_cycles table, cycle-level
  escalated_count, indexed retry-budget query, mark_*_escalated helper.
- tools/render-pr-velocity.py + pr-velocity.canvas.template.tsx:
  conflict-resolution activity section showing 7-day cycle counts,
  resolved/escalated/timeout/push-rejected breakdowns.

Open-issue dependency check
- tools/merge_drive.py: pr_is_eligible now consults Forgejo's blocks
  endpoint and applies auto/blocked-by-deps when any open dependency
  exists. Read-only predicate _pr_has_open_dependencies; label
  mutations live with the eligibility caller.
- tools/setup_auto_labels.py: provisions auto/blocked-by-deps.

Documentation
- docs/development/conflict-drive-plan.md: full plan including TOCTOU
  race documentation (§3.3.1) with implementation/test pointers.
- AGENTS.md: operator-facing section on conflict_drive.py and the
  expanded label registry.

Tests
- 300 unit tests pass / 1 skipped (opt-in fork integration test).
- Coverage includes JSON extractor fuzz, push-stderr classification,
  PAT scrub, deepen-on-demand fallback, retry budget escalation,
  cycle-level escalated_count stamping, claim collision detection
  (latest-claim-only with marker-primary identity), jitter wiring,
  and verify_claim_after_apply plumbing.

Quality invariant unchanged: conflict_drive.py only operates on PR head
branches, never on master. CI gating on the train-merge SHA continues
to enforce the exact-current-master rule for everything that lands.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 01:28:41 -04:00

463 lines
18 KiB
Python

"""Blocking OpenCode worker dispatcher.
Implements ``run_worker_blocking`` per
``docs/development/conflict-drive-plan.md`` § 6.2. The single entry
point spins up an OpenCode session, dispatches an agent prompt, polls
until the session is idle, performs tolerant JSON extraction on the
final assistant message, and tears the session down — all from a
deterministic Python driver with a watchdog timeout.
The expected caller is :mod:`tools.conflict_drive`, but the surface is
agent-agnostic and can be reused by other deterministic drivers that
need a single-shot LLM worker.
Outcomes
--------
The worker returns a :class:`WorkerResult` whose ``outcome`` is one of:
- ``resolved`` — every conflict was semantically resolved.
- ``unresolvable`` — the rebase finished with conflict markers
committed; the driver will detect them via ``git diff --check`` and
escalate via ``auto/needs-implementer``.
- ``rebase-failed`` — the worker could not progress the rebase at all.
- ``timeout`` — the watchdog wallclock fired (default 900 s).
- ``transport-error`` — OpenCode HTTP 5xx, network reset, or any
unexpected I/O error during the dispatch lifecycle.
The driver maps these onto the failure-class table in plan § 5.1;
``verification-fail`` and ``lease-violation`` are driver-side
classifications, not worker outcomes.
"""
from __future__ import annotations
import json
import logging
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from typing import Any, Literal
logger = logging.getLogger("opencode_worker")
Outcome = Literal[
"resolved",
"unresolvable",
"rebase-failed",
"timeout",
"transport-error",
]
@dataclass
class WorkerResult:
"""Typed return value from :func:`run_worker_blocking`.
``raw_response`` carries the verbatim final-assistant message text
purely for telemetry / debug; the driver never parses it directly
(the typed ``outcome`` and ``files_touched`` fields above are the
contract).
"""
outcome: Outcome
files_touched: list[str] = field(default_factory=list)
wallclock_seconds: float = 0.0
session_id: str = ""
raw_response: str = ""
# ─── HTTP layer (kept intentionally minimal — no shared retries) ──────────
#
# We deliberately do NOT reuse ``_claim_runtime``'s state-change retry
# policy: OpenCode's writes are not idempotent (POST /session creates a
# new session every call) and a transient retry would multiply session
# leaks. Each request gets one shot; any error short-circuits to
# ``transport-error``.
# Tuple of exceptions every dispatch step catches. ``urllib`` raises
# ``HTTPError`` (subclass of ``URLError``) on non-2xx, so this covers
# both transport-level failures (DNS, connection refused) and OpenCode
# 5xx / 4xx.
_TRANSPORT_EXC: tuple[type[BaseException], ...] = (
urllib.error.URLError,
TimeoutError,
OSError,
)
def _request(
method: str,
url: str,
*,
body: Any | None = None,
timeout: int = 30,
) -> Any:
"""Issue a single HTTP request to the OpenCode server.
Returns the parsed JSON body on 2xx; raises ``urllib.error.HTTPError``
(a subclass of ``URLError``) on non-2xx, and lets all other
transport-level exceptions propagate. Callers catch
:data:`_TRANSPORT_EXC` and convert to ``transport-error``.
"""
data = json.dumps(body).encode() if body is not None else None
headers = {}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = resp.read()
if not payload:
return None
ct = resp.headers.get("Content-Type", "")
if "application/json" in ct:
try:
return json.loads(payload)
except (ValueError, TypeError):
return None
return payload
# ─── Tolerant JSON extraction ──────────────────────────────────────────────
#
# The worker's prompt asks for "exactly one JSON object somewhere in
# your final assistant message". Real LLMs surround it with prose, code
# fences, trailing whitespace, and frequently emit stray ``{`` inside
# explanatory prose ("set REPO_DIR={your repo path}"). We scan
# left-to-right looking at every ``{`` independently — taking only the
# LAST successfully-parsed object — so a single unmatched opener
# earlier in the message cannot shadow the structured exit at the end.
def _extract_last_json_object(text: str) -> dict[str, Any] | None:
"""Return the last ``{...}`` substring of ``text`` that parses as a
JSON object, or ``None`` if no such substring exists.
Strategy: walk left-to-right; at every ``{`` start, run a
string-aware bracket matcher to see whether THIS opener closes at
depth 0 within the buffer. If it does, attempt ``json.loads`` on
the span and record success; in any case we then advance to the
next character (NOT to ``end + 1``) so an unmatched outer ``{``
cannot hide a valid object that nests inside it or that follows
it later. The LAST successful parse wins so the worker's
structured exit (always at the end of the message by prompt
convention) is what we return.
Cost: best-case O(N) when no stray openers appear; worst case
O(N²) when the buffer contains many unterminated ``{`` runs (the
pathological run-of-N-opens-no-closes string). LLM output is
bounded by ``max_tokens`` so the worst case is irrelevant in
practice; the pre-P1-6 implementation was the same complexity for
a much weaker reason (quadratic shrink). Replacing the unmatched-
opener ``break`` with ``i += 1`` is what closes the regression
flagged in the architect's review.
Tolerates code fences (``\\`\\`\\`json`` …), trailing prose, partial
fragments, multiple JSON objects, and stray ``{`` inside prose.
Does NOT tolerate JSON arrays at the outer level — by contract the
worker emits an object.
"""
if not text:
return None
last_match: dict[str, Any] | None = None
n = len(text)
i = 0
while i < n:
if text[i] != "{":
i += 1
continue
# Scan forward from here, respecting JSON string syntax, until
# we either close at depth 0 (complete object) or run off the
# end (truncated). Inside a string, ``{`` and ``}`` are literal
# data and must not move the depth counter.
depth = 0
in_string = False
escape = False
end = -1
for j in range(i, n):
ch = text[j]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = j + 1
break
if end < 0:
# Unmatched opener — every attempt that includes this ``{``
# in its span will fail. Advance ONE position so subsequent
# ``{``s (which may close fine) still get a chance. The
# earlier ``break`` here was a regression: it caused
# ``"prose with { stray. Then {valid: true}"`` to return
# None instead of finding the trailing object.
i += 1
continue
slice_ = text[i:end]
try:
parsed = json.loads(slice_)
except (ValueError, TypeError):
parsed = None
if isinstance(parsed, dict):
last_match = parsed
# The outer span parsed cleanly — skip past it. Without this
# the scan would re-enter the body and overwrite ``last_match``
# with a nested sub-object, returning ``{"inner": 1}`` for
# input ``{"outer": {"inner": 1}}``. The contract is "last
# complete object", not "last opener".
i = end
else:
# Span closed at depth 0 but did NOT parse (e.g. invalid
# syntax inside a stray-bracketed prose block). Advance one
# position so a valid object that nests inside this
# unparseable span is still discoverable.
i += 1
return last_match
def _last_assistant_text(messages: list[dict[str, Any]]) -> str:
"""Return the concatenated text of the most recent assistant message
in ``messages`` (oldest-first ordering, as returned by
``GET /session/{id}/message``). Empty string when no assistant
message exists yet.
"""
for m in reversed(messages):
info = m.get("info") or {}
if info.get("role") != "assistant":
continue
parts = m.get("parts") or []
# Concatenate visible text parts only; ignore reasoning / tool /
# patch / step-* entries — none of them are the structured exit
# the worker is asked to emit.
text_parts = [p.get("text", "") for p in parts if p.get("type") == "text"]
return "\n".join(t for t in text_parts if t)
return ""
# ─── Idle detection ────────────────────────────────────────────────────────
def _is_idle(server_url: str, session_id: str) -> bool:
"""Return ``True`` iff the session is not currently busy.
Mirrors the semantics of
``.opencode/skills/auto-agents-system/scripts/session_wait_till_idle.ts``:
busy/idle is reported by ``GET /session/status`` as a map of
``session_id -> {"type": "busy"|"idle"}``. A missing entry counts as
idle (the session was deleted under us; the caller will short-circuit
on the next message-fetch failure).
"""
statuses = _request("GET", f"{server_url}/session/status") or {}
if not isinstance(statuses, dict):
return True
entry = statuses.get(session_id)
if not isinstance(entry, dict):
return True
return entry.get("type") != "busy"
# ─── Public entry point ────────────────────────────────────────────────────
def run_worker_blocking(
*,
server_url: str,
agent: str,
tag: str,
prompt: str,
timeout_seconds: int = 900,
poll_interval_seconds: float = 2.0,
) -> WorkerResult:
"""Spin up a one-shot OpenCode agent session, wait for it to finish,
and return a typed :class:`WorkerResult`.
Lifecycle (matches plan § 6.2):
1. ``POST /session`` with title ``"[{tag}] {agent}"`` → ``session_id``.
The ``[TAG]`` prefix is the same convention used by the
TypeScript ``session_start.ts`` helper so existing operator
tooling (``session_find_by_tag``) finds these sessions.
2. ``POST /session/{id}/prompt_async`` with the agent name + prompt.
3. Poll ``GET /session/status`` every ``poll_interval_seconds`` until
the session is idle. The watchdog wallclock at ``timeout_seconds``
short-circuits with ``outcome=timeout``; the session is asked to
abort and DELETEd in the finally block.
4. ``GET /session/{id}/message`` to read the final assistant turn.
5. Tolerant JSON extraction (last parseable ``{...}`` substring of the
last assistant text) → ``outcome`` / ``files_touched``.
6. ``DELETE /session/{id}`` in a finally block. On DELETE failure log
a warning and continue — the session leaks until OpenCode server
restart. Acceptable for this driver's expected dispatch rate
(single-digit per day per plan § 6.2).
Any HTTP / network error short-circuits to ``transport-error``; the
finally block still attempts session teardown.
"""
started_at = time.monotonic()
session_id = ""
raw_text = ""
def _elapsed() -> float:
return time.monotonic() - started_at
try:
# 1. Create session.
title = f"[{tag}] {agent}"
try:
session = _request(
"POST", f"{server_url}/session", body={"title": title}
)
except _TRANSPORT_EXC as e:
logger.warning("OpenCode session create failed: %s", e)
return WorkerResult(
outcome="transport-error",
wallclock_seconds=_elapsed(),
raw_response=str(e),
)
if not isinstance(session, dict) or not session.get("id"):
logger.warning(
"OpenCode session create returned malformed payload: %r", session
)
return WorkerResult(
outcome="transport-error",
wallclock_seconds=_elapsed(),
raw_response=json.dumps(session) if session is not None else "",
)
session_id = str(session["id"])
logger.info(
"OpenCode session %s created (tag=%s, agent=%s)",
session_id,
tag,
agent,
)
# 2. Dispatch the prompt.
try:
_request(
"POST",
f"{server_url}/session/{session_id}/prompt_async",
body={"agent": agent, "parts": [{"type": "text", "text": prompt}]},
)
except _TRANSPORT_EXC as e:
logger.warning(
"OpenCode prompt_async failed for session %s: %s", session_id, e
)
return WorkerResult(
outcome="transport-error",
wallclock_seconds=_elapsed(),
session_id=session_id,
raw_response=str(e),
)
# 3. Poll until idle or timeout.
deadline = started_at + timeout_seconds
while True:
if time.monotonic() >= deadline:
logger.warning(
"OpenCode worker timed out after %.0f s (session=%s)",
timeout_seconds,
session_id,
)
# Best-effort abort. Ignore failures; teardown follows in
# finally either way.
try:
_request(
"POST",
f"{server_url}/session/{session_id}/abort",
)
except _TRANSPORT_EXC as e:
logger.warning(
"OpenCode abort failed for session %s: %s",
session_id,
e,
)
return WorkerResult(
outcome="timeout",
wallclock_seconds=_elapsed(),
session_id=session_id,
)
try:
if _is_idle(server_url, session_id):
break
except _TRANSPORT_EXC as e:
logger.warning(
"OpenCode status poll failed for session %s: %s",
session_id,
e,
)
return WorkerResult(
outcome="transport-error",
wallclock_seconds=_elapsed(),
session_id=session_id,
raw_response=str(e),
)
time.sleep(poll_interval_seconds)
# 4. Fetch messages and extract JSON.
try:
messages = _request(
"GET", f"{server_url}/session/{session_id}/message"
)
except _TRANSPORT_EXC as e:
logger.warning(
"OpenCode message fetch failed for session %s: %s",
session_id,
e,
)
return WorkerResult(
outcome="transport-error",
wallclock_seconds=_elapsed(),
session_id=session_id,
raw_response=str(e),
)
if not isinstance(messages, list):
messages = []
raw_text = _last_assistant_text(messages)
parsed = _extract_last_json_object(raw_text) or {}
outcome_str = parsed.get("outcome")
if outcome_str not in ("resolved", "unresolvable", "rebase-failed"):
logger.warning(
"OpenCode worker returned unexpected outcome %r — classifying "
"as rebase-failed (session=%s)",
outcome_str,
session_id,
)
outcome_str = "rebase-failed"
files_touched = parsed.get("files_touched") or []
if not isinstance(files_touched, list):
files_touched = []
return WorkerResult(
outcome=outcome_str, # type: ignore[arg-type]
files_touched=[str(f) for f in files_touched],
wallclock_seconds=_elapsed(),
session_id=session_id,
raw_response=raw_text,
)
finally:
# 6. Best-effort teardown. A failed DELETE is logged + ignored;
# the session leaks until OpenCode server restart. Acceptable for
# the driver's expected single-digit dispatch rate.
if session_id:
try:
_request("DELETE", f"{server_url}/session/{session_id}")
except _TRANSPORT_EXC as e:
logger.warning(
"OpenCode session DELETE failed for %s: %s — leaking "
"session, will be reclaimed at server restart",
session_id,
e,
)