Add audited operator approval for one entity repair attempt
This commit is contained in:
1 parent
4cbba4c1e4
commit
61889740e6
14 files changed
+296
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 geschLine truncated
|
||||
"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 geschLine truncated
|
||||
},
|
||||
{
|
||||
"id": "user-accounts",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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'"));
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user