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 9676e56..5cb20ca 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.service.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts @@ -175,6 +175,11 @@ export class GoalsService { const gatewayModels = gateway ? [gateway.defaultModel, ...gateway.models].filter((model, index, rows) => Boolean(model) && rows.indexOf(model) === index).slice(0, 5).map((model) => `openai-compatible:${model}`) : []; + // A gateway is useful for conversational fallback, but code-patch repair + // must not silently change provider/model after a direct OpenAI repair + // fails. Gateways can have their own transport and model-specific output + // contracts; an operator can explicitly opt in after validating one. + const gatewayPatchRepairModels = process.env.CASAN_GOAL_ENABLE_GATEWAY_PATCH_REPAIR === '1' ? gatewayModels : []; const preferredCloudModel = cloudCandidates[0]?.model || ''; const preferredCloudProvider = preferredCloudModel.startsWith('openai:') ? 'openai' : preferredCloudModel.startsWith('anthropic:') ? 'anthropic' : (cloud?.id || 'unavailable'); const cloudModel = preferredCloudModel || gateway?.defaultModel || gateway?.models[0] || ''; @@ -222,8 +227,9 @@ export class GoalsService { CASAN_GOAL_CLOUD_FALLBACK_MODEL: String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''), CASAN_GOAL_CLOUD_MODELS: cloudCandidates.map(({ model }) => model).join(','), CASAN_GOAL_OMNIROUTE_MODELS: gatewayModels.join(','), - // H2 recovery tries direct cloud credentials before gateway and local. - CASAN_GOAL_PATCH_REPAIR_MODELS: [...cloudCandidates.map(({ model }) => model), ...gatewayModels, String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`)].filter((model, index, rows) => rows.indexOf(model) === index).join(','), + // H2 repair stays on the direct provider by default. OmniRoute is an + // explicit opt-in because it must not disguise a Codex patch failure. + CASAN_GOAL_PATCH_REPAIR_MODELS: [...cloudCandidates.map(({ model }) => model), ...gatewayPatchRepairModels, String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`)].filter((model, index, rows) => rows.indexOf(model) === index).join(','), CASAN_GOAL_LOCAL_REVIEWER_MODEL: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`), CASAN_GOAL_REVIEWER_MAX_ATTEMPTS: process.env.CASAN_GOAL_REVIEWER_MAX_ATTEMPTS || '8', CASAN_GOAL_REVIEWER_DEADLINE_SEC: process.env.CASAN_GOAL_REVIEWER_DEADLINE_SEC || '600', diff --git a/packages/casan-harness/scripts/bash/goal-orchestrator.py b/packages/casan-harness/scripts/bash/goal-orchestrator.py index 49fe7a7..38008ab 100644 --- a/packages/casan-harness/scripts/bash/goal-orchestrator.py +++ b/packages/casan-harness/scripts/bash/goal-orchestrator.py @@ -349,8 +349,13 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c 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 "corrupt patch" in error.lower() else "" + ) return ( - f"Your previous response violated the required write-output contract: {error}. " + 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]}" @@ -390,7 +395,7 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c # 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") and candidates.count(candidate) == 1: + if candidate.endswith("-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) diff --git a/packages/casan-harness/tests/goal-patch-workflow-tests.py b/packages/casan-harness/tests/goal-patch-workflow-tests.py index 60a0575..7272e66 100644 --- a/packages/casan-harness/tests/goal-patch-workflow-tests.py +++ b/packages/casan-harness/tests/goal-patch-workflow-tests.py @@ -122,6 +122,17 @@ class GoalPatchWorkflowTests(unittest.TestCase): self.assertEqual([item.args[0] for item in call.call_args_list], ["openai:gpt-5.3-codex", "openai:gpt-5.3-codex"]) self.assertEqual(usage["repair_attempts"][-1]["status"], "pass") + def test_codex_keeps_all_repair_attempts_when_its_patch_headers_remain_corrupt(self): + with ( + patch.dict(os.environ, {"CASAN_GOAL_PATCH_REPAIR_ATTEMPTS": "3", "CASAN_GOAL_PATCH_REPAIR_MODELS": "openai:gpt-5.3-codex,ollama:ornith"}, clear=False), + patch.object(ORCHESTRATOR, "call_model", return_value=(True, "corrupt", {}, "ok")) as call, + patch.object(ORCHESTRATOR, "validate_write_output", side_effect=ValueError("goal_patch_check_failed:error: corrupt patch at line 125")), + ): + ok, _, usage, _ = ORCHESTRATOR.repair_write_output("ollama:ornith", "original", "invalid") + self.assertFalse(ok) + self.assertEqual([item.args[0] for item in call.call_args_list], ["openai:gpt-5.3-codex"] * 3) + self.assertEqual([attempt["provider"] for attempt in usage["repair_attempts"]], ["openai"] * 3) + 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"