703a7c9090
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>
463 lines
18 KiB
Python
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,
|
|
)
|