1352 lines
67 KiB
Python
1352 lines
67 KiB
Python
#!/usr/bin/env python3
|
|
"""Run one governed goal through a local worker and a cloud reviewer.
|
|
|
|
The job file is tenant-scoped and created by the Control Panel. Credentials are
|
|
passed only through the child environment and are never copied into job, trace,
|
|
audit, or metric records.
|
|
"""
|
|
import argparse
|
|
import fcntl
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
def project_root() -> str:
|
|
current = os.path.abspath(os.path.dirname(__file__))
|
|
while current != os.path.dirname(current):
|
|
if os.path.isdir(os.path.join(current, ".specify")):
|
|
return current
|
|
current = os.path.dirname(current)
|
|
raise SystemExit("GOAL_ROOT_NOT_FOUND")
|
|
|
|
|
|
ROOT = project_root()
|
|
BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
|
|
MODEL_ROUTER = os.environ.get("CASAN_GOAL_MODEL_ROUTER") or os.path.join(BIN, "model-router.sh")
|
|
SECURITY = os.path.join(BIN, "security-check.sh")
|
|
STATE_ROOT = os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
|
PROJECT_REGISTRY = os.path.join(ROOT, "packages", "casan-harness", "config", "project-registry.json")
|
|
APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
|
|
_MANIFEST_SPEC = importlib.util.spec_from_file_location(
|
|
"casan_goal_project_manifest", os.path.join(BIN, "project_manifest.py")
|
|
)
|
|
PROJECT_MANIFEST = importlib.util.module_from_spec(_MANIFEST_SPEC)
|
|
_MANIFEST_SPEC.loader.exec_module(PROJECT_MANIFEST)
|
|
CONTEXT_EXTENSIONS = {".md", ".txt", ".json", ".yaml", ".yml", ".ts", ".tsx", ".js", ".mjs", ".py", ".sh", ".prisma", ".css", ".html"}
|
|
CONTEXT_IGNORED = {"node_modules", ".git", "dist", "build", "coverage", ".vite", "tmp", "logs", "__pycache__"}
|
|
SENSITIVE_NAMES = {".env", ".env.local", "credentials", "credentials.json", "secrets.json", "id_rsa", "id_ed25519"}
|
|
|
|
|
|
def now() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def sha(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def atomic_json(path: str, payload: dict) -> None:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(prefix=".goal-", dir=os.path.dirname(path), text=True)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
handle.write("\n")
|
|
os.chmod(temporary, 0o600)
|
|
os.replace(temporary, path)
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
|
|
|
|
def load_json(path: str) -> dict:
|
|
with open(path, encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("GOAL_JOB_INVALID")
|
|
return payload
|
|
|
|
|
|
def append_jsonl(path: str, payload: dict) -> None:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "a", encoding="utf-8") as handle:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
handle.flush()
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
|
|
|
|
def trace_path(goal_id: str) -> str:
|
|
directory = os.environ.get("CASAN_TRACE_EVENTS_DIR") or os.path.join(STATE_ROOT, "logs", "trace-events")
|
|
return os.path.join(directory, f"{goal_id}.jsonl")
|
|
|
|
|
|
def emit(goal_id: str, gate_id: str, status: str, reason: str, evidence=None) -> None:
|
|
append_jsonl(trace_path(goal_id), {
|
|
"timestamp": now(),
|
|
"trace_id": goal_id,
|
|
"gate_id": gate_id,
|
|
"status": status,
|
|
"reason": reason,
|
|
"evidence": evidence or {},
|
|
})
|
|
|
|
|
|
def update_job(path: str, **changes) -> dict:
|
|
job = load_json(path)
|
|
job.update(changes)
|
|
job["updated_at"] = now()
|
|
atomic_json(path, job)
|
|
return job
|
|
|
|
|
|
def stage(path: str, stage_id: str, status: str, detail: str, provider="", model="") -> None:
|
|
job = load_json(path)
|
|
stages = list(job.get("stages", []))
|
|
row = next((item for item in stages if item.get("id") == stage_id), None)
|
|
if row is None:
|
|
row = {"id": stage_id}
|
|
stages.append(row)
|
|
row.update({"status": status, "detail": detail, "provider": provider, "model": model, "updated_at": now()})
|
|
job["stages"] = stages
|
|
job["updated_at"] = now()
|
|
atomic_json(path, job)
|
|
|
|
|
|
def registered_project(project_id: str) -> dict:
|
|
registry = load_json(PROJECT_REGISTRY)
|
|
entry = next((item for item in registry.get("projects", [])
|
|
if item.get("project_id") == project_id and item.get("status") == "active"), None)
|
|
if not entry:
|
|
raise ValueError("goal_project_not_allowed")
|
|
raw_roots = entry.get("context_roots") or [entry.get("domain_root")]
|
|
root_real = os.path.realpath(ROOT)
|
|
resolved = []
|
|
for relative in raw_roots:
|
|
if not isinstance(relative, str) or not relative:
|
|
raise ValueError("goal_context_root_invalid")
|
|
absolute = os.path.realpath(os.path.join(ROOT, relative))
|
|
if absolute != root_real and not absolute.startswith(root_real + os.sep):
|
|
raise ValueError("goal_context_root_denied")
|
|
if not os.path.exists(absolute):
|
|
raise ValueError("goal_context_root_missing")
|
|
resolved.append((relative, absolute))
|
|
return {"project_id": project_id, "domain": entry.get("domain", project_id), "domain_root": entry.get("domain_root", ""), "roots": resolved}
|
|
|
|
|
|
def redact_context(text: str) -> str:
|
|
patterns = [
|
|
(r"(?im)^[^\n]*(?:api[_-]?key|secret|password|token)[^:=\n]*[:=]\s*[^\s\n]+", "[REDACTED SECRET]"),
|
|
(r"(?i)bearer\s+[A-Za-z0-9._~+/-]{12,}", "Bearer [REDACTED]"),
|
|
(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", "[REDACTED PRIVATE KEY]"),
|
|
]
|
|
redacted = text
|
|
for pattern, replacement in patterns:
|
|
redacted = re.sub(pattern, replacement, redacted)
|
|
return redacted
|
|
|
|
|
|
def context_excerpt_is_sensitive(text: str) -> bool:
|
|
"""Exclude source excerpts that would turn the shared snapshot into unsafe input."""
|
|
normalized = " ".join(text.lower().split())
|
|
blocked_phrases = (
|
|
"ignore previous instruction",
|
|
"ignore prior instruction",
|
|
"drop table",
|
|
"shutdown system",
|
|
"export secrets",
|
|
"dump database",
|
|
# The security gate normalizes identifiers, so Prisma's `onDelete`
|
|
# otherwise looks like an imperative delete request.
|
|
"ondelete",
|
|
)
|
|
if any(phrase in normalized for phrase in blocked_phrases):
|
|
return True
|
|
return bool(re.search(
|
|
r"(?i)(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|jwt[_-]?secret|secret)"
|
|
r"\s*[:=]\s*(?!\[REDACTED\])\S+",
|
|
text,
|
|
))
|
|
|
|
|
|
def restricted_context_paths(goal: str):
|
|
"""Return an explicit user-declared file/directory boundary, if present."""
|
|
section = re.search(
|
|
r"(?:chỉ được đọc và thay đổi|chỉ làm việc trong|only (?:read and )?(?:modify|change)|only work (?:in|within))\s*:\s*"
|
|
r"(.*?)(?=\n\s*(?:không sửa bất kỳ file nào khác|do not (?:modify|change) any other file|không thay đổi|không thêm|vấn đề|yêu cầu|verification|$))",
|
|
goal,
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
if not section:
|
|
return []
|
|
paths = []
|
|
for match in re.findall(r"(?:apps|packages|docs)/[A-Za-z0-9_./*-]+", section.group(1), re.IGNORECASE):
|
|
cleaned = match.rstrip(".,:;)")
|
|
scoped_directory = cleaned.endswith(("/*", "/**"))
|
|
normalized = cleaned.rstrip("/*")
|
|
if normalized and all(path != normalized for path, _ in paths):
|
|
paths.append((normalized, scoped_directory))
|
|
return paths
|
|
|
|
|
|
def context_candidates(project: dict, goal: str):
|
|
terms = {term.lower() for term in re.findall(r"[A-Za-z0-9_-]{3,}", goal)}
|
|
explicit_paths = []
|
|
for match in re.findall(r"(?:apps|packages|docs)/[A-Za-z0-9_./*-]+", goal, re.IGNORECASE):
|
|
cleaned = match.rstrip(".,:;)")
|
|
scoped_directory = cleaned.endswith(("/*", "/**"))
|
|
normalized_path = cleaned.rstrip("/*").lower()
|
|
if normalized_path and all(path != normalized_path for path, _ in explicit_paths):
|
|
explicit_paths.append((normalized_path, scoped_directory))
|
|
candidates = []
|
|
seen = set()
|
|
for relative_root, absolute_root in project["roots"]:
|
|
if os.path.isfile(absolute_root):
|
|
paths = [absolute_root]
|
|
else:
|
|
paths = []
|
|
for base, directories, files in os.walk(absolute_root):
|
|
directories[:] = [item for item in directories if item not in CONTEXT_IGNORED and not item.startswith('.')]
|
|
paths.extend(os.path.join(base, name) for name in files)
|
|
for path in paths:
|
|
relative = os.path.relpath(path, ROOT)
|
|
if relative in seen or os.path.basename(path).lower() in SENSITIVE_NAMES:
|
|
continue
|
|
seen.add(relative)
|
|
extension = os.path.splitext(path)[1].lower()
|
|
if extension not in CONTEXT_EXTENSIONS or os.path.getsize(path) > 256_000:
|
|
continue
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
raw = handle.read(16_000)
|
|
except OSError:
|
|
continue
|
|
haystack = (relative + "\n" + raw[:4000]).lower()
|
|
score = sum(4 if term in relative.lower() else 1 for term in terms if term in haystack)
|
|
relative_lower = relative.lower()
|
|
matching_paths = [
|
|
(path, scoped_directory)
|
|
for path, scoped_directory in explicit_paths
|
|
if relative_lower == path or relative_lower.startswith(path + "/")
|
|
]
|
|
if matching_paths:
|
|
# Directory globs under "only work in" sections describe the
|
|
# actual patch surface. Exact source-file mentions come next;
|
|
# architecture/requirement references remain supporting context.
|
|
if any(scoped_directory for _, scoped_directory in matching_paths):
|
|
score += 3_000
|
|
elif os.path.splitext(relative_lower)[1] in {".ts", ".tsx", ".js", ".jsx", ".py", ".prisma", ".sql", ".sh"}:
|
|
score += 2_000
|
|
else:
|
|
score += 1_000
|
|
if relative.endswith(("README.md", "architecture.md", "technical_architecture.md", "package.json")):
|
|
score += 3
|
|
candidates.append((score, relative, raw))
|
|
return sorted(candidates, key=lambda item: (-item[0], item[1]))
|
|
|
|
|
|
def build_context(job_path: str, project_id: str, goal: str, write_intent=False):
|
|
project = registered_project(project_id)
|
|
candidates = context_candidates(project, goal)
|
|
restricted_paths = restricted_context_paths(goal)
|
|
if restricted_paths:
|
|
candidates = [
|
|
candidate for candidate in candidates
|
|
if any(
|
|
candidate[1] == path or (scoped_directory and candidate[1].startswith(path.rstrip("/") + "/"))
|
|
for path, scoped_directory in restricted_paths
|
|
)
|
|
]
|
|
if not candidates:
|
|
raise ValueError("goal_context_explicit_scope_empty")
|
|
excerpts, manifest_files, characters = [], [], 0
|
|
# A diff can only apply when the model sees the exact target-file content.
|
|
# Read-only analysis stays compact; write-intent gives the three most
|
|
# relevant files their complete bounded snapshot and retains supporting
|
|
# contracts within a larger, still finite context budget.
|
|
max_files = 12 if write_intent else 16
|
|
max_characters = 30_000 if write_intent else 7_000
|
|
for score, relative, raw in candidates:
|
|
if len(excerpts) >= max_files or characters >= max_characters:
|
|
break
|
|
per_file = 16_000 if write_intent and len(excerpts) < 3 else (3_000 if write_intent else 1_200)
|
|
source = redact_context(raw).strip()
|
|
excerpt = source[:min(per_file, max_characters - characters)].strip()
|
|
if not excerpt or context_excerpt_is_sensitive(excerpt):
|
|
continue
|
|
excerpts.append(f"### FILE: {relative}\n{excerpt}")
|
|
characters += len(excerpt)
|
|
manifest_files.append({
|
|
"path": relative,
|
|
"sha256": sha(raw),
|
|
"characters": len(excerpt),
|
|
"source_characters": len(source),
|
|
"truncated": len(excerpt) < len(source),
|
|
"relevance": score,
|
|
})
|
|
bundle = "\n\n".join(excerpts)
|
|
allowed, safe_bundle = scan(bundle, "input")
|
|
if not allowed:
|
|
raise ValueError("goal_context_security_blocked")
|
|
bundle = safe_bundle
|
|
base = os.path.splitext(job_path)[0]
|
|
bundle_path, manifest_path = base + ".context.txt", base + ".context.json"
|
|
with open(bundle_path, "w", encoding="utf-8") as handle:
|
|
handle.write(bundle + "\n")
|
|
os.chmod(bundle_path, 0o600)
|
|
manifest = {
|
|
"project_id": project_id,
|
|
"domain": project["domain"],
|
|
"domain_root": project["domain_root"],
|
|
"generated_at": now(),
|
|
"files": manifest_files,
|
|
"file_count": len(manifest_files),
|
|
"characters": characters,
|
|
"truncated": len(manifest_files) < len(candidates) or any(item["truncated"] for item in manifest_files),
|
|
"bundle_sha256": sha(bundle),
|
|
}
|
|
atomic_json(manifest_path, manifest)
|
|
return bundle, manifest, os.path.relpath(manifest_path, ROOT)
|
|
|
|
|
|
def requests_side_effect(goal: str) -> bool:
|
|
normalized = " ".join(goal.lower().split())
|
|
# The objective's leading command is authoritative. A scoped constraint
|
|
# such as "Không thay đổi API contract" must not turn "Hoàn thiện ..."
|
|
# into a read-only request. Conversely, a genuinely read-only audit does
|
|
# not begin with one of these implementation commands.
|
|
leading_write_patterns = [
|
|
r"^(hãy\s+)?(làm luôn|sửa|thay đổi|triển khai|thực hiện|hoàn thiện|hoàn thành|chạy|tạo|xóa|cài đặt|commit|push)\b",
|
|
r"^(please\s+)?(implement|fix|change|deploy|run|create|delete|install|commit|push)\b",
|
|
]
|
|
if any(re.search(pattern, normalized) for pattern in leading_write_patterns):
|
|
return True
|
|
|
|
patch_requests = re.finditer(r"\b(tạo|generate|xuất|produce)\b.{0,40}\b(patch|unified git diff)\b", normalized)
|
|
explicit_patch_request = any(
|
|
not normalized[max(0, match.start() - 8):match.start()].endswith(("không ", "do not "))
|
|
for match in patch_requests
|
|
)
|
|
explicit_read_only = bool(re.search(r"\b(không|do not)\s+(sửa|thay đổi|triển khai|implement|fix|tạo|apply|áp dụng)\b", normalized))
|
|
if explicit_read_only and not explicit_patch_request:
|
|
return False
|
|
imperative_patterns = [r"\b(thực hiện thao tác|sửa code|ghi file|mở pull request|create a pull request)\b"]
|
|
if any(re.search(pattern, normalized) for pattern in imperative_patterns):
|
|
return True
|
|
|
|
# Structured objectives commonly start with a phase label rather than the
|
|
# imperative itself (for example "PHASE 1 — Hoàn thiện backend..."). Keep
|
|
# those write requests on the patch + approval path without classifying a
|
|
# read-only audit that merely mentions source files as a side effect.
|
|
structured_write_patterns = [
|
|
r"\b(hoàn thiện|triển khai|implement|fix|sửa|thay đổi)\b.{0,120}\b(ứng dụng|backend|frontend|module|component|api|source|code|file)\b",
|
|
r"\b(tạo|generate|xuất|produce)\b.{0,40}\b(patch|unified git diff)\b",
|
|
r"\b(apply|áp dụng)\b.{0,40}\b(patch|thay đổi|change)\b",
|
|
]
|
|
return any(re.search(pattern, normalized) for pattern in structured_write_patterns)
|
|
|
|
|
|
def extract_patch(text: str) -> str:
|
|
match = re.search(r"```(?:diff|patch)\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE)
|
|
candidate = match.group(1) if match else text
|
|
start = candidate.find("diff --git ")
|
|
if start < 0:
|
|
raise ValueError("goal_patch_missing")
|
|
patch = candidate[start:].strip() + "\n"
|
|
if len(patch.encode("utf-8")) > 200_000:
|
|
raise ValueError("goal_patch_too_large")
|
|
return patch
|
|
|
|
|
|
def validate_patch_check(patch: str) -> None:
|
|
"""Require a candidate diff to apply before it can pass H2 or H3."""
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".patch") as handle:
|
|
handle.write(patch)
|
|
handle.flush()
|
|
try:
|
|
check = subprocess.run(
|
|
["git", "apply", "--check", "--whitespace=error", handle.name],
|
|
cwd=ROOT, capture_output=True, text=True, timeout=30,
|
|
)
|
|
except FileNotFoundError as error:
|
|
raise ValueError("goal_patch_validator_unavailable") from error
|
|
if check.returncode != 0:
|
|
detail = (check.stderr or check.stdout or "git_apply_check_failed").strip().replace("\n", " ")[:160]
|
|
raise ValueError(f"goal_patch_check_failed:{detail}")
|
|
|
|
|
|
def normalize_unified_diff(patch: str) -> str:
|
|
"""Repair mechanical unified-diff hunk counts without changing code.
|
|
|
|
Language models frequently emit the intended +/- lines but miscalculate
|
|
the counts in `@@ -old,count +new,count @@`, or lose the single space on a
|
|
blank context line. Both make git report a syntactically corrupt patch.
|
|
CASAN can correct those two mechanical properties deterministically. It
|
|
never invents code lines, and leaves likely truncated hunks untouched.
|
|
"""
|
|
lines = patch.rstrip("\n").split("\n")
|
|
normalized, changed = [], False
|
|
hunk_pattern = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")
|
|
index = 0
|
|
while index < len(lines):
|
|
header = hunk_pattern.match(lines[index])
|
|
if not header:
|
|
normalized.append(lines[index])
|
|
index += 1
|
|
continue
|
|
body, cursor = [], index + 1
|
|
while cursor < len(lines) and not lines[cursor].startswith(("@@ ", "diff --git ")):
|
|
line = lines[cursor]
|
|
if line == "":
|
|
line = " "
|
|
changed = True
|
|
if not line.startswith((" ", "+", "-", "\\")):
|
|
return patch
|
|
body.append(line)
|
|
cursor += 1
|
|
old_count = sum(1 for line in body if line.startswith((" ", "-")))
|
|
new_count = sum(1 for line in body if line.startswith((" ", "+")))
|
|
declared_old = int(header.group(2) or "1")
|
|
declared_new = int(header.group(4) or "1")
|
|
# A replacement cut immediately after its '-' lines must not be
|
|
# reinterpreted as a valid deletion-only patch.
|
|
if cursor == len(lines) and body and body[-1].startswith("-") and declared_new > new_count:
|
|
return patch
|
|
corrected = f"@@ -{header.group(1)},{old_count} +{header.group(3)},{new_count} @@{header.group(5)}"
|
|
if corrected != lines[index] or declared_old != old_count or declared_new != new_count:
|
|
changed = True
|
|
normalized.append(corrected)
|
|
normalized.extend(body)
|
|
index = cursor
|
|
return "\n".join(normalized) + "\n" if changed else patch
|
|
|
|
|
|
def mechanical_patch_error(error: object) -> bool:
|
|
"""Identify git parser errors that hunk recounting can safely repair."""
|
|
normalized = str(error).lower()
|
|
return any(marker in normalized for marker in (
|
|
"corrupt patch",
|
|
"patch fragment without header",
|
|
"malformed patch",
|
|
"unexpected end of file in patch",
|
|
))
|
|
|
|
|
|
def validate_write_output(text: str) -> str:
|
|
"""Fail at the producing harness when a write-intent reply is not a diff.
|
|
|
|
Keeping this separate from storage avoids creating an artifact before the
|
|
reviewer has completed, while ensuring H2/H3 accurately identify a model
|
|
that violated the required output contract.
|
|
"""
|
|
patch = extract_patch(text)
|
|
try:
|
|
validate_patch_check(patch)
|
|
return patch
|
|
except ValueError as original_error:
|
|
if not mechanical_patch_error(original_error):
|
|
raise
|
|
normalized = normalize_unified_diff(patch)
|
|
if normalized == patch:
|
|
raise
|
|
validate_patch_check(normalized)
|
|
return normalized
|
|
|
|
|
|
def patch_repair_attempts() -> int:
|
|
"""Return the bounded number of chances to repair an invalid model diff."""
|
|
try:
|
|
configured = int(os.environ.get("CASAN_GOAL_PATCH_REPAIR_ATTEMPTS", "3"))
|
|
except ValueError:
|
|
configured = 3
|
|
return min(max(configured, 0), 3)
|
|
|
|
|
|
def patch_output_tokens() -> int:
|
|
"""Keep a multi-hunk patch from being cut at the generic chat limit."""
|
|
try:
|
|
configured = int(os.environ.get("CASAN_GOAL_PATCH_MAX_OUTPUT_TOKENS", "8192"))
|
|
except ValueError:
|
|
configured = 8192
|
|
return min(max(configured, 512), 8192)
|
|
|
|
|
|
def patch_repair_models(primary_model: str):
|
|
"""Use direct cloud -> gateway -> local order for one bounded H2 recovery."""
|
|
configured = [item.strip() for item in os.environ.get("CASAN_GOAL_PATCH_REPAIR_MODELS", "").split(",") if item.strip()]
|
|
unique, seen = [], set()
|
|
ordered = [primary_model, *configured] if primary_model.startswith("account:") else [*configured, primary_model]
|
|
for candidate in ordered:
|
|
if candidate and candidate not in seen:
|
|
seen.add(candidate)
|
|
unique.append(candidate)
|
|
return unique
|
|
|
|
|
|
def model_preflight_enabled() -> bool:
|
|
configured = os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT")
|
|
return configured == "1" if configured is not None else os.environ.get("CASAN_PROFILE") == "prod"
|
|
|
|
|
|
def model_preflight(model: str):
|
|
"""Probe real patch generation before assigning a model to H2/H3.
|
|
|
|
Model discovery only proves that an ID is advertised. This bounded probe
|
|
verifies the generation route and unified-diff capability, and caches the
|
|
verdict so a broken gateway is not retried for every goal.
|
|
"""
|
|
if not model_preflight_enabled():
|
|
return True, "preflight_disabled"
|
|
if model.startswith("account:"):
|
|
endpoint = os.environ.get("CASAN_AUTH_BRIDGE_URL", "")
|
|
elif model.startswith("openai-compatible:"):
|
|
endpoint = os.environ.get("CASAN_OPENAI_COMPATIBLE_BASE_URL", "")
|
|
else:
|
|
endpoint = os.environ.get("OLLAMA_HOST", "")
|
|
cache_key = sha(f"{model}|{endpoint}")
|
|
cache_directory = os.path.join(STATE_ROOT, "cache")
|
|
cache_path = os.path.join(cache_directory, "goal-model-preflight.json")
|
|
lock_path = os.path.join(cache_directory, "goal-model-preflight.lock")
|
|
os.makedirs(cache_directory, mode=0o700, exist_ok=True)
|
|
ttl = int(os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT_TTL_SEC", "600"))
|
|
with open(lock_path, "a", encoding="utf-8") as lock:
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
try:
|
|
cache = load_json(cache_path) if os.path.isfile(cache_path) else {}
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
cache = {}
|
|
cached = cache.get(cache_key, {}) if isinstance(cache, dict) else {}
|
|
age = time.time() - float(cached.get("checked_epoch", 0))
|
|
if age <= max(30, ttl):
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
return bool(cached.get("healthy")), f"cached:{cached.get('reason', 'unknown')}"
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
|
|
prompt = (
|
|
"Patch capability probe. Return ONLY a unified git diff inside a ```diff fence that changes "
|
|
"the only line in probe.txt from old to new. No prose. The diff must begin with "
|
|
"`diff --git a/probe.txt b/probe.txt`."
|
|
)
|
|
timeout_setting = (
|
|
os.environ.get("CASAN_GOAL_LOCAL_MODEL_PREFLIGHT_TIMEOUT_SEC", "90")
|
|
if model.startswith("ollama:")
|
|
else os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT_TIMEOUT_SEC", "30")
|
|
)
|
|
timeout = max(5, min(int(timeout_setting), 120))
|
|
if model.startswith("account:"):
|
|
ok, output, _, reason = call_account_model(model.split(":", 1)[1], prompt, timeout)
|
|
else:
|
|
ok, output, _, reason = call_model(
|
|
model, prompt, model.startswith(("openai:", "anthropic:", "openai-compatible:")),
|
|
timeout_seconds=timeout, max_output_tokens=512,
|
|
)
|
|
healthy = False
|
|
if ok:
|
|
try:
|
|
patch = extract_patch(output)
|
|
healthy = (
|
|
"diff --git a/probe.txt b/probe.txt" in patch
|
|
and "\n-old\n" in patch
|
|
and "\n+new\n" in patch
|
|
)
|
|
reason = "ok" if healthy else "patch_probe_contract_invalid"
|
|
except ValueError as error:
|
|
reason = str(error)
|
|
record = {
|
|
"model": model,
|
|
"provider": provider_for_model(model),
|
|
"healthy": healthy,
|
|
"reason": reason[:180],
|
|
"checked_at": now(),
|
|
"checked_epoch": time.time(),
|
|
}
|
|
with open(lock_path, "a", encoding="utf-8") as lock:
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
try:
|
|
cache = load_json(cache_path) if os.path.isfile(cache_path) else {}
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
cache = {}
|
|
cache[cache_key] = record
|
|
atomic_json(cache_path, cache)
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
return healthy, reason
|
|
|
|
|
|
def provider_for_model(model: str) -> str:
|
|
if model.startswith("account:"):
|
|
return f"{model.split(':', 1)[1]}-account"
|
|
if model.startswith("openai:"):
|
|
return "openai"
|
|
if model.startswith("anthropic:"):
|
|
return "anthropic"
|
|
if model.startswith("openai-compatible:"):
|
|
return "omniroute"
|
|
if model.startswith("ollama:"):
|
|
return "ollama"
|
|
return "model"
|
|
|
|
|
|
def repair_write_output(model: str, original_prompt: str, invalid_output: str, contract_error="goal_patch_missing"):
|
|
"""Ask the producing model to repair format only, without relaxing H2.
|
|
|
|
A write-intent goal may never advance to approval without a checked unified
|
|
diff. Models occasionally answer with an implementation plan despite the
|
|
contract, so one bounded repair avoids treating a recoverable formatting
|
|
lapse as a final H2 failure. The caller still H4-scans and validates the
|
|
result before accepting it.
|
|
"""
|
|
if patch_repair_attempts() == 0:
|
|
return False, "", {}, "goal_patch_missing"
|
|
def repair_prompt_for(previous_output: str, error: str) -> str:
|
|
hunk_instruction = (
|
|
"The previous diff is syntactically corrupt. Recompute every `@@ -old,count +new,count @@` header from the exact added/removed/context lines that follow it; do not omit or invent any hunk line. "
|
|
if mechanical_patch_error(error) else ""
|
|
)
|
|
return (
|
|
f"Your previous response violated the required write-output contract: {error}. " +
|
|
hunk_instruction +
|
|
"Return ONLY one complete, applicable unified git diff inside a ```diff fence. The first non-empty line inside the fence MUST be `diff --git `. "
|
|
"Every hunk must be complete and the patch must pass `git apply --check`. Do not emit `index` lines, placeholder hashes, commentary, plans, summaries, or side effects. Preserve the original objective and workspace restrictions.\n\n"
|
|
f"ORIGINAL CONTRACT:\n{original_prompt}\n\nPREVIOUS INVALID RESPONSE:\n{previous_output[:8000]}"
|
|
)
|
|
repair_prompt = repair_prompt_for(invalid_output, contract_error)
|
|
candidates = patch_repair_models(model)[:patch_repair_attempts()]
|
|
last_metadata, last_reason = {}, "goal_patch_missing"
|
|
attempts = []
|
|
for attempt_number, candidate in enumerate(candidates, start=1):
|
|
healthy, preflight_reason = model_preflight(candidate)
|
|
if not healthy:
|
|
last_reason = f"goal_patch_model_preflight_failed:{preflight_reason}"
|
|
attempts.append({
|
|
"attempt": attempt_number,
|
|
"provider": provider_for_model(candidate),
|
|
"model": candidate,
|
|
"status": "failed",
|
|
"reason": last_reason,
|
|
})
|
|
continue
|
|
if candidate.startswith("account:"):
|
|
ok, output, metadata, reason = call_account_model(candidate.split(":", 1)[1], repair_prompt, 300)
|
|
else:
|
|
ok, output, metadata, reason = call_model(candidate, repair_prompt, candidate.startswith(("openai:", "anthropic:", "openai-compatible:")), max_output_tokens=patch_output_tokens())
|
|
metadata = dict(metadata)
|
|
metadata["repair_model"] = candidate
|
|
metadata["repair_attempt"] = attempt_number
|
|
last_metadata = metadata
|
|
if not ok:
|
|
last_reason = f"goal_patch_repair_failed:{reason}"
|
|
attempts.append({
|
|
"attempt": attempt_number,
|
|
"provider": provider_for_model(candidate),
|
|
"model": candidate,
|
|
"status": "failed",
|
|
"reason": last_reason,
|
|
})
|
|
continue
|
|
try:
|
|
checked_output = validate_write_output(output)
|
|
except ValueError as error:
|
|
last_reason = str(error)
|
|
attempts.append({
|
|
"attempt": attempt_number,
|
|
"provider": provider_for_model(candidate),
|
|
"model": candidate,
|
|
"status": "failed",
|
|
"reason": last_reason,
|
|
})
|
|
# Codex often produces a semantically correct but syntactically
|
|
# incomplete first diff. Give the same stronger direct model one
|
|
# corrective pass containing its own failed patch and git error
|
|
# before sending source context to a weaker gateway.
|
|
if (candidate.endswith("-codex") or candidate == "account:codex") and attempt_number < patch_repair_attempts():
|
|
candidates.insert(attempt_number, candidate)
|
|
del candidates[patch_repair_attempts():]
|
|
repair_prompt = repair_prompt_for(output, last_reason)
|
|
continue
|
|
attempts.append({
|
|
"attempt": attempt_number,
|
|
"provider": provider_for_model(candidate),
|
|
"model": candidate,
|
|
"status": "pass",
|
|
"reason": "ok",
|
|
})
|
|
metadata["repair_attempts"] = attempts
|
|
return True, checked_output, metadata, "ok"
|
|
last_metadata["repair_attempts"] = attempts
|
|
return False, "", last_metadata, f"goal_patch_repair_invalid:{last_reason}"
|
|
|
|
|
|
def merge_usage(primary: dict, additional: dict) -> dict:
|
|
"""Preserve provider metadata while adding numeric usage from a repair call."""
|
|
merged = dict(primary)
|
|
for key, value in additional.items():
|
|
if isinstance(value, (int, float)) and isinstance(merged.get(key), (int, float)):
|
|
merged[key] = merged[key] + value
|
|
elif key not in merged:
|
|
merged[key] = value
|
|
return merged
|
|
|
|
|
|
def validate_patch_semantics(job: dict, artifact: str, changed_files: list[str]) -> list[dict]:
|
|
"""Build and test an applied patch in an isolated workspace before approval."""
|
|
artifact = os.path.realpath(artifact)
|
|
if os.path.commonpath([ROOT, artifact]) != ROOT or not os.path.isfile(artifact):
|
|
raise ValueError("goal_patch_artifact_path_denied")
|
|
manifest = PROJECT_MANIFEST.load(ROOT, project_id=str(job.get("project") or ""))
|
|
commands = PROJECT_MANIFEST.verification_commands(manifest, changed_files)
|
|
if not commands:
|
|
raise ValueError("goal_patch_verification_unmapped")
|
|
results = []
|
|
with tempfile.TemporaryDirectory(prefix="casan-goal-verify-") as sandbox:
|
|
paths = ["package.json", "package-lock.json", *manifest["source_roots"]]
|
|
paths.extend(str(value) for value in job.get("workspace", {}).get("context_roots", []))
|
|
for relative in dict.fromkeys(paths):
|
|
source = os.path.realpath(os.path.join(ROOT, relative))
|
|
if os.path.commonpath([ROOT, source]) != ROOT or not os.path.exists(source):
|
|
continue
|
|
destination = os.path.join(sandbox, relative)
|
|
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
|
if os.path.isdir(source):
|
|
shutil.copytree(source, destination, dirs_exist_ok=True, symlinks=True, ignore=shutil.ignore_patterns("node_modules", "dist", "coverage"))
|
|
else:
|
|
shutil.copy2(source, destination)
|
|
dependencies = os.path.join(ROOT, "node_modules")
|
|
if os.path.isdir(dependencies):
|
|
os.symlink(dependencies, os.path.join(sandbox, "node_modules"), target_is_directory=True)
|
|
applied = subprocess.run(
|
|
["git", "apply", "--whitespace=error", artifact], cwd=sandbox,
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if applied.returncode != 0:
|
|
raise ValueError("goal_patch_sandbox_apply_failed:" + (applied.stderr or applied.stdout).strip()[:300])
|
|
timeout = max(30, int(os.environ.get("CASAN_GOAL_PATCH_VERIFY_TIMEOUT_SEC", "300")))
|
|
for command in commands:
|
|
result = subprocess.run(command, cwd=sandbox, capture_output=True, text=True, timeout=timeout)
|
|
output = (result.stdout + result.stderr)[-4000:]
|
|
results.append({"command": " ".join(command), "exit_code": result.returncode, "output": output})
|
|
if result.returncode != 0:
|
|
raise ValueError("goal_patch_verification_failed:" + output[-1200:])
|
|
return results
|
|
|
|
|
|
def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
|
|
explicit_scope = restricted_context_paths(str(job.get("goal") or ""))
|
|
roots = [path.strip("/") for path, _ in explicit_scope] or [str(value).strip("/") for value in job.get("workspace", {}).get("context_roots", [])]
|
|
directory_roots = {path.strip("/") for path, scoped_directory in explicit_scope if scoped_directory}
|
|
changed = []
|
|
header_paths = []
|
|
for line in patch.splitlines():
|
|
if line.startswith("diff --git "):
|
|
fields = shlex.split(line)
|
|
if len(fields) != 4:
|
|
raise ValueError("goal_patch_header_invalid")
|
|
header_paths.extend(fields[2:])
|
|
elif line.startswith(("--- ", "+++ ", "rename from ", "rename to ", "copy from ", "copy to ")):
|
|
value = line.split(" ", 1)[1].split("\t", 1)[0]
|
|
header_paths.append(value)
|
|
for raw in header_paths:
|
|
path = raw.strip()
|
|
if path == "/dev/null":
|
|
continue
|
|
if path.startswith(("a/", "b/")):
|
|
path = path[2:]
|
|
if path.startswith("/") or ".." in path.split("/"):
|
|
raise ValueError("goal_patch_path_denied")
|
|
if not any(path == root or (root in directory_roots and path.startswith(root + "/")) for root in roots):
|
|
raise ValueError(f"goal_patch_outside_workspace:{path}")
|
|
changed.append(path)
|
|
if not changed or len(set(changed)) > 20:
|
|
raise ValueError("goal_patch_file_count_invalid")
|
|
artifact = job_path[:-5] + ".patch"
|
|
with open(artifact, "x", encoding="utf-8") as handle:
|
|
handle.write(patch)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.chmod(artifact, 0o600)
|
|
check = subprocess.run(["git", "apply", "--check", "--whitespace=error", artifact], cwd=ROOT, capture_output=True, text=True, timeout=30)
|
|
if check.returncode != 0:
|
|
os.unlink(artifact)
|
|
raise ValueError("goal_patch_check_failed:" + (check.stderr or check.stdout).strip()[:160])
|
|
try:
|
|
verification = validate_patch_semantics(job, artifact, sorted(set(changed)))
|
|
except Exception:
|
|
os.unlink(artifact)
|
|
raise
|
|
return {"path": os.path.relpath(artifact, ROOT), "sha256": sha(patch), "files": sorted(set(changed)), "bytes": len(patch.encode("utf-8")), "status": "awaiting_approval", "preview": patch[:50_000], "preapproval_verification": verification}
|
|
|
|
|
|
def submit_side_effect(job: dict, manifest: dict, patch_artifact: dict) -> dict:
|
|
payload = {
|
|
"goal_id": job["id"],
|
|
"project_id": job["project"],
|
|
"context_manifest_hash": manifest["bundle_sha256"],
|
|
"requested_operation": job["goal"],
|
|
"patch_sha256": patch_artifact["sha256"],
|
|
"patch_path": patch_artifact["path"],
|
|
"changed_files": patch_artifact["files"],
|
|
}
|
|
result = subprocess.run([
|
|
"python3", APPROVAL_INBOX, "submit",
|
|
"--project", job["project"],
|
|
"--action", "goal.workspace.execute",
|
|
"--target", job["project"],
|
|
"--risk", "high",
|
|
"--sensitive",
|
|
"--proposer", job["actor"],
|
|
"--reason", "Goal requests a workspace side effect; execution remains disabled until approval",
|
|
"--payload", json.dumps(payload, ensure_ascii=False),
|
|
], cwd=ROOT, capture_output=True, text=True, env=os.environ.copy(), timeout=30)
|
|
if result.returncode != 0:
|
|
raise RuntimeError((result.stderr or result.stdout or "goal_approval_submit_failed").strip())
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def scan(text: str, mode: str):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
source = os.path.join(directory, "input.txt")
|
|
output = os.path.join(directory, "output.txt")
|
|
with open(source, "w", encoding="utf-8") as handle:
|
|
handle.write(text)
|
|
environment = os.environ.copy()
|
|
# The goal workflow already records and enforces its H4 boundary. Keep
|
|
# deterministic injection/secret/PII checks active, but do not turn a
|
|
# temporary semantic-classifier outage into a false-positive block.
|
|
environment["CASAN_SECURITY_STRICT"] = "0"
|
|
result = subprocess.run(
|
|
["bash", SECURITY, source, output, mode],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
env=environment,
|
|
timeout=60,
|
|
)
|
|
safe = ""
|
|
if os.path.isfile(output):
|
|
with open(output, encoding="utf-8") as handle:
|
|
safe = handle.read().strip()
|
|
return result.returncode == 0, safe
|
|
|
|
|
|
def call_model(model: str, prompt: str, cloud: bool, timeout_seconds=None, max_output_tokens=None):
|
|
if not model:
|
|
return False, "", {}, "model_unconfigured"
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
prompt_path = os.path.join(directory, "prompt.txt")
|
|
output_path = os.path.join(directory, "output.json")
|
|
with open(prompt_path, "w", encoding="utf-8") as handle:
|
|
handle.write(prompt)
|
|
environment = os.environ.copy()
|
|
environment["CASAN_PREFLIGHT"] = "1" if cloud else "0"
|
|
# Long Markdown objectives need enough time for a local 9B model to
|
|
# ingest the brief and produce a bounded plan. The outer goal timeout
|
|
# remains the hard ceiling; this only raises the router's 60s default.
|
|
environment.setdefault("CASAN_MODEL_TIMEOUT_SEC", os.environ.get("CASAN_GOAL_LOCAL_TIMEOUT_SEC", "240") if not cloud else "120")
|
|
configured_output = os.environ.get("CASAN_GOAL_MAX_OUTPUT_TOKENS", "1400")
|
|
if max_output_tokens is not None:
|
|
configured_output = str(max_output_tokens)
|
|
environment["CASAN_MODEL_GENERATE_MAX_TOKENS"] = configured_output
|
|
timeout = max(1, int(timeout_seconds or os.environ.get("CASAN_GOAL_MODEL_TIMEOUT", "300")))
|
|
environment["CASAN_MODEL_TIMEOUT_SEC"] = str(min(
|
|
int(environment.get("CASAN_MODEL_TIMEOUT_SEC", timeout)), timeout
|
|
))
|
|
try:
|
|
result = subprocess.run(
|
|
["bash", MODEL_ROUTER, prompt_path, output_path, "--role", "generate", "--model", model],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
env=environment,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return False, "", {}, "model_timeout"
|
|
if result.returncode != 0 or not os.path.isfile(output_path):
|
|
raw = (result.stderr or result.stdout or "").strip().splitlines()
|
|
detail = raw[-1][:180] if raw else "no_detail"
|
|
detail = re.sub(r"(?i)(bearer|api[_-]?key|token)[=: ]+\S+", r"\1=[redacted]", detail)
|
|
return False, "", {}, f"model_exit_{result.returncode}:{detail}"
|
|
try:
|
|
output = load_json(output_path)
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
return False, "", {}, "model_output_invalid"
|
|
text = str(output.get("text") or "").strip()
|
|
if not text:
|
|
return False, "", {}, "model_output_empty"
|
|
metadata = {
|
|
"input_tokens": int(output.get("input_tokens") or 0),
|
|
"output_tokens": int(output.get("output_tokens") or 0),
|
|
"latency_ms": int(output.get("latency_ms") or 0),
|
|
}
|
|
return True, text, metadata, "ok"
|
|
|
|
|
|
def call_account_model(provider: str, prompt: str, timeout_seconds=None):
|
|
base_url = os.environ.get("CASAN_AUTH_BRIDGE_URL", "").rstrip("/")
|
|
token = os.environ.get("CASAN_AUTH_BRIDGE_TOKEN", "")
|
|
if provider not in {"codex", "claude"} or not base_url or not token:
|
|
return False, "", {}, "account_bridge_unavailable"
|
|
request = urllib.request.Request(
|
|
f"{base_url}/v1/models/{provider}/generate",
|
|
data=json.dumps({"prompt": prompt}).encode("utf-8"),
|
|
headers={"Content-Type": "application/json", "X-CASAN-Bridge-Token": token},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=max(1, int(timeout_seconds or 310))) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
except (urllib.error.URLError, TimeoutError, ValueError):
|
|
return False, "", {}, "account_bridge_failed"
|
|
text = str(payload.get("text") or "").strip()
|
|
if not payload.get("success") or not text:
|
|
return False, "", {}, str(payload.get("reason") or "account_model_failed")[:120]
|
|
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
|
metadata = {
|
|
"input_tokens": int(usage.get("input_tokens") or 0),
|
|
"output_tokens": int(usage.get("output_tokens") or 0),
|
|
"latency_ms": int(payload.get("latency_ms") or 0),
|
|
"model": str(payload.get("model") or f"{provider}-account-default"),
|
|
}
|
|
return True, text, metadata, "ok"
|
|
|
|
|
|
def reviewer_failure_retryable(reason: str) -> bool:
|
|
"""Only transient/provider failures may advance to another reviewer."""
|
|
normalized = str(reason or "").lower()
|
|
non_retryable = (
|
|
"endpoint_not_allowed", "model_unconfigured", "model_not_discovered",
|
|
"invalid_request", "content_policy", "security_blocked",
|
|
)
|
|
return not any(marker in normalized for marker in non_retryable)
|
|
|
|
|
|
def reviewer_candidates(account_provider: str, cloud_model: str, local_model: str):
|
|
"""Build a stable, de-duplicated account/cloud -> OmniRoute -> local chain."""
|
|
candidates = []
|
|
if account_provider:
|
|
candidates.append({
|
|
"kind": "account", "provider": f"{account_provider}-account",
|
|
"model": f"{account_provider}-account-default", "value": account_provider,
|
|
})
|
|
fallback_model = os.environ.get("CASAN_GOAL_CLOUD_FALLBACK_MODEL", "").strip()
|
|
if fallback_model:
|
|
fallback_provider = "omniroute" if fallback_model.startswith("openai-compatible:") else "cloud-fallback"
|
|
candidates.append({"kind": "model", "provider": fallback_provider, "model": fallback_model, "value": fallback_model})
|
|
elif cloud_model:
|
|
candidates.append({"kind": "model", "provider": os.environ.get("CASAN_GOAL_CLOUD_PROVIDER", "cloud"), "model": cloud_model, "value": cloud_model})
|
|
|
|
for model in [item.strip() for item in os.environ.get("CASAN_GOAL_CLOUD_MODELS", "").split(",") if item.strip()]:
|
|
provider = "anthropic" if model.startswith("anthropic:") else "openai" if model.startswith("openai:") else "cloud"
|
|
candidates.append({"kind": "model", "provider": provider, "model": model, "value": model})
|
|
|
|
raw_omniroute = os.environ.get("CASAN_GOAL_OMNIROUTE_MODELS", "")
|
|
omniroute_models = [item.strip() for item in raw_omniroute.split(",") if item.strip()]
|
|
for model in omniroute_models:
|
|
value = model if ":" in model else f"openai-compatible:{model}"
|
|
candidates.append({"kind": "model", "provider": "omniroute", "model": value, "value": value})
|
|
|
|
local_reviewer = os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_MODEL", "").strip() or local_model
|
|
if local_reviewer and os.environ.get("CASAN_GOAL_ENABLE_LOCAL_REVIEWER", "1") == "1":
|
|
candidates.append({"kind": "model", "provider": os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_PROVIDER", os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy")), "model": local_reviewer, "value": local_reviewer})
|
|
|
|
unique, seen = [], set()
|
|
for candidate in candidates:
|
|
key = (candidate["kind"], candidate["value"])
|
|
if key not in seen:
|
|
seen.add(key)
|
|
unique.append(candidate)
|
|
return unique
|
|
|
|
|
|
def run_reviewer_chain(job_path: str, prompt: str, account_provider: str, cloud_model: str, local_model: str, max_output_tokens=None, excluded_models=None):
|
|
max_attempts = max(1, min(int(os.environ.get("CASAN_GOAL_REVIEWER_MAX_ATTEMPTS", "5")), 10))
|
|
deadline_seconds = max(1, min(int(os.environ.get("CASAN_GOAL_REVIEWER_DEADLINE_SEC", "360")), 900))
|
|
deadline = time.monotonic() + deadline_seconds
|
|
ledger = []
|
|
update_job(job_path, reviewer_attempts=ledger)
|
|
last_reason = "reviewer_candidates_unavailable"
|
|
excluded = set(excluded_models or [])
|
|
candidates = [candidate for candidate in reviewer_candidates(account_provider, cloud_model, local_model) if candidate.get("value") not in excluded]
|
|
local_reviewer_provider = os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_PROVIDER", os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"))
|
|
if len(candidates) > max_attempts and candidates[-1].get("provider") == local_reviewer_provider:
|
|
candidates = candidates[:max_attempts - 1] + [candidates[-1]] if max_attempts > 1 else [candidates[-1]]
|
|
else:
|
|
candidates = candidates[:max_attempts]
|
|
for candidate in candidates:
|
|
remaining = int(deadline - time.monotonic())
|
|
if remaining <= 0:
|
|
last_reason = "reviewer_deadline_exceeded"
|
|
break
|
|
attempt_number = len(ledger) + 1
|
|
stage(job_path, "cloud-reviewer", "running", f"Reviewer attempt {attempt_number}/{max_attempts}", candidate["provider"], candidate["model"])
|
|
started_at, started_clock = now(), time.monotonic()
|
|
if candidate["kind"] == "model" and max_output_tokens is not None:
|
|
healthy, preflight_reason = model_preflight(candidate["value"])
|
|
if not healthy:
|
|
reason = f"model_preflight_failed:{preflight_reason}"
|
|
ledger.append({
|
|
"attempt": attempt_number, "provider": candidate["provider"], "model": candidate["model"],
|
|
"status": "failed", "reason": reason, "retryable": True,
|
|
"started_at": started_at, "finished_at": now(),
|
|
"latency_ms": int((time.monotonic() - started_clock) * 1000),
|
|
})
|
|
update_job(job_path, reviewer_attempts=ledger)
|
|
last_reason = reason
|
|
continue
|
|
if candidate["kind"] == "account":
|
|
ok, result, metadata, reason = call_account_model(candidate["value"], prompt, remaining)
|
|
else:
|
|
if max_output_tokens is None:
|
|
ok, result, metadata, reason = call_model(candidate["value"], prompt, candidate["provider"] != local_reviewer_provider, remaining)
|
|
else:
|
|
ok, result, metadata, reason = call_model(candidate["value"], prompt, candidate["provider"] != local_reviewer_provider, remaining, max_output_tokens=max_output_tokens)
|
|
retryable = False if ok else reviewer_failure_retryable(reason)
|
|
ledger.append({
|
|
"attempt": attempt_number, "provider": candidate["provider"], "model": candidate["model"],
|
|
"status": "pass" if ok else "failed", "reason": reason, "retryable": retryable,
|
|
"started_at": started_at, "finished_at": now(),
|
|
"latency_ms": int((time.monotonic() - started_clock) * 1000),
|
|
})
|
|
update_job(job_path, reviewer_attempts=ledger)
|
|
if ok:
|
|
return True, result, metadata, "ok", candidate
|
|
last_reason = reason
|
|
if not retryable and any(marker in str(reason).lower() for marker in ("security_blocked", "content_policy")):
|
|
break
|
|
return False, "", {}, last_reason, (ledger[-1] if ledger else {})
|
|
|
|
|
|
def audit(job: dict, status: str) -> str:
|
|
path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator.jsonl")
|
|
head_path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator-head.txt")
|
|
lock_path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator.lock")
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(lock_path, "a", encoding="utf-8") as lock:
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
previous = "0" * 64
|
|
try:
|
|
with open(head_path, encoding="utf-8") as handle:
|
|
previous = handle.read().strip() or previous
|
|
except OSError:
|
|
pass
|
|
core = {
|
|
"timestamp": now(),
|
|
"harness": "H5-governance",
|
|
"goal_id": job["id"],
|
|
"tenant": job.get("tenant", "default"),
|
|
"actor": job.get("actor", "unknown"),
|
|
"goal_hash": sha(str(job.get("goal", ""))),
|
|
"status": status,
|
|
"local_provider": job.get("local_provider", ""),
|
|
"cloud_provider": job.get("cloud_provider", ""),
|
|
"prev_hash": previous,
|
|
}
|
|
record_hash = sha(json.dumps(core, sort_keys=True, ensure_ascii=False))
|
|
append_jsonl(path, {**core, "record_hash": record_hash})
|
|
with open(head_path, "w", encoding="utf-8") as handle:
|
|
handle.write(record_hash + "\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.chmod(head_path, 0o600)
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
return record_hash
|
|
|
|
|
|
def metric(job: dict, status: str, started: float, local_meta: dict, cloud_meta: dict) -> None:
|
|
metrics_path = os.environ.get(
|
|
"CASAN_TELEMETRY_METRICS_LOG",
|
|
os.environ.get("CASAN_DASHBOARD_METRICS", os.path.join(STATE_ROOT, "logs", "cost", "metrics.jsonl")),
|
|
)
|
|
append_jsonl(metrics_path, {
|
|
"timestamp": now(),
|
|
"trace_id": job["id"],
|
|
"harness": "H6-agentops",
|
|
"agent": "goal.orchestrator",
|
|
"step": "local-worker-cloud-reviewer",
|
|
"status": status,
|
|
"exit_code": 0 if status in {"success", "degraded"} else 2,
|
|
"latency_ms": int((time.monotonic() - started) * 1000),
|
|
"input_tokens": int(local_meta.get("input_tokens", 0)) + int(cloud_meta.get("input_tokens", 0)),
|
|
"output_tokens": int(local_meta.get("output_tokens", 0)) + int(cloud_meta.get("output_tokens", 0)),
|
|
"total_tokens": sum(int(meta.get(key, 0)) for meta in (local_meta, cloud_meta) for key in ("input_tokens", "output_tokens")),
|
|
"cost_estimate": 0.0,
|
|
"cost_source": "provider_usage_logs",
|
|
"input_hash": sha(str(job.get("goal", ""))),
|
|
"output_hash": sha(str(job.get("result", ""))),
|
|
})
|
|
|
|
|
|
def run(job_path: str) -> int:
|
|
started = time.monotonic()
|
|
job = load_json(job_path)
|
|
goal_id = str(job["id"])
|
|
goal = str(job.get("goal") or "").strip()
|
|
local_model = os.environ.get("CASAN_GOAL_LOCAL_MODEL", "")
|
|
cloud_model = os.environ.get("CASAN_GOAL_CLOUD_MODEL", "")
|
|
account_provider = os.environ.get("CASAN_GOAL_ACCOUNT_PROVIDER", "")
|
|
local_meta, cloud_meta = {}, {}
|
|
try:
|
|
update_job(job_path, status="running", started_at=now())
|
|
emit(goal_id, "H1-context", "running", "Validating objective contract")
|
|
if len(goal) < 10 or len(goal) > 8000:
|
|
raise ValueError("goal_length_invalid")
|
|
project_id = str(job.get("project") or "")
|
|
|
|
emit(goal_id, "H4-security", "running", "Scanning objective before model routing")
|
|
allowed, safe_goal = scan(goal, "input")
|
|
if not allowed:
|
|
emit(goal_id, "H4-security", "blocked", "Objective rejected by security boundary")
|
|
raise ValueError("goal_security_blocked")
|
|
write_intent = requests_side_effect(safe_goal)
|
|
context_bundle, context_manifest, context_manifest_path = build_context(job_path, project_id, safe_goal, write_intent)
|
|
context_summary = {
|
|
"files": context_manifest["file_count"],
|
|
"characters": context_manifest["characters"],
|
|
"truncated": context_manifest["truncated"],
|
|
"path": context_manifest_path,
|
|
}
|
|
update_job(job_path, context_manifest=context_summary)
|
|
emit(goal_id, "H1-context", "pass", "Allowlisted workspace snapshot prepared", {
|
|
"goal_hash": sha(goal), "project_id": project_id, **context_summary,
|
|
"bundle_sha256": context_manifest["bundle_sha256"],
|
|
})
|
|
emit(goal_id, "H4-security", "running", "Objective and workspace snapshot passed; model outputs pending")
|
|
|
|
local_healthy, local_preflight_reason = model_preflight(local_model)
|
|
if not local_healthy:
|
|
stage(job_path, "local-worker", "error", f"model_preflight_failed:{local_preflight_reason}", job.get("local_provider", ""), local_model)
|
|
emit(goal_id, "H2-tool", "error", "Local worker failed patch-generation preflight", {"provider": job.get("local_provider", ""), "model": local_model, "reason": local_preflight_reason})
|
|
raise RuntimeError(f"local_worker_preflight_failed:{local_preflight_reason}")
|
|
|
|
stage(job_path, "local-worker", "running", "Local model is developing the primary solution", job.get("local_provider", ""), local_model)
|
|
emit(goal_id, "H2-tool", "running", "Local worker is developing a solution", {"provider": job.get("local_provider", ""), "model": local_model})
|
|
output_contract = (
|
|
"Produce ONLY one complete unified git diff inside a ```diff fence. The diff must implement the objective, include every required file, and may only touch paths visible in the workspace snapshot. Do not include commentary outside the diff. "
|
|
if write_intent else
|
|
"Produce: clarified outcome, assumptions, ordered implementation plan, risks, and verifiable acceptance checks. Respond in the same language as the objective. "
|
|
)
|
|
local_prompt = (
|
|
"You are the local CASAN worker. Solve the user's objective concretely. " + output_contract +
|
|
"Use only the bounded, redacted workspace snapshot below as repository evidence. "
|
|
"Do not claim to inspect any filesystem outside this snapshot and do not perform side effects.\n\n"
|
|
f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}"
|
|
)
|
|
if local_model.startswith("account:"):
|
|
ok, local_draft, local_meta, reason = call_account_model(local_model.split(":", 1)[1], local_prompt, 300)
|
|
else:
|
|
ok, local_draft, local_meta, reason = call_model(
|
|
local_model,
|
|
local_prompt,
|
|
False,
|
|
max_output_tokens=patch_output_tokens() if write_intent else None,
|
|
)
|
|
if not ok:
|
|
stage(job_path, "local-worker", "error", reason, job.get("local_provider", ""), local_model)
|
|
emit(goal_id, "H2-tool", "error", "Local worker failed", {"reason": reason})
|
|
raise RuntimeError(f"local_worker_failed:{reason}")
|
|
allowed, safe_local = scan(local_draft, "output")
|
|
if not allowed:
|
|
emit(goal_id, "H4-security", "blocked", "Local worker output rejected")
|
|
raise ValueError("local_output_security_blocked")
|
|
if write_intent:
|
|
try:
|
|
safe_local = validate_write_output(safe_local)
|
|
except ValueError as first_error:
|
|
repair_candidates = patch_repair_models(local_model)[:patch_repair_attempts()]
|
|
repair_model = repair_candidates[0] if repair_candidates else local_model
|
|
repair_provider = provider_for_model(repair_model)
|
|
stage(job_path, "local-worker", "running", "Repairing invalid patch output contract", repair_provider, repair_model)
|
|
emit(goal_id, "H2-tool", "running", "Worker is repairing patch output contract", {"reason": str(first_error), "max_attempts": patch_repair_attempts(), "repair_provider": repair_provider, "repair_model": repair_model, "repair_candidates": repair_candidates})
|
|
repaired, repaired_output, repaired_meta, repair_reason = repair_write_output(local_model, local_prompt, safe_local, str(first_error))
|
|
local_meta = merge_usage(local_meta, repaired_meta)
|
|
if not repaired:
|
|
reason = repair_reason
|
|
else:
|
|
allowed, safe_local = scan(repaired_output, "output")
|
|
if not allowed:
|
|
emit(goal_id, "H4-security", "blocked", "Repaired local worker output rejected")
|
|
raise ValueError("local_output_security_blocked")
|
|
try:
|
|
safe_local = validate_write_output(safe_local)
|
|
reason = ""
|
|
except ValueError as repair_error:
|
|
reason = f"goal_patch_repair_invalid:{repair_error}"
|
|
if reason:
|
|
actual_repair_model = str(repaired_meta.get("repair_model") or repair_model)
|
|
update_job(job_path, local_usage=local_meta, patch_repair_attempts=repaired_meta.get("repair_attempts", []))
|
|
stage(job_path, "local-worker", "error", reason, provider_for_model(actual_repair_model), actual_repair_model)
|
|
emit(goal_id, "H2-tool", "error", "Worker violated patch output contract after repair", {"reason": reason, "repair_provider": provider_for_model(actual_repair_model), "repair_model": actual_repair_model, "repair_attempts": repaired_meta.get("repair_attempts", [])})
|
|
raise ValueError(reason)
|
|
effective_local_model = str(local_meta.get("repair_model") or local_model)
|
|
effective_local_provider = provider_for_model(effective_local_model)
|
|
stage(job_path, "local-worker", "pass", "Primary solution prepared", effective_local_provider, effective_local_model)
|
|
update_job(
|
|
job_path,
|
|
local_draft=safe_local,
|
|
local_usage=local_meta,
|
|
patch_repair_attempts=local_meta.get("repair_attempts", []),
|
|
effective_local_provider=effective_local_provider,
|
|
effective_local_model=effective_local_model,
|
|
)
|
|
emit(goal_id, "H2-tool", "pass", "Primary solution prepared", {"provider": effective_local_provider, "model": effective_local_model, **local_meta})
|
|
|
|
stage(job_path, "cloud-reviewer", "running", "Independent reviewer is challenging and improving the local solution", job.get("cloud_provider", ""), cloud_model)
|
|
emit(goal_id, "H3-eval", "running", "Cloud reviewer is evaluating the local solution", {"provider": job.get("cloud_provider", ""), "model": cloud_model})
|
|
reviewer_contract = (
|
|
"Return ONLY the corrected, complete unified git diff inside a ```diff fence. Preserve valid implementation details, fix defects, and include no prose or `index` lines outside the diff. Every hunk must be complete and pass `git apply --check`. "
|
|
if write_intent else
|
|
"Return one final actionable solution with ordered steps and acceptance checks. Respond in the same language as the objective. "
|
|
)
|
|
review_prompt = (
|
|
"You are the cloud CASAN reviewer. Critically review the local worker's proposal "
|
|
"against the objective. Correct gaps, remove unsafe or unverifiable claims, and "
|
|
+ reviewer_contract + "Use only the exact bounded, redacted "
|
|
"workspace snapshot provided below; your execution directory is intentionally empty. "
|
|
"Do not inspect or infer from any other filesystem and do not perform side effects.\n\n"
|
|
f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}\n\n"
|
|
f"LOCAL WORKER PROPOSAL:\n{safe_local[:5000]}"
|
|
)
|
|
cloud_ok, cloud_result, cloud_meta, cloud_reason, reviewer = run_reviewer_chain(
|
|
job_path,
|
|
review_prompt,
|
|
account_provider,
|
|
cloud_model,
|
|
local_model,
|
|
max_output_tokens=patch_output_tokens() if write_intent else None,
|
|
excluded_models={effective_local_model},
|
|
)
|
|
reviewer_provider = str(reviewer.get("provider") or job.get("cloud_provider", ""))
|
|
reviewer_model = str(reviewer.get("model") or cloud_model)
|
|
if cloud_ok:
|
|
allowed, safe_result = scan(cloud_result, "output")
|
|
if not allowed:
|
|
emit(goal_id, "H4-security", "blocked", "Cloud reviewer output rejected")
|
|
raise ValueError("cloud_output_security_blocked")
|
|
if write_intent:
|
|
try:
|
|
safe_result = validate_write_output(safe_result)
|
|
except ValueError as error:
|
|
cloud_ok = False
|
|
cloud_reason = f"reviewer_output_contract_invalid:{error}"
|
|
if cloud_ok:
|
|
stage(job_path, "cloud-reviewer", "pass", "Independent review incorporated", reviewer_provider, reviewer_model)
|
|
emit(goal_id, "H3-eval", "pass", "Independent review incorporated", {"provider": reviewer_provider, "model": reviewer_model, **cloud_meta})
|
|
final_status = "completed"
|
|
metric_status = "success"
|
|
else:
|
|
safe_result = safe_local
|
|
stage(job_path, "cloud-reviewer", "warning", cloud_reason, reviewer_provider, reviewer_model)
|
|
emit(goal_id, "H3-eval", "warning", "Cloud reviewer output contract rejected; local solution retained", {"reason": cloud_reason})
|
|
final_status = "degraded"
|
|
metric_status = "degraded"
|
|
else:
|
|
safe_result = safe_local
|
|
stage(job_path, "cloud-reviewer", "warning", cloud_reason, reviewer_provider, reviewer_model)
|
|
emit(goal_id, "H3-eval", "warning", "Cloud reviewer unavailable; local solution retained", {"reason": cloud_reason})
|
|
final_status = "degraded"
|
|
metric_status = "degraded"
|
|
|
|
emit(goal_id, "H4-security", "pass", "Objective and all released outputs passed security scans")
|
|
if write_intent:
|
|
patch = extract_patch(safe_result)
|
|
patch_artifact = validate_and_store_patch(job_path, load_json(job_path), patch)
|
|
proposal = submit_side_effect(load_json(job_path), context_manifest, patch_artifact)
|
|
approval = {"id": proposal["id"], "status": proposal["status"], "action": proposal["action"]}
|
|
patch_artifact["approval_id"] = proposal["id"]
|
|
final_status = "requires_approval"
|
|
metric_status = "degraded"
|
|
if cloud_ok:
|
|
safe_result = "Implementation patch generated and independently reviewed. Approval is required before applying it to the workspace."
|
|
wait_detail = "Independently reviewed patch awaits approval"
|
|
else:
|
|
safe_result = "Implementation patch generated and validated. Automated H3 review was unavailable, so Independent Reviewer approval is required before applying it to the workspace."
|
|
wait_detail = "Validated patch awaits Independent Reviewer approval after H3 degradation"
|
|
emit(goal_id, "H7-orchestration", "blocked", wait_detail, {"proposal_id": proposal["id"], "patch_sha256": patch_artifact["sha256"], "cloud_incorporated": cloud_ok})
|
|
job = update_job(job_path, status=final_status, result=safe_result, patch_artifact=patch_artifact, approval=approval, cloud_usage=cloud_meta, finished_at=now())
|
|
else:
|
|
job = update_job(job_path, status=final_status, result=safe_result, cloud_usage=cloud_meta, finished_at=now())
|
|
emit(goal_id, "H5-governance", "running", "Anchoring orchestration decision")
|
|
audit_hash = audit(job, final_status)
|
|
emit(goal_id, "H5-governance", "pass", "Orchestration decision anchored", {"audit_hash": audit_hash, "status": final_status})
|
|
emit(goal_id, "H6-agentops", "running", "Recording orchestration telemetry")
|
|
metric(job, metric_status, started, local_meta, cloud_meta)
|
|
emit(goal_id, "H6-agentops", "pass", "Orchestration telemetry recorded", {"latency_ms": int((time.monotonic() - started) * 1000), "status": metric_status})
|
|
if not write_intent:
|
|
emit(goal_id, "H7-orchestration", "pass", "Local worker and cloud review workflow completed", {"status": final_status, "cloud_incorporated": cloud_ok})
|
|
update_job(job_path, audit_hash=audit_hash)
|
|
return 0
|
|
except Exception as error:
|
|
reason = str(error)[:120] or error.__class__.__name__
|
|
update_job(job_path, status="failed", error=reason, finished_at=now())
|
|
emit(goal_id, "H5-governance", "running", "Anchoring failed orchestration decision")
|
|
job = load_json(job_path)
|
|
audit_hash = audit(job, "failed")
|
|
emit(goal_id, "H5-governance", "pass", "Failed decision anchored", {"audit_hash": audit_hash})
|
|
# Keep gate semantics distinct. H2 may report a patch-contract failure,
|
|
# while H6/H7 must report their own lifecycle state and retain the H2
|
|
# error only as causal evidence. Reusing `reason` here made the UI show
|
|
# goal_patch_missing as if telemetry and orchestration independently
|
|
# failed the patch contract.
|
|
emit(goal_id, "H6-agentops", "error", "goal_orchestration_failed", {"root_reason": reason, "latency_ms": int((time.monotonic() - started) * 1000)})
|
|
emit(goal_id, "H7-orchestration", "blocked", "goal_workflow_stopped", {"root_reason": reason})
|
|
update_job(job_path, audit_hash=audit_hash)
|
|
return 2
|
|
|
|
|
|
def verify_audit() -> int:
|
|
path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator.jsonl")
|
|
previous = "0" * 64
|
|
records = 0
|
|
try:
|
|
handle = open(path, encoding="utf-8")
|
|
except OSError:
|
|
print(json.dumps({"ok": True, "records": 0, "head": previous}))
|
|
return 0
|
|
with handle:
|
|
for line in handle:
|
|
if not line.strip():
|
|
continue
|
|
records += 1
|
|
try:
|
|
record = json.loads(line)
|
|
except ValueError:
|
|
print(json.dumps({"ok": False, "records": records, "reason": "invalid_json"}))
|
|
return 3
|
|
record_hash = str(record.pop("record_hash", ""))
|
|
expected = sha(json.dumps(record, sort_keys=True, ensure_ascii=False))
|
|
if record.get("prev_hash") != previous or record_hash != expected:
|
|
print(json.dumps({"ok": False, "records": records, "reason": "chain_break"}))
|
|
return 3
|
|
previous = record_hash
|
|
print(json.dumps({"ok": True, "records": records, "head": previous}))
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument("--job-file")
|
|
group.add_argument("--verify-audit", action="store_true")
|
|
args = parser.parse_args()
|
|
if args.verify_audit:
|
|
return verify_audit()
|
|
path = os.path.abspath(args.job_file)
|
|
state = os.path.abspath(STATE_ROOT) + os.sep
|
|
if not path.startswith(state):
|
|
raise SystemExit("GOAL_JOB_PATH_DENIED")
|
|
return run(path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|