305 lines
20 KiB
Python
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)
|