Files
VianoBugAnalysis/src/viano_bug_analysis/analysis/change_request_builder.py
T

305 lines
20 KiB
Python

from viano_bug_analysis.telemetry.decorators import observed
import hashlib
import tomllib
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from viano_bug_analysis.case.contracts import read_json, require_file, validate
from viano_bug_analysis.case.errors import CaseBuildError
from viano_bug_analysis.evidence.store import digest
from viano_bug_analysis.lifecycle import State, LifecycleError
from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain
from viano_bug_analysis.lifecycle.errors import LockBusy
from viano_bug_analysis.lifecycle.locking import FileLock
from viano_bug_analysis.lifecycle.storage import json_bytes, key
from viano_bug_analysis.llm import LLMError
from viano_bug_analysis.package import PackageReader, PackageReadError
from viano_bug_analysis.package.manifest import parse_manifest
from viano_bug_analysis.package.validator import ARCHIVE_ERRORS
from .diagnosis_builder import DiagnosisBuilder, DiagnosisConfig, DiagnosisLimits, DiagnosisError
from .investigator import PROVIDER_CONFIGURATION_ERRORS
from .change_request_contracts import ChangeRequestError, bugfix_policy, build_prompt, validate_request, make_document, render_change_request
@dataclass(frozen=True)
class ChangeRequestConfig(DiagnosisConfig):
def __post_init__(self):
if type(self.max_invalid_responses) is not int or self.max_invalid_responses <= 0:
raise ChangeRequestError("CHANGE_REQUEST_CONFIG")
@classmethod
def load(cls, path):
try:
with open(path, "rb") as stream:
config = tomllib.load(stream)
if set(config) != {"change_request"} or set(config["change_request"]) - {"max_invalid_responses"}:
raise ChangeRequestError("CHANGE_REQUEST_CONFIG")
return cls(**config["change_request"])
except (OSError, TypeError, ValueError, DiagnosisError):
raise ChangeRequestError("CHANGE_REQUEST_CONFIG") from None
@dataclass(frozen=True)
class ChangeRequestLimits(DiagnosisLimits):
def __post_init__(self):
if any(type(value) is not int or value <= 0 for value in (self.max_context_bytes, self.max_json_bytes)):
raise ChangeRequestError("CHANGE_REQUEST_LIMIT_CONFIG")
@dataclass(frozen=True)
class ChangeRequestResult:
status: str
package: Path
change_request_ref: str | None = None
failure_ref: str | None = None
reused: bool = False
code: str | None = None
retry_not_before: str | None = None
class ChangeRequestBuilder:
def __init__(self, store, gateway, config=None, limits=None, *, router=None, clock=None):
self.store, self.gateway = store, gateway
self.config = config or ChangeRequestConfig()
self.config_explicit = config is not None
self.limits = limits or ChangeRequestLimits()
self.diagnosis = DiagnosisBuilder(store, gateway, limits=self.limits, router=router, clock=clock)
self.runner = self.diagnosis.runner
@observed("change_request_build")
async def build(self, case_id, package_id, *, retry_id=None):
if retry_id is not None and (not isinstance(retry_id, str) or not retry_id.strip()):
raise ChangeRequestError("CHANGE_REQUEST_RETRY_ID")
try:
with FileLock(self.store.workspace.runtime / "locks" / f"change-request-{key(case_id)}.lock"):
return await self._build(case_id, package_id, retry_id)
except LockBusy:
raise
except (CaseBuildError, DiagnosisError, LifecycleError, LLMError, PackageReadError) as error:
raise ChangeRequestError(getattr(error, "code", "CHANGE_REQUEST_PACKAGE")) from None
except ARCHIVE_ERRORS + (ValueError, TypeError, KeyError, IndexError, UnicodeError, RecursionError):
raise ChangeRequestError("CHANGE_REQUEST_INPUT") from None
def _read(self, reader, ref, contract):
return read_json(reader, ref, contract, self.limits.max_json_bytes)
def context(self, reader, policy):
"""Input is a confirmed, replay-verified diagnosis, never a new LLM phase."""
m = reader.manifest
diagnosis_ref = m["artifacts"]["diagnosis"]
document = self._read(reader, diagnosis_ref, "diagnosis-result")
previous = self._read(reader, document["context_ref"], "diagnosis-context")
local = {}
for kind in ("source", "database"):
for record in previous["case"][kind + "_context"]["refs"]:
ref = record["path"]
require_file(reader, ref)
sha = hashlib.sha256()
with reader._archive.open(ref) as stream:
while chunk := stream.read(65536):
sha.update(chunk)
if sha.hexdigest() != record["sha256"] or reader._archive.getinfo(ref).file_size != record["size"]:
raise ChangeRequestError("CHANGE_REQUEST_LOCAL_CONTEXT_CHANGED")
local[ref] = {"kind": kind, "source_ref": ref, "sha256": record["sha256"], "size": record["size"]}
result = {"format": "viano-change-request-context", "format_version": 1,
**{f: m[f] for f in ("case_id", "package_id", "analysis_revision")},
"diagnosis_ref": diagnosis_ref, "diagnosis_sha256": digest(document), "diagnosis": document,
"case": previous["case"], "interpretation": previous["interpretation"], "evidence": previous["evidence"],
"local_context": local, "policy": bugfix_policy(), "max_invalid_responses": policy}
validate(result, "change-request-context")
if len(json_bytes(result)) > self.limits.max_context_bytes:
raise ChangeRequestError("CHANGE_REQUEST_CONTEXT_LIMIT")
return result
async def _build(self, case_id, package_id, retry_id):
path = self.store.latest(case_id, package_id)
if path is None:
raise ChangeRequestError("CHANGE_REQUEST_DIAGNOSIS_NOT_READY")
with PackageReader(path, self.store.limits) as reader:
m = reader.manifest
revision = m["analysis_revision"]
if not any(cp["step_id"] == f"diagnosis:v1:{revision}:complete" for _, cp in checkpoint_chain(reader)):
raise ChangeRequestError("CHANGE_REQUEST_DIAGNOSIS_NOT_READY")
confirmed = await self.diagnosis.build(case_id, package_id)
if confirmed.status != "COMPLETED":
return ChangeRequestResult(confirmed.status, confirmed.package, failure_ref=confirmed.failure_ref, code=confirmed.code, retry_not_before=confirmed.retry_not_before)
with PackageReader(confirmed.package, self.store.limits) as reader:
m = reader.manifest
chain = checkpoint_chain(reader)
completed = next((cp for _, cp in chain if cp["step_id"] == f"change-request:v1:{revision}:complete"), None)
started = next((cp for _, cp in chain if cp["step_id"] == f"change-request:v1:{revision}:start"), None)
base = f"analysis/change-requests/{revision:04d}"
context_ref = base + "/context.json"
policy = self.config.max_invalid_responses
if started:
old = self._read(reader, context_ref, "change-request-context")
if m["artifacts"].get("change_request_context") != context_ref:
raise ChangeRequestError("CHANGE_REQUEST_POINTER_CHANGED")
policy = old["max_invalid_responses"]
if self.config_explicit and policy != self.config.max_invalid_responses:
raise ChangeRequestError("CHANGE_REQUEST_POLICY_CHANGED")
context = self.context(reader, policy)
if started and (old != context or started["details"]["context_sha256"] != digest(context)):
raise ChangeRequestError("CHANGE_REQUEST_CONTEXT_CHANGED")
if completed:
return self.reuse(reader, completed, context)
if m["state"] != State.BUILDING_DIAGNOSIS:
raise ChangeRequestError("CHANGE_REQUEST_PHASE")
failures = [cp for _, cp in chain if cp["analysis_revision"] == revision and cp["details"].get("operation") == "change-request-rejected"]
attempt = len(failures) + 1
if attempt > policy:
raise ChangeRequestError("CHANGE_REQUEST_REJECTION_LIMIT")
path = confirmed.package
if not started:
path = self.store.advance(case_id, package_id, State.BUILDING_DIAGNOSIS, step_id=f"change-request:v1:{revision}:start",
details={"operation": "change-request-start", "context_ref": context_ref, "context_sha256": digest(context)},
additions={context_ref: json_bytes(context)}, artifacts={"change_request_context": context_ref, "output_validation": None}, expected_generation=m["package_generation"])
operation = f"change-request:v1:attempt:{attempt}"
if retry_id:
with PackageReader(path, self.store.limits) as reader:
prior = next((cp for _, cp in checkpoint_chain(reader) if cp["details"].get("operation_key") == key(f"{revision}:{operation}")), None)
if prior and (prior["details"].get("status") == "BLOCKED" or prior["details"].get("code") in PROVIDER_CONFIGURATION_ERRORS):
operation += ":retry:" + key(retry_id)
try:
outcome = await self.runner.run(case_id, package_id, operation_id=operation, request=build_prompt(context))
except LLMError as error:
if error.code not in PROVIDER_CONFIGURATION_ERRORS:
raise
return self._block(case_id, package_id, revision, operation, error.code)
if outcome.status not in {"SUCCESS", "ERROR"}:
return ChangeRequestResult(outcome.status, outcome.package, code=outcome.code, retry_not_before=outcome.retry_not_before)
if outcome.status == "ERROR" and outcome.code in PROVIDER_CONFIGURATION_ERRORS:
return self._block(case_id, package_id, revision, operation, outcome.code)
# Re-verify the accepted earlier phase against concurrent case changes.
checked = await self.diagnosis.build(case_id, package_id)
with PackageReader(checked.package, self.store.limits) as reader:
if reader.manifest["state"] != State.BUILDING_DIAGNOSIS or self.context(reader, policy) != context:
raise ChangeRequestError("CHANGE_REQUEST_CONTEXT_CHANGED")
call = self.diagnosis._read_call(reader, outcome.result_ref)
self.bind(reader, call, context, outcome.result_ref, operation)
fallback = False
try:
if outcome.status != "SUCCESS":
raise ChangeRequestError(outcome.code or "CHANGE_REQUEST_OUTPUT")
payload = call["outcome"]["document"]
validate_request(payload, context)
except (CaseBuildError, ChangeRequestError) as error:
if attempt < policy:
ref = base + f"/errors/{attempt:04d}.json"
result = self.store.advance(case_id, package_id, State.BUILDING_DIAGNOSIS, step_id=f"change-request:v1:{revision}:rejected:{attempt}",
details={"operation": "change-request-rejected", "error_ref": ref, "code": error.code, "llm_result_ref": outcome.result_ref},
additions={ref: json_bytes({"code": error.code, "llm_result_ref": outcome.result_ref})}, expected_generation=reader.manifest["package_generation"])
self.runner.logger.error("Change request rejected case_id=%s code=%s", case_id, error.code)
return ChangeRequestResult("REJECTED", result, code=error.code)
payload, fallback = self.fallback(), True
return self.publish(reader, context, context_ref, outcome.result_ref, operation, attempt, payload, fallback)
def _block(self, case_id, package_id, revision, operation, code):
due = (self.runner.clock() + timedelta(seconds=self.gateway.config.blocked_retry_seconds)).isoformat()
path = self.store.advance(case_id, package_id, State.BLOCKED, step_id=f"change-request:v1:{revision}:config:{key(operation)}",
reason="LLM_CONFIGURATION", retry_not_before=due, details={"operation": "change-request-provider-blocked", "code": code, "automatic_retry": False})
self.runner.logger.error("Change request blocked case_id=%s code=%s", case_id, code)
return ChangeRequestResult("BLOCKED", path, code=code, retry_not_before=due)
def bind(self, reader, call, context, ref, operation):
with reader._archive.open(ref.rsplit("/", 1)[0] + "/request.json") as stream:
raw = reader.validator._read_bounded(stream, min(self.gateway.config.max_request_bytes, self.store.limits.max_file_bytes))
saved = parse_manifest(raw)
if not isinstance(saved, dict):
raise ChangeRequestError("CHANGE_REQUEST_LLM_BINDING")
expected = build_prompt(context)
expected["privacy"] = saved.get("privacy")
if (saved != expected or hashlib.sha256(raw).hexdigest() != call["input_request_hash"]
or call["operation_key"] != key(f"{context['analysis_revision']}:{operation}") or call["phase"] != State.BUILDING_DIAGNOSIS
or any(call[f] != context[f] for f in ("case_id", "package_id", "analysis_revision"))):
raise ChangeRequestError("CHANGE_REQUEST_LLM_BINDING")
@staticmethod
def fallback():
return {"status": "insufficient_context", "problem_summary": "No validated coding request could be formed.", "affected_scope": [],
"required_change": "Human review required.", "implementation_intent": "Do not issue a coding task.", "implementation_guidance": [],
"constraints": [], "preserve": [], "forbidden_changes": [], "do_not_fix_by": [], "validation_requirements": [],
"missing_information": ["Review the implementation context and rejected model replies."], "reason": "Change request validation attempts exhausted."}
def failure_document(self, reader, context, payload):
state = self._read(reader, context["diagnosis"]["context_ref"], "diagnosis-context")["investigation"]
return {"format": "viano-failure-report", "format_version": 1,
**{f: context[f] for f in ("case_id", "package_id", "analysis_revision")},
"diagnosis_ref": context["diagnosis_ref"], "reason": payload["reason"], "hypothesis_end_states": state["hypotheses"],
"remaining_questions": {hid: [q for q in h["questions"] if not q["resolved"]] for hid, h in state["hypotheses"].items()},
"recommended_human_actions": payload["missing_information"]}
def publish(self, reader, context, context_ref, call_ref, operation, attempt, payload, fallback):
validate_request(payload, context)
revision = context["analysis_revision"]
base = f"analysis/change-requests/{revision:04d}"
decision_ref = base + "/decision.json"
additions = {decision_ref: json_bytes(payload)}
cr_ref = summary_ref = failure_ref = None
if payload["status"] == "ready":
cr_ref = f"handoffs/coding/CR-{revision:04d}/change_request.json"
summary_ref = cr_ref.removesuffix(".json") + ".md"
document = make_document(context, payload, context_ref, call_ref)
validate(document, "change-request")
additions.update({cr_ref: json_bytes(document), summary_ref: render_change_request(document).encode()})
else:
failure_ref = base + "/failure_report.json"
failure = self.failure_document(reader, context, payload)
validate(failure, "failure-report")
additions[failure_ref] = json_bytes(failure)
if any(len(data) > self.limits.max_json_bytes for data in additions.values()):
raise ChangeRequestError("CHANGE_REQUEST_OUTPUT_LIMIT")
details = {"operation": "change-request-complete", "context_ref": context_ref, "context_sha256": digest(context),
"decision_ref": decision_ref, "decision_sha256": digest(payload), "change_request_ref": cr_ref, "summary_ref": summary_ref,
"failure_ref": failure_ref, "llm_result_ref": call_ref, "llm_operation": operation, "attempt": attempt, "fallback": fallback,
"artifact_hashes": {ref: hashlib.sha256(data).hexdigest() for ref, data in additions.items()}}
target = State.FAILED_TO_ANALYZE if failure_ref else State.BUILDING_DIAGNOSIS
path = self.store.advance(context["case_id"], context["package_id"], target, step_id=f"change-request:v1:{revision}:complete",
reason="CHANGE_REQUEST_INSUFFICIENT_CONTEXT" if failure_ref else None, details=details, additions=additions,
artifacts={"change_request": cr_ref, "change_request_summary": summary_ref, "failure_report": failure_ref}, expected_generation=reader.manifest["package_generation"])
return ChangeRequestResult("INCONCLUSIVE" if failure_ref else "COMPLETED", path, cr_ref, failure_ref)
def reuse(self, reader, checkpoint, context):
d = checkpoint["details"]
payload = self._read(reader, d["decision_ref"], "change-request-output")
if digest(payload) != d["decision_sha256"] or digest(context) != d["context_sha256"]:
raise ChangeRequestError("CHANGE_REQUEST_OUTPUT_CHANGED")
call = self.diagnosis._read_call(reader, d["llm_result_ref"])
self.bind(reader, call, context, d["llm_result_ref"], d["llm_operation"])
validate_request(payload, context)
if d["fallback"]:
if d["attempt"] != context["max_invalid_responses"] or payload != self.fallback():
raise ChangeRequestError("CHANGE_REQUEST_LLM_BINDING")
if call["status"] == "SUCCESS":
try:
validate_request(call["outcome"]["document"], context)
except (CaseBuildError, ChangeRequestError):
pass
else:
raise ChangeRequestError("CHANGE_REQUEST_LLM_BINDING")
elif call["status"] != "ERROR" or call.get("code") in PROVIDER_CONFIGURATION_ERRORS:
raise ChangeRequestError("CHANGE_REQUEST_LLM_BINDING")
elif call["status"] != "SUCCESS" or call["outcome"]["document"] != payload:
raise ChangeRequestError("CHANGE_REQUEST_LLM_BINDING")
for ref, sha in d["artifact_hashes"].items():
require_file(reader, ref)
with reader._archive.open(ref) as stream:
raw = reader.validator._read_bounded(stream, min(self.limits.max_json_bytes, self.store.limits.max_file_bytes))
if hashlib.sha256(raw).hexdigest() != sha:
raise ChangeRequestError("CHANGE_REQUEST_OUTPUT_CHANGED")
for field, ref in (("change_request", d["change_request_ref"]), ("change_request_summary", d["summary_ref"]), ("failure_report", d["failure_ref"])):
if reader.manifest["artifacts"].get(field) != ref:
raise ChangeRequestError("CHANGE_REQUEST_POINTER_CHANGED")
if d["change_request_ref"]:
document = self._read(reader, d["change_request_ref"], "change-request")
if document != make_document(context, payload, d["context_ref"], d["llm_result_ref"]):
raise ChangeRequestError("CHANGE_REQUEST_OUTPUT_CHANGED")
if reader.read_bytes(d["summary_ref"]) != render_change_request(document).encode():
raise ChangeRequestError("CHANGE_REQUEST_OUTPUT_CHANGED")
elif payload["status"] != "insufficient_context" or d["failure_ref"] is None:
raise ChangeRequestError("CHANGE_REQUEST_OUTPUT_CHANGED")
elif self._read(reader, d["failure_ref"], "failure-report") != self.failure_document(reader, context, payload):
raise ChangeRequestError("CHANGE_REQUEST_OUTPUT_CHANGED")
return ChangeRequestResult("INCONCLUSIVE" if d["failure_ref"] else "COMPLETED", reader.path, d["change_request_ref"], d["failure_ref"], True)