diff --git a/packages/casan-control-panel/backend/src/goals/goals.service.ts b/packages/casan-control-panel/backend/src/goals/goals.service.ts
index aae9f59..9676e56 100644
--- a/packages/casan-control-panel/backend/src/goals/goals.service.ts
+++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts
@@ -59,6 +59,7 @@ export interface GoalJob {
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string };
reviewer_attempts?: GoalReviewerAttempt[];
+ patch_repair_attempts?: GoalPatchRepairAttempt[];
patch_artifact?: { path: string; sha256: string; files: string[]; bytes: number; status: string; preview?: string; approval_id?: string; applied_at?: string; applied_by?: string };
verification?: Array<{ command: string; exit_code: number; output: string }>;
}
@@ -75,6 +76,14 @@ export interface GoalReviewerAttempt {
latency_ms: number;
}
+export interface GoalPatchRepairAttempt {
+ attempt: number;
+ provider: string;
+ model: string;
+ status: 'pass' | 'failed';
+ reason: string;
+}
+
interface ModelConnection {
id: string;
kind: 'local' | 'cloud' | 'gateway';
diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts
index 9a9a2ee..f78a27d 100644
--- a/packages/casan-control-panel/frontend/src/lib/api.ts
+++ b/packages/casan-control-panel/frontend/src/lib/api.ts
@@ -313,6 +313,7 @@ export interface GoalJob {
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string };
reviewer_attempts?: GoalReviewerAttempt[];
+ patch_repair_attempts?: GoalPatchRepairAttempt[];
patch_artifact?: { path: string; sha256: string; files: string[]; bytes: number; status: string; preview?: string; approval_id?: string; applied_at?: string; applied_by?: string };
verification?: Array<{ command: string; exit_code: number; output: string }>;
}
@@ -329,6 +330,14 @@ export interface GoalReviewerAttempt {
latency_ms: number;
}
+export interface GoalPatchRepairAttempt {
+ attempt: number;
+ provider: string;
+ model: string;
+ status: 'pass' | 'failed';
+ reason: string;
+}
+
export interface GoalProject {
project_id: string;
domain: string;
diff --git a/packages/casan-control-panel/frontend/src/pages/Goals.tsx b/packages/casan-control-panel/frontend/src/pages/Goals.tsx
index 5ff04cb..65732ce 100644
--- a/packages/casan-control-panel/frontend/src/pages/Goals.tsx
+++ b/packages/casan-control-panel/frontend/src/pages/Goals.tsx
@@ -200,6 +200,7 @@ export function Goals() {
)}
{selected.error &&
{selected.error}
}
+ {selected.patch_repair_attempts && selected.patch_repair_attempts.length > 0 && H2 patch-repair ledger ({selected.patch_repair_attempts.length})
{selected.patch_repair_attempts.map((attempt) =>
Attempt {attempt.attempt}{attempt.provider} · {attempt.model}
{attempt.reason}
)}
}
{selected.patch_artifact && Reviewed implementation patch · {selected.patch_artifact.files.length} file(s)
{selected.patch_artifact.files.map((file) => {file})}
{selected.patch_artifact.preview || 'Patch preview unavailable'}SHA-256 {selected.patch_artifact.sha256}
}
{selected.approval && selected.status === 'requires_approval' && Reviewed patch awaiting approval
Proposal {selected.approval.id} requires an approver different from proposer {selected.actor}.
Open approval inbox
{(approveAndApply.isError || retryApply.isError) &&
{errorMessage(approveAndApply.error || retryApply.error)}
}
}
{selected.verification && selected.verification.length > 0 && Patch applied and verified
{selected.verification.map((check) =>
{check.command}
)}
}
diff --git a/packages/casan-harness/scripts/bash/goal-orchestrator.py b/packages/casan-harness/scripts/bash/goal-orchestrator.py
index f27adb0..7056f52 100644
--- a/packages/casan-harness/scripts/bash/goal-orchestrator.py
+++ b/packages/casan-harness/scripts/bash/goal-orchestrator.py
@@ -299,9 +299,9 @@ def validate_write_output(text: str) -> str:
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", "2"))
+ configured = int(os.environ.get("CASAN_GOAL_PATCH_REPAIR_ATTEMPTS", "3"))
except ValueError:
- configured = 2
+ configured = 3
return min(max(configured, 0), 3)
@@ -356,20 +356,45 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c
)
candidates = patch_repair_models(model)[:patch_repair_attempts()]
last_metadata, last_reason = {}, "goal_patch_missing"
- for candidate in candidates:
+ attempts = []
+ for attempt_number, candidate in enumerate(candidates, start=1):
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:
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,
+ })
continue
+ attempts.append({
+ "attempt": attempt_number,
+ "provider": provider_for_model(candidate),
+ "model": candidate,
+ "status": "pass",
+ "reason": "ok",
+ })
+ metadata["repair_attempts"] = attempts
return True, output, metadata, "ok"
+ last_metadata["repair_attempts"] = attempts
return False, "", last_metadata, f"goal_patch_repair_invalid:{last_reason}"
@@ -785,8 +810,9 @@ def run(job_path: str) -> int:
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})
+ 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)
stage(job_path, "local-worker", "pass", "Primary solution prepared", job.get("local_provider", ""), local_model)
update_job(job_path, local_draft=safe_local, local_usage=local_meta)
diff --git a/packages/casan-harness/scripts/bash/model-call.py b/packages/casan-harness/scripts/bash/model-call.py
index 693ed78..d63aace 100755
--- a/packages/casan-harness/scripts/bash/model-call.py
+++ b/packages/casan-harness/scripts/bash/model-call.py
@@ -67,6 +67,37 @@ def generation_max_tokens(role):
return min(max(configured, 64), 8192)
+def decode_provider_json(response, provider):
+ """Decode a provider response without mislabelling an empty 2xx body.
+
+ Gateways occasionally terminate an upstream stream after accepting a
+ request and return an empty (or HTML) 2xx response. Treating that as a
+ generic JSONDecodeError makes it look like the network failed and obscures
+ the provider that needs attention. The diagnostic deliberately contains
+ only response shape metadata: never response content or credentials.
+ """
+ raw = response.read()
+ content_type = ""
+ headers = getattr(response, "headers", None)
+ if headers is not None:
+ try:
+ content_type = str(headers.get("Content-Type", "")).split(";", 1)[0].lower()
+ except (AttributeError, TypeError):
+ content_type = ""
+ if not raw:
+ fail(f"provider_response_empty {provider} content_type={content_type or 'unknown'}")
+ try:
+ payload = json.loads(raw.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ fail(
+ f"provider_response_invalid {provider} {type(exc).__name__} "
+ f"bytes={len(raw)} content_type={content_type or 'unknown'}"
+ )
+ if not isinstance(payload, dict):
+ fail(f"provider_response_invalid {provider} payload_type={type(payload).__name__}")
+ return payload
+
+
def enforce_call_budget():
"""Fail closed when a run exceeds CASAN_MODEL_MAX_CALLS. The count is tracked in
CASAN_MODEL_CALL_COUNTER_FILE so it spans the many per-step model-call processes
@@ -192,7 +223,7 @@ def call_ollama(model_name, prompt, role):
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
- payload = json.loads(resp.read().decode())
+ payload = decode_provider_json(resp, "ollama")
except Exception as exc: # backend/model failure -> honest non-zero, no fake success
fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}")
latency_ms = int((time.time() - t0) * 1000)
@@ -226,7 +257,7 @@ def call_openai(model_name, prompt, role):
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
- payload = json.loads(resp.read().decode())
+ payload = decode_provider_json(resp, "openai-chat")
except Exception as exc: # honest non-zero, no fake success
fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}")
latency_ms = int((time.time() - t0) * 1000)
@@ -278,7 +309,7 @@ def call_openai_responses(model_name, prompt, role):
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
- payload = json.loads(resp.read().decode())
+ payload = decode_provider_json(resp, "openai-responses")
except Exception as exc:
fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}")
latency_ms = int((time.time() - t0) * 1000)
@@ -345,7 +376,7 @@ def call_openai_compatible(model_name, prompt, role):
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
- payload = json.loads(resp.read().decode())
+ payload = decode_provider_json(resp, "openai-compatible")
except Exception as exc:
fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}")
latency_ms = int((time.time() - t0) * 1000)
@@ -413,7 +444,7 @@ def call_anthropic(model_name, prompt, role):
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
- payload = json.loads(resp.read().decode())
+ payload = decode_provider_json(resp, "anthropic")
except Exception as exc: # honest non-zero, no fake success
fail(f"backend_unreachable {type(exc).__name__}: {str(exc)[:120]}")
latency_ms = int((time.time() - t0) * 1000)
diff --git a/packages/casan-harness/tests/goal-patch-workflow-tests.py b/packages/casan-harness/tests/goal-patch-workflow-tests.py
index 3a97cfa..b2068d7 100644
--- a/packages/casan-harness/tests/goal-patch-workflow-tests.py
+++ b/packages/casan-harness/tests/goal-patch-workflow-tests.py
@@ -89,8 +89,26 @@ class GoalPatchWorkflowTests(unittest.TestCase):
self.assertEqual(output, repaired)
self.assertEqual(reason, "ok")
self.assertEqual(usage["repair_model"], "openai-compatible:aug/claude-sonnet")
+ self.assertEqual(usage["repair_attempts"], [
+ {"attempt": 1, "provider": "openai", "model": "openai:gpt-4.1", "status": "failed", "reason": "goal_patch_check_failed:corrupt"},
+ {"attempt": 2, "provider": "omniroute", "model": "openai-compatible:aug/claude-sonnet", "status": "pass", "reason": "ok"},
+ ])
self.assertEqual([item.args[0] for item in call.call_args_list], ["openai:gpt-4.1", "openai-compatible:aug/claude-sonnet"])
+ def test_repair_records_each_provider_failure_without_hiding_the_primary(self):
+ with (
+ patch.dict(os.environ, {"CASAN_GOAL_PATCH_REPAIR_ATTEMPTS": "2", "CASAN_GOAL_PATCH_REPAIR_MODELS": "openai:gpt-5.3-codex,openai-compatible:gateway"}, clear=False),
+ patch.object(ORCHESTRATOR, "call_model", side_effect=[
+ (False, "", {}, "model_exit_2:MODEL_ROUTER_ERROR provider_response_empty openai-responses content_type=unknown"),
+ (False, "", {}, "model_exit_2:MODEL_ROUTER_ERROR provider_response_invalid openai-compatible JSONDecodeError bytes=0 content_type=unknown"),
+ ]),
+ ):
+ ok, _, usage, reason = ORCHESTRATOR.repair_write_output("ollama:ornith", "original", "plan")
+ self.assertFalse(ok)
+ self.assertIn("openai-compatible", reason)
+ self.assertEqual([entry["provider"] for entry in usage["repair_attempts"]], ["openai", "omniroute"])
+ self.assertIn("openai-responses", usage["repair_attempts"][0]["reason"])
+
def test_patch_outside_workspace_is_denied(self):
job = {"workspace": {"context_roots": ["apps/okr/frontend"]}}
content = "diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -1 +1 @@\n-a\n+b\n"
diff --git a/packages/casan-harness/tests/phase3-model-router-tests.sh b/packages/casan-harness/tests/phase3-model-router-tests.sh
index b53007a..b081991 100755
--- a/packages/casan-harness/tests/phase3-model-router-tests.sh
+++ b/packages/casan-harness/tests/phase3-model-router-tests.sh
@@ -150,6 +150,15 @@ class FakeResp:
def read(self):
return json.dumps(self.payload).encode()
+class EmptyResp:
+ headers = {"Content-Type": "application/json"}
+ def __enter__(self):
+ return self
+ def __exit__(self, exc_type, exc, tb):
+ return False
+ def read(self):
+ return b""
+
def fake_urlopen(req, timeout):
seen.append((req.full_url, dict(req.header_items()), json.loads(req.data.decode())))
if req.full_url.endswith("/v1/responses"):
@@ -207,6 +216,14 @@ for fn, bad in (
assert exc.code == 2, exc.code
else:
raise AssertionError(f"{fn.__name__} accepted malformed provider payload")
+
+try:
+ with contextlib.redirect_stderr(io.StringIO()):
+ mc.decode_provider_json(EmptyResp(), "openai-compatible")
+except SystemExit as exc:
+ assert exc.code == 2, exc.code
+else:
+ raise AssertionError("empty provider response was accepted")
print("ok")
PY
[[ $? -eq 0 ]] && pass "cloud/gateway provider responses parse real usage and reject malformed payloads" || fail "cloud/gateway provider parser coverage failed"