diff --git a/.opencode/agents/reflection-agent.md b/.opencode/agents/reflection-agent.md index 725a0424..c6366051 100644 --- a/.opencode/agents/reflection-agent.md +++ b/.opencode/agents/reflection-agent.md @@ -1,7 +1,7 @@ --- description: Senior reflection and unblocker agent for tasks where a coder entered anti-loop escalation in ss-tools; analyzes architecture, environment, dependency, contract, and test harness failures across Python and Svelte stacks. mode: subagent -model: opencode-go/deepseek-v4-flash +model: opencode-go/kimi-k2.6 temperature: 0.0 permission: edit: allow diff --git a/backend/src/plugins/translate/__tests__/test_orthogonal_fixes.py b/backend/src/plugins/translate/__tests__/test_orthogonal_fixes.py index 5986e3e4..f547223e 100644 --- a/backend/src/plugins/translate/__tests__/test_orthogonal_fixes.py +++ b/backend/src/plugins/translate/__tests__/test_orthogonal_fixes.py @@ -223,6 +223,38 @@ class TestCancelRunOrthogonal: orch.cancel_run("run-ghost") # endregion test_cancel_run_lock_timeout_nonexistent_after_flag + # region test_cancel_run_flush_lock_timeout_fallback [C:2] [TYPE Function] + # @BRIEF If row lock happens during flush, fallback sets CANCEL_REQUESTED and does not raise 500. + def test_cancel_run_flush_lock_timeout_fallback(self) -> None: + from sqlalchemy.exc import OperationalError + + db = MagicMock() + config_manager = MagicMock() + + locked_run = MagicMock(spec=TranslationRun) + locked_run.id = "run-flush-lock-1" + locked_run.job_id = "job-1" + locked_run.status = "RUNNING" + locked_run.error_message = None + + flagged_run = MagicMock(spec=TranslationRun) + flagged_run.id = "run-flush-lock-1" + flagged_run.job_id = "job-1" + flagged_run.status = "RUNNING" + flagged_run.error_message = "CANCEL_REQUESTED" + + db.query.return_value.filter.return_value.first.side_effect = [locked_run, flagged_run] + db.flush.side_effect = OperationalError("lock timeout", {}, None) + + orch = TranslationOrchestrator(db, config_manager, "test-user") + result = orch.cancel_run("run-flush-lock-1") + + db.rollback.assert_called_once() + assert db.execute.call_count >= 2, "Expected SET LOCAL and fallback UPDATE to run" + assert result.error_message == "CANCEL_REQUESTED" + assert result.status == "RUNNING" + # endregion test_cancel_run_flush_lock_timeout_fallback + # region test_cancel_run_failed_status_rejected [C:2] [TYPE Function] # @BRIEF FAILED runs cannot be cancelled (only PENDING/RUNNING). def test_cancel_run_failed_status_rejected(self) -> None: diff --git a/backend/src/plugins/translate/executor.py b/backend/src/plugins/translate/executor.py index 137c58bc..ca96ef6c 100644 --- a/backend/src/plugins/translate/executor.py +++ b/backend/src/plugins/translate/executor.py @@ -174,6 +174,7 @@ class TranslationExecutor: run.status = "COMPLETED" elif s == 0: run.status = "FAILED" + run.error_message = f"All {f} record(s) failed — see individual record errors for details" else: run.status = "COMPLETED" run.completed_at = datetime.now(UTC) diff --git a/backend/src/plugins/translate/orchestrator_cancel.py b/backend/src/plugins/translate/orchestrator_cancel.py index 508355c2..8c42d7f2 100644 --- a/backend/src/plugins/translate/orchestrator_cancel.py +++ b/backend/src/plugins/translate/orchestrator_cancel.py @@ -18,6 +18,24 @@ from .events import TranslationEventLog from .orchestrator_sql import SQLInsertService +def _fallback_cancel_request(db: Session, run_id: str) -> TranslationRun: + """Set cancellation flag when row lock prevents direct status update.""" + try: + db.rollback() + except Exception: + pass + logger.explore("Row lock timeout — setting cancellation flag via direct SQL", {"run_id": run_id}) + db.execute( + text("UPDATE translation_runs SET error_message = 'CANCEL_REQUESTED' WHERE id = :run_id"), + {"run_id": run_id}, + ) + db.commit() + run = db.query(TranslationRun).filter(TranslationRun.id == run_id).first() + if not run: + raise ValueError(f"Run '{run_id}' not found") + return run + + # #region cancel_run [C:3] [TYPE Function] [SEMANTICS translate, cancel, run] # @BRIEF Cancel a running translation run. # @SIDE_EFFECT DB writes; event log. @@ -35,16 +53,7 @@ def cancel_run( db.execute(text("SET LOCAL lock_timeout = '3s'")) run = db.query(TranslationRun).filter(TranslationRun.id == run_id).first() except Exception: - logger.explore("Row lock timeout — setting cancellation flag via direct SQL", {"run_id": run_id}) - db.execute( - text("UPDATE translation_runs SET error_message = 'CANCEL_REQUESTED' WHERE id = :run_id"), - {"run_id": run_id}, - ) - db.commit() - run = db.query(TranslationRun).filter(TranslationRun.id == run_id).first() - if not run: - raise ValueError(f"Run '{run_id}' not found") - return run + return _fallback_cancel_request(db, run_id) if not run: raise ValueError(f"Run '{run_id}' not found") @@ -56,7 +65,10 @@ def cancel_run( run.status = "CANCELLED" run.completed_at = datetime.now(UTC) - db.flush() + try: + db.flush() + except Exception: + return _fallback_cancel_request(db, run_id) event_log.log_event( job_id=run.job_id, run_id=run.id, event_type="RUN_CANCELLED", payload={}, created_by=current_user, diff --git a/backend/src/plugins/translate/scheduler.py b/backend/src/plugins/translate/scheduler.py index 62d25dd0..bb069daa 100644 --- a/backend/src/plugins/translate/scheduler.py +++ b/backend/src/plugins/translate/scheduler.py @@ -381,7 +381,14 @@ def execute_scheduled_translation( # Execute in same thread (APScheduler runs in background thread pool) try: orch.execute_run(run) - run.insert_status = run.insert_status or "succeeded" + if run.status == "FAILED": + logger.explore("Scheduled translation completed with all records failed", { + "run_id": run.id, + "error": run.error_message, + }) + run.insert_status = run.insert_status or None + else: + run.insert_status = run.insert_status or "succeeded" except Exception as exec_err: logger.explore("Scheduled translation execution failed", { "run_id": run.id, diff --git a/docker-compose.yml b/docker-compose.yml index de0030be..a0f6e976 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,7 @@ services: depends_on: - backend ports: - - "8100:80" + - "${FRONTEND_HOST_PORT:-8000}:80" volumes: postgres_data: diff --git a/docker/nginx.conf b/docker/nginx.conf index b67ada1e..d22442b9 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -1,6 +1,7 @@ server { listen 80; server_name _; + resolver 127.0.0.11 ipv6=off; root /usr/share/nginx/html; index index.html; @@ -10,7 +11,8 @@ server { } location /api/ { - proxy_pass http://backend:8000/api/; + set $backend_api http://backend:8000; + proxy_pass $backend_api; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -20,7 +22,8 @@ server { } location /ws/ { - proxy_pass http://backend:8000/ws/; + set $backend_ws http://backend:8000; + proxy_pass $backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; diff --git a/frontend/src/lib/components/translate/TranslationRunProgress.svelte b/frontend/src/lib/components/translate/TranslationRunProgress.svelte index 933d03b2..b4fea8ff 100644 --- a/frontend/src/lib/components/translate/TranslationRunProgress.svelte +++ b/frontend/src/lib/components/translate/TranslationRunProgress.svelte @@ -13,130 +13,48 @@