3 Commits

Author SHA1 Message Date
Dom
83be93e121 chore(qw): cleanup post-review (préfixes BUS, événements monitor, import io)
Some checks failed
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
- safety_checks_provider : tous les logger.warning d'échec LLM préfixés
  [BUS] lea:safety_checks_llm_failed avec une raison spécifique
  (exception, http_status, timeout, network, json_decode).
- monitor_router : émission [BUS] lea:monitor_invalid_index si l'index
  explicite passé dans l'action est hors limites de monitors_geometry,
  et [BUS] lea:monitor_unavailable si focus actif demandé mais introuvable.
  Ces deux events permettent au bus de tracer chaque fallback de la cascade
  de routage QW1.
- safety_checks_provider : import io supprimé (inutilisé).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:08:22 +02:00
Dom
f5c33477f0 fix(qw4): câblage polling frontend → streaming pour PauseDialog
Avant ce fix, le frontend VWB ne savait pas qu'un replay Agent V1 (Windows)
était en pause supervisée : le seul polling (App.tsx) interrogeait
/execute/status (exécution locale Linux) et n'avait jamais l'info
safety_checks / pause_message du replay distant.

Côté backend (dag_execute.py) :
- ajout du proxy GET /api/v3/replay/state/<replay_id> qui forward vers
  /api/v1/traces/stream/replay/<id> avec Bearer token.

Côté frontend :
- ExecutionControls : nouvelle prop onWindowsReplayStarted, appelée avec
  le replay_id retourné par /api/v3/execute-windows.
- App.tsx : nouveau state streamingReplayId + useEffect qui poll
  /api/v3/replay/state/<id> toutes les secondes et fusionne status,
  pause_message, pause_reason, safety_checks dans appState.execution.
  Le PauseDialog existant s'affiche donc automatiquement quand
  status = paused_need_help.

Le polling s'arrête quand le replay est completed/error/cancelled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:06:20 +02:00
Dom
b1a3aa16f1 fix(qw1): enrichir heartbeat Windows avec monitor_index + monitors_geometry
Avant ce fix, le _heartbeat_loop côté Agent V1 deploy Windows
n'enrichissait pas son payload, donc QW1 multi-écran ne s'activait sur Windows
que via les events window_capture (déclenchés par les clics), pas en continu.

La source agent_v0/agent_v1/main.py portait déjà l'enrichissement (commit 2d71e2a24)
mais le snapshot deploy/windows_client/agent_v1/main.py n'avait pas été synchronisé.

Désormais chaque heartbeat porte monitor_index + monitors_geometry, le serveur
peut donc résoudre l'écran cible en permanence, même sans clic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:02:11 +02:00
6 changed files with 157 additions and 11 deletions

View File

@@ -319,7 +319,22 @@ class AgentV1:
if img_hash != self._last_heartbeat_hash:
self._last_heartbeat_hash = img_hash
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id})
heartbeat_event = {
"type": "heartbeat",
"image": full_path,
"timestamp": time.time(),
"machine_id": self.machine_id,
}
# QW1 — enrichissement multi-écrans (monitor_index + monitors_geometry)
# Additif, fallback gracieux : sans cet enrichissement, le serveur
# ne reçoit l'info qu'au moment des clics, donc QW1 ne s'active
# pas en continu sur poste Windows multi-écrans.
try:
from .vision.capturer import _enrich_with_monitor_info
_enrich_with_monitor_info(heartbeat_event)
except Exception as e:
logger.debug("QW1 enrichissement heartbeat échoué: %s", e)
self.streamer.push_event(heartbeat_event)
except Exception as e:
logger.error(f"Heartbeat error: {e}")
time.sleep(5)

View File

@@ -9,9 +9,12 @@ Stratégie en cascade :
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
"""
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class MonitorTarget:
@@ -76,6 +79,10 @@ def resolve_target_monitor(
if m is not None:
return _to_target(m, source="action")
# Index invalide → on tombe sur le fallback focus
logger.warning(
"[BUS] lea:monitor_invalid_index requested=%d available_idx=%s",
int(explicit_idx), [g.get("idx") for g in geometry],
)
# 2. Fallback focus actif
focused_idx = session_state.get("last_focused_monitor")
@@ -83,6 +90,10 @@ def resolve_target_monitor(
m = _find_monitor(geometry, int(focused_idx))
if m is not None:
return _to_target(m, source="focus")
logger.warning(
"[BUS] lea:monitor_unavailable focused_idx=%d available_idx=%s",
int(focused_idx), [g.get("idx") for g in geometry],
)
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
return _COMPOSITE_FALLBACK

View File

@@ -11,7 +11,6 @@ le replay continue avec uniquement les déclaratifs (fallback safe).
"""
import base64
import io
import json
import logging
import os
@@ -77,7 +76,7 @@ def build_pause_payload(
existing_labels=[c["label"] for c in checks],
)
except Exception as e:
logger.warning("safety_checks LLM exception (%s) — fallback safe", e)
logger.warning("[BUS] lea:safety_checks_llm_failed reason=exception detail=%s", e)
additional = []
for a in additional:
@@ -159,21 +158,21 @@ Réponds UNIQUEMENT en JSON :
timeout=timeout_s,
)
if response.status_code != 200:
logger.warning("safety_checks LLM HTTP %s", response.status_code)
logger.warning("[BUS] lea:safety_checks_llm_failed reason=http_status detail=%s", response.status_code)
return []
text = response.json().get("response", "").strip()
except requests.Timeout:
logger.warning("safety_checks LLM timeout (%ss)", timeout_s)
logger.warning("[BUS] lea:safety_checks_llm_failed reason=timeout detail=%ss", timeout_s)
return []
except Exception as e:
logger.warning("safety_checks LLM erreur réseau: %s", e)
logger.warning("[BUS] lea:safety_checks_llm_failed reason=network detail=%s", e)
return []
# format=json garantit normalement du JSON valide
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
logger.warning("safety_checks LLM JSON invalide (%s) — fallback safe", e)
logger.warning("[BUS] lea:safety_checks_llm_failed reason=json_decode detail=%s", e)
return []
additional = parsed.get("additional_checks") or []

View File

@@ -1155,3 +1155,34 @@ def replay_resume_proxy():
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
except req.RequestException as e:
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502
# ---------------------------------------------------------------------------
# QW4 — Proxy GET /api/v3/replay/state/<replay_id> → streaming /replay/{id}
# Forward Bearer token vers le serveur streaming.
# Permet à App.tsx de récupérer le state du replay actif (Agent V1 Windows)
# pour afficher PauseDialog quand status = paused_need_help avec safety_checks.
# ---------------------------------------------------------------------------
@api_v3_bp.route('/replay/state/<replay_id>', methods=['GET'])
def replay_state_proxy(replay_id):
"""Proxy QW4 vers le serveur streaming pour récupérer le state replay actif."""
import requests as req
streaming_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005')
token = os.environ.get('RPA_API_TOKEN', '')
headers = {}
if token:
headers['Authorization'] = f'Bearer {token}'
try:
resp = req.get(
f'{streaming_url}/api/v1/traces/stream/replay/{replay_id}',
headers=headers,
timeout=5,
)
return resp.content, resp.status_code, {'Content-Type': 'application/json'}
except req.ConnectionError:
return jsonify({'error': 'streaming_unreachable',
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
except req.RequestException as e:
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502

View File

@@ -62,6 +62,13 @@ function App() {
const [healingCandidates, setHealingCandidates] = useState<any[]>([]);
const [healingStepInfo, setHealingStepInfo] = useState<any>(null);
// QW4 — Replay streaming Windows en cours (Agent V1 distant).
// Quand un replay distant est lancé via ExecutionControls "→ Windows",
// ExecutionControls appelle setStreamingReplayId(replay_id) et un useEffect
// poll /api/v3/replay/state/<id> pour fusionner safety_checks + pause_*
// dans appState.execution → PauseDialog s'affiche.
const [streamingReplayId, setStreamingReplayId] = useState<string | null>(null);
// Charger l'état initial
const loadState = useCallback(async () => {
try {
@@ -123,6 +130,62 @@ function App() {
return () => clearInterval(interval);
}, [isExecutionRunning, loadState]);
// QW4 — Polling state replay streaming (Agent V1 Windows distant)
// Tourne dès qu'un replay distant a été lancé. Récupère safety_checks,
// pause_message, pause_reason et les fusionne dans appState.execution
// pour que PauseDialog s'affiche quand status = paused_need_help.
useEffect(() => {
if (!streamingReplayId) return;
let stopped = false;
const pollReplay = async () => {
try {
const resp = await fetch(`/api/v3/replay/state/${streamingReplayId}`);
if (!resp.ok) return;
const state = await resp.json();
if (stopped) return;
// Fusionner dans appState.execution sans écraser le reste.
setAppState(prev => {
if (!prev) return prev;
const prevExec = prev.execution || {
id: streamingReplayId,
workflow_id: prev.session?.active_workflow_id || '',
status: 'pending',
progress: 0,
current_step_index: 0,
completed_steps: 0,
failed_steps: 0,
total_steps: 0,
};
return {
...prev,
execution: {
...prevExec,
status: state.status || prevExec.status,
pause_message: state.pause_message || state.message,
pause_reason: state.pause_reason,
safety_checks: state.safety_checks || [],
replay_id: streamingReplayId,
},
};
});
// Stopper le polling si le replay est terminé / annulé.
if (state.status && ['completed', 'error', 'cancelled'].includes(state.status)) {
setStreamingReplayId(null);
}
} catch (err) {
// ignore (le serveur streaming peut être momentanément indispo)
}
};
// Tick immédiat puis toutes les 1s.
pollReplay();
const interval = setInterval(pollReplay, 1000);
return () => { stopped = true; clearInterval(interval); };
}, [streamingReplayId]);
// Convertir les étapes en nœuds React Flow
// Les edges ne sont générées automatiquement que lors du premier chargement
// d'un workflow. Ensuite, les connexions manuelles de l'utilisateur sont préservées.
@@ -452,6 +515,7 @@ function App() {
execution={appState?.execution || null}
onStart={handleStartExecution}
onStop={handleStopExecution}
onWindowsReplayStarted={(replayId) => setStreamingReplayId(replayId)}
/>
<ConfidenceDashboard
isExecutionRunning={isExecutionRunning}

View File

@@ -9,9 +9,12 @@ interface Props {
execution: Execution | null;
onStart: () => void;
onStop: () => void;
// QW4 — Notifie App.tsx quand un replay streaming Windows est lancé,
// pour qu'il poll /api/v3/replay/state/<id> et affiche PauseDialog au besoin.
onWindowsReplayStarted?: (replayId: string) => void;
}
export default function ExecutionControls({ execution, onStart, onStop }: Props) {
export default function ExecutionControls({ execution, onStart, onStop, onWindowsReplayStarted }: Props) {
const isRunning = execution?.status === 'running' || execution?.status === 'paused';
const [windowsStatus, setWindowsStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
@@ -56,6 +59,11 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
const result = await resp.json();
if (result.replay_id) {
setWindowsStatus('sent');
// QW4 — propage le replay_id à App.tsx pour activer le polling
// /api/v3/replay/state/<id> (PauseDialog si paused_need_help).
if (onWindowsReplayStarted) {
try { onWindowsReplayStarted(result.replay_id); } catch {}
}
alert('Replay lancé ! Réduisez cette fenêtre maintenant.\nLes actions commenceront dans 5 secondes.');
setTimeout(() => setWindowsStatus('idle'), 5000);
} else {
@@ -75,9 +83,27 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
{!isRunning ? (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{userOS === 'linux' ? (
<button className="btn-start" onClick={onStart} title="Exécuter sur cet écran">
Exécuter
</button>
<>
<button className="btn-start" onClick={onStart} title="Exécuter sur cet écran (Linux local)">
Exécuter
</button>
<button
className="btn-start"
onClick={handleExecuteWindows}
disabled={windowsStatus === 'sending'}
style={{
background: windowsStatus === 'sent' ? '#22c55e' : windowsStatus === 'error' ? '#ef4444' : '#0078d4',
fontSize: '12px',
opacity: windowsStatus === 'sending' ? 0.6 : 1,
}}
title="Exécuter sur le PC Windows (Agent V1)"
>
{windowsStatus === 'sending' ? 'Envoi...' :
windowsStatus === 'sent' ? 'Lancé !' :
windowsStatus === 'error' ? 'Erreur' :
'→ Windows'}
</button>
</>
) : (
<button
className="btn-start"