diff --git a/docs/analysis-worker.md b/docs/analysis-worker.md index fd118ac..90d2f51 100644 --- a/docs/analysis-worker.md +++ b/docs/analysis-worker.md @@ -160,6 +160,39 @@ noch abschließen; erst danach wird die Prozess-Lease freigegeben. Ein Dispatch kann mehrere interne Schritte/Modellanfragen enthalten und entsprechend dauern. Bei hartem Beenden greift beim nächsten Start der Checkpoint-Wiederanlauf. +## Freigabe nach einer technischen Korrektur + +Bei `ENTITY_RESOLUTION_INVALID_RESPONSES` bietet die aktuelle Fallansicht die +Aktion **Einen Reparaturversuch freigeben**. Vorher die technische Ursache beheben +und im Pflichtfeld kurz dokumentieren (keine Secrets). Die Freigabe wird über den +authentifizierten Betreiberzugang mit CSRF-Schutz vorgemerkt. Maschinenclients +dürfen sie nicht auslösen. Der Worker prüft Zustand, Projektbindung und aktuelle +Paketgeneration erneut. Doppelte oder veraltete Aufträge gewähren keinen weiteren Versuch. + +Jede Freigabe erlaubt genau einen zusätzlichen Antwortversuch in der betroffenen +Namensauflösungsstufe (Suchplan oder Bewertung), ohne die bisherigen Versuche zu +löschen. Eine gültige Reparatur kann anschließend den regulären Ablauf fortsetzen. +Scheitert sie, bleibt der Fall erneut gesperrt. Weitere Versuche benötigen eine neue +ausdrückliche Freigabe. Akteur, Begründung, betroffener Antwort-Hash und Code-Hash +werden dauerhaft als Checkpoint in der Fallakte gespeichert. Revision, gültiger +Suchplan, Recherchebelege und Untersuchungsbudgets werden nicht zurückgesetzt. +Die Freigabe selbst führt keine Modellanfrage aus; die anschließende Bearbeitung +kann reguläre LLM-Kosten verursachen. + +Alternativ bei gestopptem Worker als root unter Linux: + +```sh +/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.worker repair --config /etc/viano-bug-analysis/worker.toml --case-id BUG-000021 --package-id api-PAKETIDENTITAET --reason "Technische Korrektur installiert; Namensbewertung gezielt reparieren" +``` + +Danach den Worker wieder starten. Der CLI-Befehl benötigt keinen Cloud-Schlüssel, +weil er nur eine Freigabe protokolliert. Die vorhandene Projektkonfiguration muss +unverändert gebunden sein. Diese Freigabe ist derzeit bewusst auf ausgeschöpfte +Namensauflösung beschränkt. Sie umgeht weder menschliche Rückfragen noch +fachliche Budgetgrenzen und öffnet keine erfolgreich abgeschlossenen Fälle oder +`FAILED_TO_ANALYZE`-Revisionen. Für andere Fehlergründe bleiben die jeweiligen +Retry- beziehungsweise Review-Verfahren maßgeblich. + Für einen Server ist die Vorlage `deploy/viano-bug-analysis-worker.service` vorbereitet. Sie wird nicht automatisch installiert oder gestartet. Dienstbenutzer, Pfade, Zugangsdaten und gemeinsame Workspace-Berechtigungen müssen zum Zielserver diff --git a/docs/test-results/operator-entity-repair-20261024.md b/docs/test-results/operator-entity-repair-20261024.md new file mode 100644 index 0000000..14b31d9 --- /dev/null +++ b/docs/test-results/operator-entity-repair-20261024.md @@ -0,0 +1,15 @@ +# Explicit technical recovery for exhausted entity resolution + +The authenticated case detail page now supports a reasoned approval for one additional response attempt after a software correction. Scope is currently `BLOCKED / ENTITY_RESOLUTION_INVALID_RESPONSES` during interpretation, covering plan and assessment. Other technical scheduling failures retain their existing retry paths; human feedback, completed results, final review and investigation-budget limits cannot be bypassed by this action. + +Approval is durable intent, not a model call. The worker holds its regular case lock, rechecks generation and project configuration binding, verifies exhaustion and appends a manual-resume checkpoint. Actor, reason, rejected-call hash, implementation hash, stage and next attempt number are recorded. Previous attempts, revision, captures and accepted plan are retained. The entity resolver verifies these grants on replay and extends only that stage's attempt range, by one per explicit grant. A rejected extra attempt blocks again. Grants are not automatic and repeated same-generation web submissions are deduplicated or rejected as stale. + +Validation on 2026-10-24, no paid model calls: + +- 99 passed across operator repair, worker, entity resolution, lifecycle checkpoints and manual packaging tests. +- 25 passed in the dashboard/operator repair test invocation (includes tests overlapping the previous invocation). +- Node syntax check and real Edge browser smoke passed, including rendering and dispatch arguments of the repair control; no console errors. +- Manual package --check passed after rebuilding the bundled user manual. +- An unchanged copy of the operator-supplied BUG-000021 revision-2 ZIP was admitted into a temporary workspace and explicitly approved. It transitioned to INTERPRETING_CASE in the same revision without a model call. Original file and VPS data were not modified. The stale source generation was correctly rejected first, then the current imported generation was used. + +Tests cover one-attempt exhaustion, second explicit grant, plan/capture reuse for assessment recovery, original-byte preservation, portable replay without another model call, CSRF, machine-client rejection, stale generation, invalid reason, queue deduplication and rejection of unrelated block reasons. Native Linux field verification remains pending deployment. No architecture specification was changed. diff --git a/src/viano_bug_analysis/analysis/entity_resolution.py b/src/viano_bug_analysis/analysis/entity_resolution.py index be1a12f..e0f0727 100644 --- a/src/viano_bug_analysis/analysis/entity_resolution.py +++ b/src/viano_bug_analysis/analysis/entity_resolution.py @@ -347,7 +347,9 @@ class EntityResolver: async def model_stage(self, context, settings, stage, validator, **args): errors=[] - for attempt in range(1,settings["max_invalid_responses"]+1): + with PackageReader(self.store.latest(context['case']['case_id'],context['case']['package_id']),self.store.limits) as reader: + maximum=self.authorized_attempts(reader,context,settings,stage) + for attempt in range(1,maximum+1): error_stage=f"error-{stage}-{attempt:04d}" feedback_stage=f"feedback-{stage}-{attempt:04d}" with PackageReader(self.store.latest(context['case']['case_id'],context['case']['package_id']),self.store.limits) as r: @@ -431,6 +433,26 @@ class EntityResolver: return self.owner._correction_result('REJECTED',self.store.latest(i['case_id'],i['package_id']),i['analysis_revision'],code=code) return self.block(context,'ENTITY_RESOLUTION_INVALID_RESPONSES') + def authorized_attempts(self, reader, context, settings, stage): + maximum=settings['max_invalid_responses'] + for _,checkpoint in reversed(checkpoint_chain(reader)): + details=checkpoint['details'] + if (checkpoint['analysis_revision']!=context['case']['analysis_revision'] + or details.get('operation')!='operator-entity-repair'): + continue + grant=details['repair'] + if grant['stage']!=stage:continue + error=self.read(reader,context,f'error-{stage}-{maximum:04d}') + if (grant['attempt']!=maximum+1 or not error or grant['rejected_call_ref']!=error['call_ref'] + or not isinstance(details.get('actor'),str) or not details['actor'].strip() + or not isinstance(details.get('reason'),str) or not details['reason'].strip()): + raise InterpretationError('ENTITY_RESOLUTION_HISTORY') + call=read_json(reader,error['call_ref'],'llm-call',4*1024**2) + if digest(call)!=grant['rejected_call_sha256']: + raise InterpretationError('ENTITY_RESOLUTION_HISTORY') + maximum+=1 + return maximum + def read_error(self, reader, context, stage): # Error filenames are attempt-specific; envelope stage remains versioned. return self.read(reader,context,stage) diff --git a/src/viano_bug_analysis/api/cases.py b/src/viano_bug_analysis/api/cases.py index f65a2bb..915c7d8 100644 --- a/src/viano_bug_analysis/api/cases.py +++ b/src/viano_bug_analysis/api/cases.py @@ -118,6 +118,7 @@ def next_actions(state, reason, worker): 'AWAITING_TEST_FEEDBACK': 'Die angeforderte Testerantwort auf der Seite Testanfragen bestätigen. Die Untersuchung wird in derselben Revision fortgesetzt.', 'RESEARCH_UNAVAILABLE': 'Datenbank-/Repository-Dienst prüfen. Nach Behebung ist eine gezielte Betreiberfreigabe über den Worker möglich.', 'INVALID_LLM_RESPONSES': 'Die Antworten wurden fachlich oder technisch zurückgewiesen. Fehlerdiagnostik prüfen; keine Budgets zurücksetzen.', + 'ENTITY_RESOLUTION_INVALID_RESPONSES': 'Die Namensauflösung hat ihre Antwortversuche ausgeschöpft. Nach einer technischen Korrektur kann der Betreiber mit Begründung genau einen zusätzlichen Reparaturversuch freigeben.', 'LLM_CONFIGURATION': 'Providerkonfiguration und Zugangsdaten durch den Betreiber prüfen.', 'LLM_UNAVAILABLE': 'Provider-Erreichbarkeit und ausgeschöpfte Wiederholungen prüfen. Eine neue Versuchskennung ist keine automatische Fehlerbehebung.', } diff --git a/src/viano_bug_analysis/api/operations.py b/src/viano_bug_analysis/api/operations.py index 0060cf1..088fe2d 100644 --- a/src/viano_bug_analysis/api/operations.py +++ b/src/viano_bug_analysis/api/operations.py @@ -53,7 +53,11 @@ class Operations: raise APIError('API_FORBIDDEN',403) self.control.pause(client.id,project,action=='pause') return {'status':'APPLIED'} - if action=='retry' and set(data)=={'action','key','package_generation'}: + repair=action=='repair' + if (action=='retry' and set(data)=={'action','key','package_generation'} or + repair and set(data)=={'action','key','package_generation','reason'}): + if repair and (not isinstance(data['reason'],str) or not 1<=len(data['reason'].strip())<=1000): + raise APIError('API_CONTROL_INVALID',422) if type(data['package_generation']) is not int or not isinstance(data['key'],str): raise APIError('API_CONTROL_INVALID',422) path=Cases(self.service).head(data['key'],client) @@ -61,12 +65,16 @@ class Operations: manifest=reader.manifest if manifest['package_generation'] != data['package_generation']: raise APIError('API_CONTROL_STALE',409) - if (manifest['state']=='BLOCKED' and (manifest['routing']['reason']!='RESEARCH_UNAVAILABLE' + if repair: + if manifest['state']!='BLOCKED' or manifest['routing']['reason']!='ENTITY_RESOLUTION_INVALID_RESPONSES': + raise APIError('API_CONTROL_RETRY_STATE',409) + elif (manifest['state']=='BLOCKED' and (manifest['routing']['reason']!='RESEARCH_UNAVAILABLE' or (manifest['routing']['retry_not_before'] and datetime.fromisoformat( manifest['routing']['retry_not_before'].replace('Z','+00:00')) > datetime.now(timezone.utc)))): raise APIError('API_CONTROL_RETRY_STATE',409) if manifest['state'] in {'OUTPUT_READY','HANDED_OFF','FAILED_TO_ANALYZE'}: raise APIError('API_CONTROL_RETRY_STATE',409) - command=self.control.enqueue(client.id,manifest['project']['id'],manifest) + command=self.control.enqueue(client.id,manifest['project']['id'],manifest, + repair_reason=data['reason'].strip() if repair else None) return {'status':'QUEUED','command_id':command} raise APIError('API_CONTROL_INVALID',422) diff --git a/src/viano_bug_analysis/lifecycle/checkpoints.py b/src/viano_bug_analysis/lifecycle/checkpoints.py index da3c751..6bc1b0e 100644 --- a/src/viano_bug_analysis/lifecycle/checkpoints.py +++ b/src/viano_bug_analysis/lifecycle/checkpoints.py @@ -345,7 +345,10 @@ class CheckpointStore: resume=State.INVESTIGATING_HYPOTHESES check_transition(manifest["state"], target, resume) if manual_retry and (manifest["state"] != State.BLOCKED - or manifest["routing"]["reason"] != "INVALID_LLM_RESPONSES" + or not (manifest["routing"]["reason"] == "INVALID_LLM_RESPONSES" + or (manifest['routing']['reason']=='ENTITY_RESOLUTION_INVALID_RESPONSES' + and (details or {}).get('operation')=='operator-entity-repair' + and target==State.INTERPRETING_CASE)) or target != resume or additions or artifacts): raise LifecycleError("INVALID_RETRY", "Manual repair retry requires an unchanged technical-response block") if manifest["state"] == State.BLOCKED and not manual_retry and not self.retry_due(manifest): diff --git a/src/viano_bug_analysis/web/operations.js b/src/viano_bug_analysis/web/operations.js index 547e580..400f040 100644 --- a/src/viano_bug_analysis/web/operations.js +++ b/src/viano_bug_analysis/web/operations.js @@ -12,6 +12,15 @@ async function changeOperation(data,button){ } function addRetryControl(data,root){ if(!operationState?.operator||data.revision!==data.current_revision)return; + if(data.state==='BLOCKED'&&data.reason==='ENTITY_RESOLUTION_INVALID_RESPONSES'){ + root.append(text('p','Nach einer technischen Korrektur kann ein zusätzlicher Antwortversuch für die Namensauflösung freigegeben werden. Vorhandene Ergebnisse und Untersuchungsbudgets bleiben erhalten. Der Folgeaufruf kann LLM-Kosten verursachen.','hint')); + const label=text('label','Durchgeführte Fehlerbehebung (keine Schlüssel oder Passwörter)'); + const reason=document.createElement('textarea');reason.maxLength=1000;reason.rows=2;label.append(reason);root.append(label); + const button=text('button','Einen Reparaturversuch freigeben');button.type='button'; + button.addEventListener('click',()=>{const note=reason.value.trim();if(!note){error('Bitte die durchgeführte Fehlerbehebung beschreiben.');return;} + changeOperation({action:'repair',key:data.key,package_generation:data.package_generation,reason:note},button);}); + root.append(button);return; + } const retry=data.state==='BLOCKED'?data.reason==='RESEARCH_UNAVAILABLE':!finished.has(data.state)&&['ERROR','ATTENTION','BUSY','DEFERRED','RETRY_PENDING'].includes(data.worker.status); if(!retry)return; const button=text('button','Fortsetzung nach Fehlerbehebung anfordern');button.type='button'; diff --git a/src/viano_bug_analysis/web/user-manual.json b/src/viano_bug_analysis/web/user-manual.json index 5f2a7f0..5725c87 100644 --- a/src/viano_bug_analysis/web/user-manual.json +++ b/src/viano_bug_analysis/web/user-manual.json @@ -24,7 +24,7 @@ { "id": "analysis-worker", "title": "Analyseworker und Wiederanlauf", - "markdown": "# Dauerhafter Analyseworker\n\nDer Worker verbindet die Übergabe-API mit dem vorhandenen Analyseablauf. Er nimmt\nPakete aus derselben Inbox auf, wählt die serverseitige Projektkonfiguration und\nbearbeitet aktive Fälle bis zu Outbox oder Review. Er erstellt keine Patches,\nführt keine Builds aus und verändert keine Projekt-Datenbank.\n\n## Konfiguration\n\n`config/worker.example.toml` verweist auf die Konfiguration der Übergabe-API.\nAPI und Worker müssen denselben Workspace und Projektkatalog verwenden. Der\nWorker benötigt die API-Clienttokens nicht, wohl aber die in seiner LLM-Konfiguration\ngenannten Provider-Zugangsdaten. Beide Prozesse dürfen getrennt laufen.\n\nFür jedes freigegebene Projekt werden die Analysekonfigurationen in der\nAPI-Konfiguration hinterlegt:\n\n```toml\n[[projects]]\nid = \"viano-core\"\ntype = \"wow\"\nprofile = \"vcore\"\nadapter = \"vcore\"\nplanning_mode = \"joint\"\n[projects.settings]\nproject_config = \"project.local.toml\"\nllm_config = \"llm.early-review.local.toml\"\ninvestigation_config = \"investigation.bounded.toml\"\n```\n\nDie Pfade sind relativ zur API-Konfigurationsdatei. `llm_config` ist erforderlich.\nWeitere optionale Einstellungen: `analysis_config`, `budget_config`,\n`diagnosis_config`, `change_request_config`, `correction_config`. Ohne explizite\nDatei gelten deren bestehende Standardwerte. Für den aktuellen Vcore-Betrieb die\nerprobte `investigation.bounded.toml` verwenden; ältere ausführliche Promptmodi\nkönnen an der unveränderten 20.000-Zeichen-Grenze scheitern.\n\n`planning_mode` ist je Projekt `joint` (Standard) oder `separate`. Die Profilbindung\neiner Akte muss vollständig zum Katalog passen: `project.id`, `type`, `profile`.\nAlte manuelle Pakete ohne `project.id` werden nicht automatisch zugeordnet.\nAndere Projektadapter bleiben ein eigener Ausbauschritt.\n\n## Start und Prüfung\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker check --config config/worker.local.toml\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker run --config config/worker.local.toml\n```\n\n`check` lädt Konfiguration und prüft die Anwesenheit der LLM-Zugangsdaten. Es sendet\nkeine Modellanfrage und ist kein Erreichbarkeitstest für API, Datenbank oder\nRepository. `run` verarbeitet angenommene Aufträge und kann deshalb kostenpflichtige\nLLM-Aufrufe auslösen. Der reine Start ohne anstehende Fälle führt keine Modellaufrufe\naus. `run --once` führt genau einen Scheduler-Durchgang aus, nicht einen vollständigen\nBuglauf. Auch dieser Modus kann LLM-Aufrufe auslösen.\n\nAPI zusätzlich in einem zweiten Prozess starten:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml\n```\n\nFür diese Entwicklung wurden die zuvor nicht vorhandenen lokalen Dateien\n`config/api.local.toml` und `config/worker.local.toml` mit dem Vcore-Profil vorbereitet.\nSie sind nicht versioniert und enthalten keine Zugangsschlüssel. Vorhandene lokale\nKonfigurationen werden bei späteren Änderungen nicht automatisch überschrieben.\n\n## Scheduling und Wiederanlauf\n\n- Pro Durchgang erhält jeder berechtigte aktive Fall einen Dispatch. Danach\n darf der nächste Fall arbeiten. Gibt es Fortschritt, folgt der nächste Durchgang\n unmittelbar; nur im Leerlauf oder bei Wartefällen greift das Pollintervall.\n- Der Dienst läuft als einzelner Analyseprozess. Eine OS-Dateisperre verhindert\n einen zweiten Worker im selben Workspace. Die Fall-Lease ist mit dem manuellen\n Trialbefehl geteilt. API-Veröffentlichungen verwenden weiterhin die kurzen\n Checkpoint-/Inbox-Sperren; während einer Modellanfrage bleibt die API benutzbar.\n- Paketmanifest und Checkpoints bleiben maßgeblich. Es wird nur die neueste\n Generation pro Fall geprüft, nicht jede historische Generation erneut geöffnet.\n- Bei Neustart werden verwaltete aktive Fälle und noch nicht übernommene Uploads\n wiedergefunden. PREPARED-Veröffentlichungen der API werden vor der Verarbeitung\n abgeschlossen. Rückläufer starten die bereits eröffnete neue Analyse-Revision;\n alte Interpretation und Hypothesen werden nicht als neue Ergebnisse behandelt.\n- Bestehende LLM-Operationskennungen bleiben stabil. Der Dienst ändert keine\n Versuchskennungen, Budgets, Iterationsgrenzen oder Korrekturregeln, um einen\n blockierten Fall weiterzutreiben.\n- `RETRY_PENDING`/`DEFERRED` beachten die gespeicherte Wartezeit. Ein unerwarteter\n Konfigurations-/Vorbereitungsfehler erhält einen lokalen Cooldown; andere Fälle\n werden weiterbearbeitet. Eine ausdrücklich zurückgegebene `ERROR`-Antwort oder\n unerwartete Exception während eines Dispatch erzeugt `ATTENTION`, statt dieselbe\n Fehlantwort endlos abzuspielen oder möglicherweise erneut Modellkosten auszulösen.\n- BLOCKED bleibt BLOCKED, wenn eine menschliche Testerantwort benötigt wird,\n automatische Wiederholung untersagt ist, Modellvalidierung gescheitert ist oder\n die LLM-Wiederholungen ausgeschöpft sind. Explizit freigegebene, fällige technische\n Wiederholungen dürfen über den vorhandenen Resume-Checkpoint fortgesetzt werden.\n- Outbox-/Review-Veröffentlichung wird nach Erfolg nicht ständig wiederholt. Nach\n einem Absturz zwischen Paketabschluss und Veröffentlichung kann der bestehende\n Outputvalidator die fehlende Veröffentlichung idempotent nachholen.\n\nEin Prozessabbruch während einer externen Modellanfrage kann deren bereits\nberechnete Kosten nicht zurücknehmen. Ist die Antwort vor dem Checkpoint verloren\ngegangen, lässt sich eine erneute Anfrage nicht grundsätzlich ausschließen. Der\nWorker verspricht keine Exactly-once-Ausführung beim externen Anbieter.\n\n## Betrieb und Fehlerbehandlung\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker status --config config/worker.local.toml\n```\n\n`runtime/worker/status.json` enthält den letzten Dienstzustand und gegebenenfalls\nden bearbeiteten Fall. Es ist ein Snapshot, kein Beweis für einen noch lebenden\nProzess. Die bestehende Telemetrie liefert ergänzend Heartbeat, Phasen, Laufzeiten\nund Modellverbrauch. `runtime/worker/cases/` enthält kleine lokale\nSchedulingquittungen mit Status, Fehlercode, Wartezeit und Konfigurationshashes.\nKeine Prompts, Antworten oder Geheimnisse werden diesen Quittungen hinzugefügt.\nDie Statusantwort der Übergabe-API enthält für einen zugeordneten Fall zusätzlich\nden letzten Workerstatus.\n\nDie Akte bleibt vollständig transportabel. Die lokalen Quittungen sind keine\nfachliche Beweisquelle und keine verteilte Jobdatenbank. Bei ihrer vollständigen\nEntfernung müssen reine Scheduler-Cooldowns/Attention-Markierungen gegebenenfalls\nneu erkannt werden; fachliche Sperren und LLM-Operationshistorie bleiben im Paket.\n\nKonfigurationsdateien werden über ihre Hashes vor dem ersten Dispatch gebunden.\nEine geänderte Konfiguration wird nicht unbemerkt auf dieselbe aktive Revision\nangewendet. Neue Profileinstellungen benötigen einen Worker-Neustart; laufende\nRevisionen mit abweichender Bindung gehen auf `ATTENTION/WORKER_CONFIG_CHANGED`.\nAuch abhängige Profil-/Relationsdateien nicht während einer Untersuchung ändern.\nEine allgemeine Migration laufender Modelloperationen auf neue Policies ist noch\nkein Bestandteil dieses Dienstes.\n\nNach Behebung eines lokalen Schedulingfehlers kann der Betreiber den **gestoppten**\nWorker freigeben:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker retry --config config/worker.local.toml --case-id BUG-000020 --package-id api-PAKETIDENTITAET\n```\n\nDies entfernt die lokale Warte-/Attention-Markierung, aber setzt keine LLM-Operation\noder fachliche Budgets zurück. Für einen fälligen `RESEARCH_UNAVAILABLE`-Fall\nbestätigt der Befehl zusätzlich die Reparatur des Research-Dienstes und setzt dessen\nResume-Checkpoint fort. Andere BLOCKED-Gründe, ausstehende menschliche Rückmeldungen\nund abweichende Konfigurationsbindungen werden damit nicht umgangen.\n\nBei zu wenig freiem Speicher pausiert der Worker mit `STORAGE_LOW`, ohne Akten zu\nlöschen oder Untersuchungsbudgets zu verbrauchen. Die konfigurierbare\n`min_free_bytes`-Reserve ersetzt keine Speicherüberwachung oder Retention. Die\n20-GB-Vorgabe für Entwicklungs-/Testdaten bleibt davon unabhängig. Der Worker\nlöscht keine Paketgenerationen und keine Inhalte erhaltener Akten.\n\nDie [Web-Betriebssteuerung](web-console.md#betriebssteuerung) kann neue Fälle je\nProjekt pausieren, während begonnene Analysen abschließen. Freigabeaufträge werden\ndauerhaft gespeichert und zwischen Dispatches unter derselben Fall-Lease geprüft.\nDamit ist für Web-Freigaben kein Worker-Stopp nötig. CLI-Freigaben benötigen\nweiterhin den gestoppten Dienst. Alle fachlichen Retry- und Budgetregeln gelten\nfür beide Wege; ein veralteter Webauftrag entsperrt keinen neueren Paketstand.\n\nCtrl+C bzw. SIGTERM fordert einen geordneten Stopp an. Der laufende Dispatch darf\nnoch abschließen; erst danach wird die Prozess-Lease freigegeben. Ein Dispatch\nkann mehrere interne Schritte/Modellanfragen enthalten und entsprechend dauern.\nBei hartem Beenden greift beim nächsten Start der Checkpoint-Wiederanlauf.\n\nFür einen Server ist die Vorlage `deploy/viano-bug-analysis-worker.service`\nvorbereitet. Sie wird nicht automatisch installiert oder gestartet. Dienstbenutzer,\nPfade, Zugangsdaten und gemeinsame Workspace-Berechtigungen müssen zum Zielserver\npassen. Der Worker startet weder die lokale Testdatenbank noch einen Repository-\nSynchronisationsdienst. Diese Abhängigkeiten werden separat betrieben.\n\n## Lokale Tests\n\nWorker-, Übergabe- und Wiederanlauftests verwenden künstliche Daten und lokale\nProvideradapter; es werden keine kostenpflichtigen Modellanfragen ausgeführt:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m pytest tests/test_worker.py tests/profiles/vcore/test_worker_intake.py tests/profiles/vcore/test_intake_api.py -q\n```\n" + "markdown": "# Dauerhafter Analyseworker\n\nDer Worker verbindet die Übergabe-API mit dem vorhandenen Analyseablauf. Er nimmt\nPakete aus derselben Inbox auf, wählt die serverseitige Projektkonfiguration und\nbearbeitet aktive Fälle bis zu Outbox oder Review. Er erstellt keine Patches,\nführt keine Builds aus und verändert keine Projekt-Datenbank.\n\n## Konfiguration\n\n`config/worker.example.toml` verweist auf die Konfiguration der Übergabe-API.\nAPI und Worker müssen denselben Workspace und Projektkatalog verwenden. Der\nWorker benötigt die API-Clienttokens nicht, wohl aber die in seiner LLM-Konfiguration\ngenannten Provider-Zugangsdaten. Beide Prozesse dürfen getrennt laufen.\n\nFür jedes freigegebene Projekt werden die Analysekonfigurationen in der\nAPI-Konfiguration hinterlegt:\n\n```toml\n[[projects]]\nid = \"viano-core\"\ntype = \"wow\"\nprofile = \"vcore\"\nadapter = \"vcore\"\nplanning_mode = \"joint\"\n[projects.settings]\nproject_config = \"project.local.toml\"\nllm_config = \"llm.early-review.local.toml\"\ninvestigation_config = \"investigation.bounded.toml\"\n```\n\nDie Pfade sind relativ zur API-Konfigurationsdatei. `llm_config` ist erforderlich.\nWeitere optionale Einstellungen: `analysis_config`, `budget_config`,\n`diagnosis_config`, `change_request_config`, `correction_config`. Ohne explizite\nDatei gelten deren bestehende Standardwerte. Für den aktuellen Vcore-Betrieb die\nerprobte `investigation.bounded.toml` verwenden; ältere ausführliche Promptmodi\nkönnen an der unveränderten 20.000-Zeichen-Grenze scheitern.\n\n`planning_mode` ist je Projekt `joint` (Standard) oder `separate`. Die Profilbindung\neiner Akte muss vollständig zum Katalog passen: `project.id`, `type`, `profile`.\nAlte manuelle Pakete ohne `project.id` werden nicht automatisch zugeordnet.\nAndere Projektadapter bleiben ein eigener Ausbauschritt.\n\n## Start und Prüfung\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker check --config config/worker.local.toml\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker run --config config/worker.local.toml\n```\n\n`check` lädt Konfiguration und prüft die Anwesenheit der LLM-Zugangsdaten. Es sendet\nkeine Modellanfrage und ist kein Erreichbarkeitstest für API, Datenbank oder\nRepository. `run` verarbeitet angenommene Aufträge und kann deshalb kostenpflichtige\nLLM-Aufrufe auslösen. Der reine Start ohne anstehende Fälle führt keine Modellaufrufe\naus. `run --once` führt genau einen Scheduler-Durchgang aus, nicht einen vollständigen\nBuglauf. Auch dieser Modus kann LLM-Aufrufe auslösen.\n\nAPI zusätzlich in einem zweiten Prozess starten:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml\n```\n\nFür diese Entwicklung wurden die zuvor nicht vorhandenen lokalen Dateien\n`config/api.local.toml` und `config/worker.local.toml` mit dem Vcore-Profil vorbereitet.\nSie sind nicht versioniert und enthalten keine Zugangsschlüssel. Vorhandene lokale\nKonfigurationen werden bei späteren Änderungen nicht automatisch überschrieben.\n\n## Scheduling und Wiederanlauf\n\n- Pro Durchgang erhält jeder berechtigte aktive Fall einen Dispatch. Danach\n darf der nächste Fall arbeiten. Gibt es Fortschritt, folgt der nächste Durchgang\n unmittelbar; nur im Leerlauf oder bei Wartefällen greift das Pollintervall.\n- Der Dienst läuft als einzelner Analyseprozess. Eine OS-Dateisperre verhindert\n einen zweiten Worker im selben Workspace. Die Fall-Lease ist mit dem manuellen\n Trialbefehl geteilt. API-Veröffentlichungen verwenden weiterhin die kurzen\n Checkpoint-/Inbox-Sperren; während einer Modellanfrage bleibt die API benutzbar.\n- Paketmanifest und Checkpoints bleiben maßgeblich. Es wird nur die neueste\n Generation pro Fall geprüft, nicht jede historische Generation erneut geöffnet.\n- Bei Neustart werden verwaltete aktive Fälle und noch nicht übernommene Uploads\n wiedergefunden. PREPARED-Veröffentlichungen der API werden vor der Verarbeitung\n abgeschlossen. Rückläufer starten die bereits eröffnete neue Analyse-Revision;\n alte Interpretation und Hypothesen werden nicht als neue Ergebnisse behandelt.\n- Bestehende LLM-Operationskennungen bleiben stabil. Der Dienst ändert keine\n Versuchskennungen, Budgets, Iterationsgrenzen oder Korrekturregeln, um einen\n blockierten Fall weiterzutreiben.\n- `RETRY_PENDING`/`DEFERRED` beachten die gespeicherte Wartezeit. Ein unerwarteter\n Konfigurations-/Vorbereitungsfehler erhält einen lokalen Cooldown; andere Fälle\n werden weiterbearbeitet. Eine ausdrücklich zurückgegebene `ERROR`-Antwort oder\n unerwartete Exception während eines Dispatch erzeugt `ATTENTION`, statt dieselbe\n Fehlantwort endlos abzuspielen oder möglicherweise erneut Modellkosten auszulösen.\n- BLOCKED bleibt BLOCKED, wenn eine menschliche Testerantwort benötigt wird,\n automatische Wiederholung untersagt ist, Modellvalidierung gescheitert ist oder\n die LLM-Wiederholungen ausgeschöpft sind. Explizit freigegebene, fällige technische\n Wiederholungen dürfen über den vorhandenen Resume-Checkpoint fortgesetzt werden.\n- Outbox-/Review-Veröffentlichung wird nach Erfolg nicht ständig wiederholt. Nach\n einem Absturz zwischen Paketabschluss und Veröffentlichung kann der bestehende\n Outputvalidator die fehlende Veröffentlichung idempotent nachholen.\n\nEin Prozessabbruch während einer externen Modellanfrage kann deren bereits\nberechnete Kosten nicht zurücknehmen. Ist die Antwort vor dem Checkpoint verloren\ngegangen, lässt sich eine erneute Anfrage nicht grundsätzlich ausschließen. Der\nWorker verspricht keine Exactly-once-Ausführung beim externen Anbieter.\n\n## Betrieb und Fehlerbehandlung\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker status --config config/worker.local.toml\n```\n\n`runtime/worker/status.json` enthält den letzten Dienstzustand und gegebenenfalls\nden bearbeiteten Fall. Es ist ein Snapshot, kein Beweis für einen noch lebenden\nProzess. Die bestehende Telemetrie liefert ergänzend Heartbeat, Phasen, Laufzeiten\nund Modellverbrauch. `runtime/worker/cases/` enthält kleine lokale\nSchedulingquittungen mit Status, Fehlercode, Wartezeit und Konfigurationshashes.\nKeine Prompts, Antworten oder Geheimnisse werden diesen Quittungen hinzugefügt.\nDie Statusantwort der Übergabe-API enthält für einen zugeordneten Fall zusätzlich\nden letzten Workerstatus.\n\nDie Akte bleibt vollständig transportabel. Die lokalen Quittungen sind keine\nfachliche Beweisquelle und keine verteilte Jobdatenbank. Bei ihrer vollständigen\nEntfernung müssen reine Scheduler-Cooldowns/Attention-Markierungen gegebenenfalls\nneu erkannt werden; fachliche Sperren und LLM-Operationshistorie bleiben im Paket.\n\nKonfigurationsdateien werden über ihre Hashes vor dem ersten Dispatch gebunden.\nEine geänderte Konfiguration wird nicht unbemerkt auf dieselbe aktive Revision\nangewendet. Neue Profileinstellungen benötigen einen Worker-Neustart; laufende\nRevisionen mit abweichender Bindung gehen auf `ATTENTION/WORKER_CONFIG_CHANGED`.\nAuch abhängige Profil-/Relationsdateien nicht während einer Untersuchung ändern.\nEine allgemeine Migration laufender Modelloperationen auf neue Policies ist noch\nkein Bestandteil dieses Dienstes.\n\nNach Behebung eines lokalen Schedulingfehlers kann der Betreiber den **gestoppten**\nWorker freigeben:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker retry --config config/worker.local.toml --case-id BUG-000020 --package-id api-PAKETIDENTITAET\n```\n\nDies entfernt die lokale Warte-/Attention-Markierung, aber setzt keine LLM-Operation\noder fachliche Budgets zurück. Für einen fälligen `RESEARCH_UNAVAILABLE`-Fall\nbestätigt der Befehl zusätzlich die Reparatur des Research-Dienstes und setzt dessen\nResume-Checkpoint fort. Andere BLOCKED-Gründe, ausstehende menschliche Rückmeldungen\nund abweichende Konfigurationsbindungen werden damit nicht umgangen.\n\nBei zu wenig freiem Speicher pausiert der Worker mit `STORAGE_LOW`, ohne Akten zu\nlöschen oder Untersuchungsbudgets zu verbrauchen. Die konfigurierbare\n`min_free_bytes`-Reserve ersetzt keine Speicherüberwachung oder Retention. Die\n20-GB-Vorgabe für Entwicklungs-/Testdaten bleibt davon unabhängig. Der Worker\nlöscht keine Paketgenerationen und keine Inhalte erhaltener Akten.\n\nDie [Web-Betriebssteuerung](web-console.md#betriebssteuerung) kann neue Fälle je\nProjekt pausieren, während begonnene Analysen abschließen. Freigabeaufträge werden\ndauerhaft gespeichert und zwischen Dispatches unter derselben Fall-Lease geprüft.\nDamit ist für Web-Freigaben kein Worker-Stopp nötig. CLI-Freigaben benötigen\nweiterhin den gestoppten Dienst. Alle fachlichen Retry- und Budgetregeln gelten\nfür beide Wege; ein veralteter Webauftrag entsperrt keinen neueren Paketstand.\n\nCtrl+C bzw. SIGTERM fordert einen geordneten Stopp an. Der laufende Dispatch darf\nnoch abschließen; erst danach wird die Prozess-Lease freigegeben. Ein Dispatch\nkann mehrere interne Schritte/Modellanfragen enthalten und entsprechend dauern.\nBei hartem Beenden greift beim nächsten Start der Checkpoint-Wiederanlauf.\n\n## Freigabe nach einer technischen Korrektur\n\nBei `ENTITY_RESOLUTION_INVALID_RESPONSES` bietet die aktuelle Fallansicht die\nAktion **Einen Reparaturversuch freigeben**. Vorher die technische Ursache beheben\nund im Pflichtfeld kurz dokumentieren (keine Secrets). Die Freigabe wird über den\nauthentifizierten Betreiberzugang mit CSRF-Schutz vorgemerkt. Maschinenclients\ndürfen sie nicht auslösen. Der Worker prüft Zustand, Projektbindung und aktuelle\nPaketgeneration erneut. Doppelte oder veraltete Aufträge gewähren keinen weiteren Versuch.\n\nJede Freigabe erlaubt genau einen zusätzlichen Antwortversuch in der betroffenen\nNamensauflösungsstufe (Suchplan oder Bewertung), ohne die bisherigen Versuche zu\nlöschen. Eine gültige Reparatur kann anschließend den regulären Ablauf fortsetzen.\nScheitert sie, bleibt der Fall erneut gesperrt. Weitere Versuche benötigen eine neue\nausdrückliche Freigabe. Akteur, Begründung, betroffener Antwort-Hash und Code-Hash\nwerden dauerhaft als Checkpoint in der Fallakte gespeichert. Revision, gültiger\nSuchplan, Recherchebelege und Untersuchungsbudgets werden nicht zurückgesetzt.\nDie Freigabe selbst führt keine Modellanfrage aus; die anschließende Bearbeitung\nkann reguläre LLM-Kosten verursachen.\n\nAlternativ bei gestopptem Worker als root unter Linux:\n\n```sh\n/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.worker repair --config /etc/viano-bug-analysis/worker.toml --case-id BUG-000021 --package-id api-PAKETIDENTITAET --reason \"Technische Korrektur installiert; Namensbewertung gezielt reparieren\"\n```\n\nDanach den Worker wieder starten. Der CLI-Befehl benötigt keinen Cloud-Schlüssel,\nweil er nur eine Freigabe protokolliert. Die vorhandene Projektkonfiguration muss\nunverändert gebunden sein. Diese Freigabe ist derzeit bewusst auf ausgeschöpfte\nNamensauflösung beschränkt. Sie umgeht weder menschliche Rückfragen noch\nfachliche Budgetgrenzen und öffnet keine erfolgreich abgeschlossenen Fälle oder\n`FAILED_TO_ANALYZE`-Revisionen. Für andere Fehlergründe bleiben die jeweiligen\nRetry- beziehungsweise Review-Verfahren maßgeblich.\n\nFür einen Server ist die Vorlage `deploy/viano-bug-analysis-worker.service`\nvorbereitet. Sie wird nicht automatisch installiert oder gestartet. Dienstbenutzer,\nPfade, Zugangsdaten und gemeinsame Workspace-Berechtigungen müssen zum Zielserver\npassen. Der Worker startet weder die lokale Testdatenbank noch einen Repository-\nSynchronisationsdienst. Diese Abhängigkeiten werden separat betrieben.\n\n## Lokale Tests\n\nWorker-, Übergabe- und Wiederanlauftests verwenden künstliche Daten und lokale\nProvideradapter; es werden keine kostenpflichtigen Modellanfragen ausgeführt:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m pytest tests/test_worker.py tests/profiles/vcore/test_worker_intake.py tests/profiles/vcore/test_intake_api.py -q\n```\n" }, { "id": "user-accounts", diff --git a/src/viano_bug_analysis/worker/__main__.py b/src/viano_bug_analysis/worker/__main__.py index 7854bac..6311c12 100644 --- a/src/viano_bug_analysis/worker/__main__.py +++ b/src/viano_bug_analysis/worker/__main__.py @@ -2,6 +2,8 @@ import argparse import asyncio import json import signal +import os +import sys from viano_bug_analysis.api.config import directory from viano_bug_analysis.lifecycle.storage import Workspace @@ -13,14 +15,18 @@ from .service import AnalysisWorker def main(argv=None): parser = argparse.ArgumentParser(description='Resumable BugAnalysis service worker') - parser.add_argument('command', choices=['check', 'run', 'status', 'retry']) + parser.add_argument('command', choices=['check', 'run', 'status', 'retry', 'repair']) parser.add_argument('--config', required=True) parser.add_argument('--once', action='store_true', help='one scheduler pass; may make paid model calls') parser.add_argument('--case-id') parser.add_argument('--package-id') + parser.add_argument('--reason',help='Technical correction performed; required for repair, no secrets') args = parser.parse_args(argv) - if args.command == 'retry' and not (args.case_id and args.package_id): - parser.error('retry requires --case-id and --package-id') + if args.command in {'retry','repair'} and not (args.case_id and args.package_id): + parser.error('retry/repair requires --case-id and --package-id') + if args.command=='repair': + if not args.reason or not 1<=len(args.reason.strip())<=1000:parser.error('repair requires --reason (1 to 1000 characters)') + if sys.platform=='linux' and os.geteuid()!=0:parser.error('Local repair approval requires root; alternatively use the authenticated web interface') try: config = WorkerConfig.load(args.config) if args.command == 'check': @@ -38,8 +44,9 @@ def main(argv=None): else: directory(config.intake.workspace) workspace = Workspace(config.intake.workspace) - if args.command == 'retry': - AnalysisWorker(config).retry(args.case_id, args.package_id) + if args.command in {'retry','repair'}: + AnalysisWorker(config).retry(args.case_id, args.package_id, + repair_reason=args.reason if args.command=='repair' else None) result = {'status': 'RELEASED', 'case_id': args.case_id, 'package_id': args.package_id} else: telemetry_config = TelemetryConfig.load(config.telemetry_config) if config.telemetry_config else None diff --git a/src/viano_bug_analysis/worker/control.py b/src/viano_bug_analysis/worker/control.py index 9e54ef9..0e25d70 100644 --- a/src/viano_bug_analysis/worker/control.py +++ b/src/viano_bug_analysis/worker/control.py @@ -35,7 +35,7 @@ class Control: value['paused'][project] = paused self.event(value, actor, 'pause' if paused else 'resume', project, status='APPLIED') - def enqueue(self, actor, project, manifest): + def enqueue(self, actor, project, manifest, *, repair_reason=None): with self.transaction() as value: if len(value['commands']) >= 100: from viano_bug_analysis.api.config import APIError @@ -45,13 +45,15 @@ class Control: if all(command[k] == v for k,v in identity.items()): return command['id'] command = {'id':uuid.uuid4().hex,'actor':actor,'project_id':project,**identity} + if repair_reason is not None:command['repair_reason']=repair_reason value['commands'].append(command) - self.event(value, actor, 'retry', project, command_id=command['id'], status='QUEUED', **identity) + self.event(value, actor, 'repair' if repair_reason is not None else 'retry', project, + command_id=command['id'], status='QUEUED', **identity) return command['id'] def complete(self, command, code=None): with self.transaction() as value: value['commands'] = [c for c in value['commands'] if c['id'] != command['id']] - self.event(value, command['actor'], 'retry', command['project_id'], + self.event(value, command['actor'], 'repair' if 'repair_reason' in command else 'retry', command['project_id'], command_id=command['id'], case_id=command['case_id'], package_id=command['package_id'], status='REJECTED' if code else 'APPLIED', code=code) diff --git a/src/viano_bug_analysis/worker/repair.py b/src/viano_bug_analysis/worker/repair.py new file mode 100644 index 0000000..ab47954 --- /dev/null +++ b/src/viano_bug_analysis/worker/repair.py @@ -0,0 +1,44 @@ +"""Explicit, single-attempt entity-resolution release after operator code repair.""" +from types import SimpleNamespace +import hashlib +from pathlib import Path + +from viano_bug_analysis.analysis.context import InterpretationLimits +from viano_bug_analysis.analysis.entity_resolution import EntityResolver,digest +from viano_bug_analysis.case.contracts import read_json +from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain +from viano_bug_analysis.package import PackageReader +from .config import WorkerError + + +def approve_entity_repair(store,case_id,package_id,*,actor,reason,expected_generation): + if not isinstance(reason,str) or not 1<=len(reason.strip())<=1000 or not isinstance(actor,str) or not actor.strip(): + raise WorkerError('WORKER_RETRY_STATE') + resolver=EntityResolver(SimpleNamespace(store=store,project_research=None,limits=InterpretationLimits())) + with PackageReader(store.latest(case_id,package_id),store.limits) as reader: + m=reader.manifest + if m['package_generation']!=expected_generation:raise WorkerError('WORKER_RETRY_STALE') + cp=checkpoint_chain(reader)[0][1] + if (m['state']!='BLOCKED' or m['routing']['reason']!='ENTITY_RESOLUTION_INVALID_RESPONSES' + or cp['resume_state']!='INTERPRETING_CASE'): + raise WorkerError('WORKER_RETRY_STATE') + base=f"analysis/interpretations/{m['analysis_revision']:04d}/entity-resolution" + initial=read_json(reader,base+'/input.json','entity-resolution',4*1024**2)['payload'] + context=initial['context'];settings=initial['policy'] + if resolver.read(reader,context,'assessment'):raise WorkerError('WORKER_RETRY_STATE') + stage='assessment' if resolver.read(reader,context,'plan') else 'plan' + maximum=resolver.authorized_attempts(reader,context,settings,stage) + error=resolver.read(reader,context,f'error-{stage}-{maximum:04d}') + if not error:raise WorkerError('WORKER_RETRY_STATE') + call=read_json(reader,error['call_ref'],'llm-call',4*1024**2) + if (call['analysis_revision']!=m['analysis_revision'] or call['case_id']!=case_id + or call['package_id']!=package_id or call['phase']!='INTERPRETING_CASE'): + raise WorkerError('WORKER_RETRY_STATE') + details={'operation':'operator-entity-repair','actor':actor,'reason':reason.strip(), + 'implementation_sha256':hashlib.sha256(Path(__file__).read_bytes()+ + Path(__file__).parents[1].joinpath('analysis/entity_resolution.py').read_bytes()).hexdigest(), + 'repair':{'stage':stage,'attempt':maximum+1,'rejected_call_ref':error['call_ref'], + 'rejected_call_sha256':digest(call)}} + return store.advance(case_id,package_id,'INTERPRETING_CASE', + step_id=f'operator-entity-repair:{expected_generation}',details=details, + expected_generation=expected_generation,manual_retry=True) diff --git a/src/viano_bug_analysis/worker/service.py b/src/viano_bug_analysis/worker/service.py index 0b731e2..6eeee47 100644 --- a/src/viano_bug_analysis/worker/service.py +++ b/src/viano_bug_analysis/worker/service.py @@ -115,12 +115,13 @@ class AnalysisWorker: self.runtimes[project.id] = runtime return runtime - def retry(self, case_id, package_id, *, expected_generation=None, locked=False): + def retry(self, case_id, package_id, *, expected_generation=None, locked=False, repair_reason=None, actor='local-cli'): """Release only a scheduler error; never bypass case/operation budgets.""" if not locked: with FileLock(self.workspace.runtime / 'locks/analysis-service.lock'): with FileLock(self.workspace.runtime / 'locks' / ('trial-' + key(case_id) + '.lock')): - return self.retry(case_id, package_id, expected_generation=expected_generation, locked=True) + return self.retry(case_id, package_id, expected_generation=expected_generation, locked=True, + repair_reason=repair_reason,actor=actor) if locked: latest = self.store.latest(case_id, package_id) if not latest: @@ -136,7 +137,16 @@ class AnalysisWorker: if (record.get('analysis_revision') == manifest['analysis_revision'] and record.get('config_hash') not in {None, binding(project)}): raise WorkerError('WORKER_CONFIG_CHANGED') + if repair_reason is not None and manifest['state']!=State.BLOCKED: + raise WorkerError('WORKER_RETRY_STATE') if manifest['state'] == State.BLOCKED: + if repair_reason is not None: + from .repair import approve_entity_repair + latest=approve_entity_repair(self.store,case_id,package_id,actor=actor,reason=repair_reason, + expected_generation=manifest['package_generation']) + with PackageReader(latest,self.store.limits) as reader: + self._save(reader.manifest,'RELEASED') + return # Operator confirms the read-only research service is repaired. # No human-feedback, model-validation or exhausted LLM reset. if manifest['routing']['reason'] != 'RESEARCH_UNAVAILABLE' or not self._due(manifest['routing']['retry_not_before']): @@ -164,7 +174,8 @@ class AnalysisWorker: try: with FileLock(self.workspace.runtime / 'locks' / ('trial-' + key(command['case_id']) + '.lock')): self.retry(command['case_id'], command['package_id'], - expected_generation=command['package_generation'], locked=True) + expected_generation=command['package_generation'], locked=True, + repair_reason=command.get('repair_reason'),actor=command['actor']) except LockBusy: continue except Exception as error: diff --git a/tests/dashboard-smoke.mjs b/tests/dashboard-smoke.mjs index 6d86004..41d9389 100644 --- a/tests/dashboard-smoke.mjs +++ b/tests/dashboard-smoke.mjs @@ -33,6 +33,19 @@ try { await until(()=>evaluate("document.readyState === 'complete' && typeof render === 'function'")); await evaluate("document.getElementById('username').value='preview';document.getElementById('password').value='ui-fixture-password';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));"); await until(()=>evaluate("document.getElementById('token-count').textContent==='35.300'")); + await until(()=>evaluate('operationState?.operator === true')); + const repairControl=await evaluate(`(()=>{ + const root=document.createElement('div'); + addRetryControl({state:'BLOCKED',reason:'ENTITY_RESOLUTION_INVALID_RESPONSES',revision:2,current_revision:2,key:'fixture',package_generation:43},root); + const input=root.querySelector('textarea'),button=root.querySelector('button'); + if(!input||!button)return false; + const original=changeOperation;let sent; + changeOperation=(data)=>{sent=data;}; + try{input.value='Installed technical correction';button.click(); + return button.textContent==='Einen Reparaturversuch freigeben'&&sent?.action==='repair'&&sent.package_generation===43&&sent.reason==='Installed technical correction'; + }finally{changeOperation=original;} + })()`); + if(!repairControl)throw new Error('Scoped operator repair control failed'); if(await evaluate("document.cookie.includes('vba_session')"))throw new Error('Session cookie is readable by JavaScript'); await send('Page.reload'); await until(()=>evaluate("session===0&&token==='session'&&document.getElementById('token-count').textContent==='35.300'")); diff --git a/tests/test_operator_entity_repair.py b/tests/test_operator_entity_repair.py new file mode 100644 index 0000000..2ea9732 --- /dev/null +++ b/tests/test_operator_entity_repair.py @@ -0,0 +1,111 @@ +import json + +import pytest +from starlette.testclient import TestClient +from test_case_builder import case_setup +from test_case_interpreter import built_case +from test_entity_resolution import Model,service_for,run +from viano_bug_analysis.api import APIConfig,Project,Client +from viano_bug_analysis.api.app import create_app +from viano_bug_analysis.api.cases import case_key +from viano_bug_analysis.lifecycle import CheckpointStore +from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain +from viano_bug_analysis.package import PackageReader +from viano_bug_analysis.worker import AnalysisWorker,WorkerConfig,WorkerError +from viano_bug_analysis.worker.repair import approve_entity_repair + + +@pytest.fixture(autouse=True) +def project_manifest(manifest): + manifest['project']={'id':'p','type':'wow','profile':'vcore'} + + +def exhaust(store): + model=Model();model.invalid_plan=3;service=service_for(store) + for _ in range(3):assert run(store,model,service).status=='REJECTED' + assert run(store,model,service).status=='BLOCKED' + return model,service + + +def test_approval_keeps_revision_artifacts_and_only_allows_one_attempt(built_case,tmp_path): + store=built_case[0];model,service=exhaust(store) + with PackageReader(store.latest('case-A','stable-package')) as reader: + manifest=reader.manifest;before={n:reader.read_bytes(n) for n in reader.files if n!='manifest.json'} + path=approve_entity_repair(store,'case-A','stable-package',actor='alice',reason='Updated plan validation guidance',expected_generation=manifest['package_generation']) + with PackageReader(path) as reader: + assert reader.manifest['analysis_revision']==manifest['analysis_revision'] + assert {k:v for k,v in reader.manifest['artifacts'].items() if k!='lifecycle_checkpoint'}=={k:v for k,v in manifest['artifacts'].items() if k!='lifecycle_checkpoint'} + assert reader.manifest['state']=='INTERPRETING_CASE' + for name,raw in before.items():assert reader.read_bytes(name)==raw + details=checkpoint_chain(reader)[0][1]['details'] + assert details['repair']['attempt']==4 and details['actor']=='alice' + with pytest.raises(WorkerError): + approve_entity_repair(store,'case-A','stable-package',actor='bob',reason='duplicate',expected_generation=manifest['package_generation']) + # Another invalid response exhausts this approval; no new attempt is automatic. + model.invalid_plan=1 + assert run(store,model,service).status=='REJECTED' + assert run(store,model,service).status=='BLOCKED' + assert run(store,model,service).status=='BLOCKED' + assert len(model.requests)==4 + with PackageReader(store.latest('case-A','stable-package')) as reader:generation=reader.manifest['package_generation'] + approve_entity_repair(store,'case-A','stable-package',actor='alice',reason='Second explicit correction',expected_generation=generation) + result=run(store,model,service) + assert result.status=='COMPLETED' + recipient=CheckpointStore(type(store.workspace)(tmp_path/'portable')) + recipient.intake(result.package) + calls=len(model.requests) + assert run(recipient,model).reused and len(model.requests)==calls + + +def test_web_approval_is_scoped_csrf_protected_and_audited(built_case,monkeypatch): + store=built_case[0];model,service=exhaust(store) + monkeypatch.setenv('MACHINE_TOKEN','m_'+'x'*40) + config=APIConfig(store.workspace.root,(Project('p','wow','vcore'),), + (Client('capture','MACHINE_TOKEN',('p',)),)) + app=create_app(config);web=TestClient(app) + app.state.sessions.users.manage('create_user','alice','example-password') + login=web.post('/v1/auth/login',json={'username':'alice','password':'example-password'}) + headers={'X-CSRF-Token':login.json()['csrf']} + with PackageReader(store.latest('case-A','stable-package')) as reader:generation=reader.manifest['package_generation'] + request={'action':'repair','key':case_key('case-A','stable-package'),'package_generation':generation,'reason':'Installed technical correction'} + assert web.post('/v1/operations',json=request).status_code==403 + machine=TestClient(app) + assert machine.post('/v1/operations',headers={'Authorization':'Bearer m_'+'x'*40},json=request).status_code==403 + assert web.post('/v1/operations',headers=headers,json={**request,'reason':' '}).status_code==422 + assert web.post('/v1/operations',headers=headers,json={**request,'package_generation':generation-1}).status_code==409 + reply=web.post('/v1/operations',headers=headers,json=request) + assert reply.status_code==200 and reply.json()['status']=='QUEUED' + assert web.post('/v1/operations',headers=headers,json=request).json()['command_id']==reply.json()['command_id'] + worker=AnalysisWorker(WorkerConfig(config,min_free_bytes=1)) + worker._commands() + assert not worker.control.read()['commands'] + assert worker.control.read()['events'][-1]['action']=='repair' + assert worker.control.read()['events'][-1]['status']=='APPLIED' + assert run(store,model,service).status=='COMPLETED' + + +def test_assessment_approval_keeps_plan_and_database_captures(built_case): + store=built_case[0];model=Model();model.invalid_assessment=3;service=service_for(store) + for _ in range(3):assert run(store,model,service).status=='REJECTED' + assert run(store,model,service).status=='BLOCKED' + with PackageReader(store.latest('case-A','stable-package')) as reader: + generation=reader.manifest['package_generation'] + plan=reader.read_bytes('analysis/interpretations/0001/entity-resolution/plan.json') + search=reader.read_bytes('analysis/interpretations/0001/entity-resolution/search.json') + approve_entity_repair(store,'case-A','stable-package',actor='alice',reason='Precise assessment repair installed',expected_generation=generation) + result=run(store,model,service) + assert result.status=='COMPLETED' and len(service.database.calls)==1 + with PackageReader(result.package) as reader: + assert reader.read_bytes('analysis/interpretations/0001/entity-resolution/plan.json')==plan + assert reader.read_bytes('analysis/interpretations/0001/entity-resolution/search.json')==search + + +@pytest.mark.parametrize('reason',['AWAITING_TEST_FEEDBACK','RESEARCH_UNAVAILABLE','INVALID_LLM_RESPONSES']) +def test_other_blocks_cannot_receive_an_entity_allowance(built_case,reason): + store=built_case[0] + # The protected human-feedback state is rejected before its resume semantics matter. + store.advance('case-A','stable-package','BLOCKED',step_id='other-block',reason=reason, + retry_not_before='2099-01-01T00:00:00+00:00') + with PackageReader(store.latest('case-A','stable-package')) as reader:generation=reader.manifest['package_generation'] + with pytest.raises(WorkerError): + approve_entity_repair(store,'case-A','stable-package',actor='alice',reason='Do not bypass',expected_generation=generation)