Guide repairs and expose worker progress
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-08-27 23:26:29 +02:00
parent dd86d8cd2d
commit 67ec2131c5
5 changed files with 178 additions and 17 deletions

View File

@@ -68,6 +68,21 @@ Der Default `AUTOMATION_MODE=dry-run` claimt einen offenen Report, erzeugt den
anonymisierten Auftrag und setzt ihn danach wieder auf `open`. Es wird weder ein Agent anonymisierten Auftrag und setzt ihn danach wieder auf `open`. Es wird weder ein Agent
gestartet noch gepusht. gestartet noch gepusht.
## Fortschritt beobachten
```bash
docker compose ps automation
docker compose exec automation lai-agent check
docker compose logs -f automation
```
Der Worker protokolliert nur sichere Metadaten, keine Prompts, SQL-Ergebnisse, Secrets oder
Modellausgaben. Fuer jeden Report erscheinen Workspace-Vorbereitung, Agentenversuch, Anzahl
und Pfade geaenderter Dateien, Policy-Pruefung, jeder Validierungsbefehl, Commit, PR-URL und
Cleanup. Bei laengeren Agenten- oder Testphasen wird alle 30 Sekunden Laufzeitfortschritt
ausgegeben. `agent_running` ohne Heartbeat oder Unterprozess deutet auf einen unterbrochenen
Container hin; normale Claims werden nach `STALE_CLAIM_MINUTES` wieder freigegeben.
## PR-Modus ## PR-Modus
```dotenv ```dotenv

View File

@@ -18,6 +18,33 @@ _SECRET_PATTERNS = (
(re.compile(r"\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), "[JWT]"), (re.compile(r"\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), "[JWT]"),
) )
_REPOSITORY_PLAYBOOK = """
Repository ownership map:
- app/Services/SQLGeneratorService.php assembles live schema and business context for SQL generation.
- config/erp_semantics.php owns ERP joins, metrics, formulas, filters, and reference examples.
- config/cockpit_semantics.php owns device and customer-database semantics.
- app/Services/AIService.php owns provider calls, pseudonymization boundaries, and response parsing.
- app/Services/TenantDatabaseService.php owns ERP/config/customer source selection and tenant metadata.
- app/Services/SQLValidatorService.php owns read-only SQL safety; do not weaken it to accept a fix.
- app/Http/Controllers/ChatController.php orchestrates routing, execution, answers, and diagnostics.
- app/Services/FailureTrackingService.php and FailureAutomationService.php own failure handoff.
- tests/Unit is preferred for deterministic service/context rules; tests/Feature is for HTTP,
persistence, authorization, or real isolated-database behavior.
Repair workflow:
1. Inspect the owning implementation, its configuration, and the nearest existing test first.
2. State one root-cause hypothesis and make the smallest change that can prove it.
3. Put reusable business knowledge in tracked semantics configuration, not question-specific code.
4. A prompt/context fix must test the real generated context. A deterministic SQL builder must test
its exact SQL and execute it against synthetic fixtures when useful.
5. Tests must contain assertions that execute; reject empty loops and broad unrelated fallbacks.
6. Do not expose protected production methods publicly for tests. Use a test-only subclass.
7. Do not duplicate one rule in service code and semantics config, or add explanatory comments for
self-evident test setup.
8. Inspect the final diff for malformed edits, unrelated files, hardcoded report values, and tests
that pass without exercising the changed behavior.
"""
def redact(value: str | None) -> str: def redact(value: str | None) -> str:
result = value or "" result = value or ""
@@ -62,6 +89,8 @@ itself deterministically builds and executes the SQL under test.
return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository. return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository.
{_REPOSITORY_PLAYBOOK}
Rules: Rules:
- First add a regression test that reproduces the reported behavior. - First add a regression test that reproduces the reported behavior.
- The regression test must execute the relevant production behavior; never mock or stub the behavior being tested. - The regression test must execute the relevant production behavior; never mock or stub the behavior being tested.

View File

@@ -3,6 +3,9 @@ from __future__ import annotations
import logging import logging
import signal import signal
import threading import threading
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import asdict from dataclasses import asdict
from .agent import AiderAgent from .agent import AiderAgent
@@ -31,7 +34,13 @@ class AutomationWorker:
LOG.info("No open failure reports") LOG.info("No open failure reports")
return False return False
LOG.info("Claimed failure report %s", report.id) LOG.info(
"Claimed failure report %s category=%s source=%s database_type=%s",
report.id,
report.category,
report.source,
report.context.get("database_type", "unknown"),
)
if self.settings.dry_run: if self.settings.dry_run:
task = build_task(report) task = build_task(report)
LOG.info("Dry-run task for report %s:\n%s", report.id, task) LOG.info("Dry-run task for report %s:\n%s", report.id, task)
@@ -41,13 +50,31 @@ class AutomationWorker:
workspace = GitWorkspace(self.settings, report.id) workspace = GitWorkspace(self.settings, report.id)
metadata: dict = {"mode": self.settings.mode, "model": self.settings.agent_model} metadata: dict = {"mode": self.settings.mode, "model": self.settings.agent_model}
try: try:
with self._progress(report.id, "workspace preparation"):
workspace.prepare() workspace.prepare()
LOG.info("Report %s branch=%s workspace ready", report.id, workspace.branch)
task = build_task(report) task = build_task(report)
metadata["agent_output"] = [] metadata["agent_output"] = []
for attempt in range(1, self.settings.max_agent_attempts + 1): for attempt in range(1, self.settings.max_agent_attempts + 1):
LOG.info(
"Report %s agent attempt %s/%s starting",
report.id,
attempt,
self.settings.max_agent_attempts,
)
with self._progress(report.id, f"agent attempt {attempt}"):
metadata["agent_output"].append(self.agent.run(workspace.path, task)) metadata["agent_output"].append(self.agent.run(workspace.path, task))
summary = workspace.summary() summary = workspace.summary()
metadata["changes"] = asdict(summary) metadata["changes"] = asdict(summary)
LOG.info(
"Report %s attempt %s changed files=%s lines=+%s/-%s paths=%s",
report.id,
attempt,
len(summary.files),
summary.added_lines,
summary.deleted_lines,
",".join(summary.files) or "none",
)
policy = evaluate_changes(summary, self.settings, workspace.path) policy = evaluate_changes(summary, self.settings, workspace.path)
if not policy.allowed: if not policy.allowed:
if not summary.files and attempt < self.settings.max_agent_attempts: if not summary.files and attempt < self.settings.max_agent_attempts:
@@ -67,7 +94,8 @@ class AutomationWorker:
"Policy rejected changes: " + "; ".join(policy.errors) "Policy rejected changes: " + "; ".join(policy.errors)
) )
try: try:
metadata["tests"] = self._run_tests(workspace) metadata["tests"] = self._run_tests(workspace, report.id, attempt)
LOG.info("Report %s attempt %s validation passed", report.id, attempt)
break break
except RuntimeError as exception: except RuntimeError as exception:
if attempt >= self.settings.max_agent_attempts: if attempt >= self.settings.max_agent_attempts:
@@ -88,14 +116,17 @@ class AutomationWorker:
) )
commit = workspace.commit(report.id) commit = workspace.commit(report.id)
metadata["commit"] = commit metadata["commit"] = commit
LOG.info("Report %s committed %s", report.id, commit)
if self.settings.mode == "pr": if self.settings.mode == "pr":
with self._progress(report.id, "branch publication"):
workspace.push_branch() workspace.push_branch()
metadata["pull_request"] = self.gitea.create_pull_request( metadata["pull_request"] = self.gitea.create_pull_request(
workspace.branch, workspace.branch,
f"Fix failure report #{report.id}", f"Fix failure report #{report.id}",
self._pull_request_body(report, summary, metadata["tests"]), self._pull_request_body(report, summary, metadata["tests"]),
) )
LOG.info("Report %s pull request=%s", report.id, metadata["pull_request"])
elif self.settings.mode == "direct-main": elif self.settings.mode == "direct-main":
workspace.push_main() workspace.push_main()
metadata["direct_main"] = True metadata["direct_main"] = True
@@ -110,10 +141,20 @@ class AutomationWorker:
finally: finally:
try: try:
workspace.cleanup() workspace.cleanup()
LOG.info("Report %s workspace cleaned", report.id)
except OSError: except OSError:
LOG.exception("Could not clean workspace for report %s", report.id) LOG.exception("Could not clean workspace for report %s", report.id)
def run_forever(self) -> None: def run_forever(self) -> None:
LOG.info(
"Worker started mode=%s model=%s poll_seconds=%s max_attempts=%s tests=%s direct_main=%s",
self.settings.mode,
self.settings.agent_model,
self.settings.poll_seconds,
self.settings.max_agent_attempts,
len(self.settings.test_commands),
self.settings.allow_direct_main,
)
signal.signal(signal.SIGTERM, self._stop) signal.signal(signal.SIGTERM, self._stop)
signal.signal(signal.SIGINT, self._stop) signal.signal(signal.SIGINT, self._stop)
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@@ -124,7 +165,12 @@ class AutomationWorker:
LOG.info("Received signal %s; stopping after the current report", signum) LOG.info("Received signal %s; stopping after the current report", signum)
self.stop_event.set() self.stop_event.set()
def _run_tests(self, workspace: GitWorkspace) -> list[dict]: def _run_tests(
self,
workspace: GitWorkspace,
report_id: int | None = None,
attempt: int | None = None,
) -> list[dict]:
results = [] results = []
test_environment = { test_environment = {
"AGENT_TEST_DB_ENABLED": "true", "AGENT_TEST_DB_ENABLED": "true",
@@ -152,6 +198,9 @@ class AutomationWorker:
"COCKPIT_CONFIG_DATABASE": self.settings.test_config_database, "COCKPIT_CONFIG_DATABASE": self.settings.test_config_database,
} }
for command in self.settings.test_commands: for command in self.settings.test_commands:
label = f"validation command: {command}"
LOG.info("Report %s attempt %s %s starting", report_id, attempt, label)
with self._progress(report_id, label):
result = workspace.run( result = workspace.run(
command, command,
check=False, check=False,
@@ -164,11 +213,51 @@ class AutomationWorker:
"stdout": result.stdout[-6000:], "stdout": result.stdout[-6000:],
"stderr": result.stderr[-6000:], "stderr": result.stderr[-6000:],
}) })
LOG.info(
"Report %s attempt %s %s finished returncode=%s",
report_id,
attempt,
label,
result.returncode,
)
if result.returncode != 0: if result.returncode != 0:
output = (result.stdout + "\n" + result.stderr)[-4000:] output = (result.stdout + "\n" + result.stderr)[-4000:]
raise RuntimeError(f"Test command failed: {command}\n{output}") raise RuntimeError(f"Test command failed: {command}\n{output}")
return results return results
@contextmanager
def _progress(
self,
report_id: int | None,
stage: str,
interval: float = 30.0,
) -> Iterator[None]:
started = time.monotonic()
stopped = threading.Event()
def heartbeat() -> None:
while not stopped.wait(interval):
LOG.info(
"Report %s %s still running elapsed=%ss",
report_id,
stage,
round(time.monotonic() - started),
)
thread = threading.Thread(target=heartbeat, daemon=True)
thread.start()
try:
yield
finally:
stopped.set()
thread.join(timeout=0.1)
LOG.info(
"Report %s %s finished elapsed=%.1fs",
report_id,
stage,
time.monotonic() - started,
)
@staticmethod @staticmethod
def _pull_request_body(report: FailureReport, summary, tests: list[dict]) -> str: def _pull_request_body(report: FailureReport, summary, tests: list[dict]) -> str:
checks = "\n".join( checks = "\n".join(

View File

@@ -33,6 +33,10 @@ def test_task_contains_constraints_and_redacted_report() -> None:
assert "admin@example.com" not in task assert "admin@example.com" not in task
assert "Do not commit or push" in task assert "Do not commit or push" in task
assert "never mock or stub the behavior being tested" in task assert "never mock or stub the behavior being tested" in task
assert "Repository ownership map:" in task
assert "config/erp_semantics.php owns ERP joins" in task
assert "Tests must contain assertions that execute" in task
assert "Do not expose protected production methods publicly" in task
assert "Feedback type: wrong_join" in task assert "Feedback type: wrong_join" in task
assert "Join FK_Artikel with FK_AuftragPos and filter Auftragkennung = 3." in task assert "Join FK_Artikel with FK_AuftragPos and filter Auftragkennung = 3." in task
assert "The wrong product count was returned." in task assert "The wrong product count was returned." in task

View File

@@ -1,3 +1,4 @@
import threading
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
@@ -60,6 +61,29 @@ def test_database_credentials_are_only_passed_to_test_process() -> None:
assert environment["COCKPIT_CONFIG_DATABASE"] == "cockpit_agent_test" assert environment["COCKPIT_CONFIG_DATABASE"] == "cockpit_agent_test"
def test_validation_commands_are_logged_without_output(caplog) -> None:
worker = AutomationWorker(settings())
workspace = Mock()
workspace.run.return_value = Mock(returncode=0, stdout="sensitive output", stderr="")
with caplog.at_level("INFO"):
worker._run_tests(workspace, report_id=7, attempt=2)
assert "Report 7 attempt 2 validation command: php artisan test starting" in caplog.text
assert "returncode=0" in caplog.text
assert "sensitive output" not in caplog.text
def test_long_stage_emits_heartbeat(caplog) -> None:
worker = AutomationWorker(settings())
with caplog.at_level("INFO"), worker._progress(7, "agent attempt 1", interval=0.001):
threading.Event().wait(0.01)
assert "Report 7 agent attempt 1 still running" in caplog.text
assert "Report 7 agent attempt 1 finished" in caplog.text
def test_failed_validation_gets_one_correction_before_pr() -> None: def test_failed_validation_gets_one_correction_before_pr() -> None:
report = FailureReport( report = FailureReport(
id=13, id=13,