From 60d10edd03b8ce7c40cd6a3c68ca14c6891712f3 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 2 May 2026 22:00:23 -0300 Subject: [PATCH] fix: verify mirrored av stimulus playback --- AGENTS.md | 14 ++ Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/manual/local_av_stimulus.py | 134 ++++++++++++-- .../manual/run_upstream_browser_av_sync.sh | 14 +- .../manual/run_upstream_mirrored_av_sync.sh | 165 +++++++++++++++++- server/Cargo.toml | 2 +- .../client_manual_sync_script_contract.rs | 19 ++ 9 files changed, 335 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1293c49..98e2e58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -630,3 +630,17 @@ stimulus plus environmental noise. - [x] Generate a `manual-review/index.html` with embedded segment captures so runs are easy to inspect by eye. - [ ] Re-run the mirrored probe and confirm pair counts rise enough for calibration-ready evidence. - [ ] If pair counts improve but p95 remains high, move next to server sink handoff jitter and late-run queue pressure. + +## 0.17.34 Stimulus Verification Checklist + +Context: the first 0.17.33 run did not prove the analyzer changes because the +browser-control path timed out before the local stimulus was started, and the +operator did not see colored flashes or hear coded tones. A sync probe must +verify its own source before asking the analyzer to explain the capture. + +- [x] Add a short visible/audible local stimulus preview before the real client starts so framing and audio audibility are human-checkable. +- [x] Record stimulus start/preview tokens, audio context state, and active pulse metadata in `stimulus-status.json`. +- [x] Make each mirrored segment fail early if the local page does not observe `/start` or WebAudio is not running. +- [x] Retry the Tethys browser recording `/start` request to survive transient SSH banner timeouts. +- [x] Open the manual review capture directory in Dolphin after summarization so copied Tethys captures are immediately inspectable. +- [ ] Re-run the mirrored probe and confirm the preview is visible/audible before trusting any pairing diagnosis. diff --git a/Cargo.lock b/Cargo.lock index 6919024..f6fd258 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.17.33" +version = "0.17.34" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.17.33" +version = "0.17.34" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.17.33" +version = "0.17.34" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index fd1fddd..266d8e8 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.17.33" +version = "0.17.34" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 9d7b688..08473e9 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.17.33" +version = "0.17.34" edition = "2024" build = "build.rs" diff --git a/scripts/manual/local_av_stimulus.py b/scripts/manual/local_av_stimulus.py index 3e4046e..3b3e04f 100755 --- a/scripts/manual/local_av_stimulus.py +++ b/scripts/manual/local_av_stimulus.py @@ -9,6 +9,7 @@ import json import socketserver import threading import time +import urllib.parse from pathlib import Path DEFAULT_EVENT_WIDTH_CODES = "1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2" @@ -47,6 +48,8 @@ class StimulusState: self.args = args self.lock = threading.RLock() self.start_token = 0 + self.preview_token = 0 + self.preview_seconds = 0 self.status = { "booted_at": time.time(), "ready": False, @@ -54,6 +57,15 @@ class StimulusState: "completed": False, "last_error": None, "page_message": "booting", + "stimulus_mode": "idle", + "audio_state": "not-created", + "observed_start_token": None, + "completed_start_token": None, + "observed_preview_token": None, + "completed_preview_token": None, + "pulse_active": False, + "pulse_index": 0, + "pulse_width_code": 0, "last_update": time.time(), } self.write_status() @@ -75,6 +87,8 @@ class StimulusState: snap = dict(self.status) snap.update({ "start_token": self.start_token, + "preview_token": self.preview_token, + "preview_seconds": self.preview_seconds, "duration_seconds": self.args.duration_seconds, "warmup_seconds": self.args.warmup_seconds, "pulse_period_ms": self.args.pulse_period_ms, @@ -93,11 +107,27 @@ class StimulusState: "completed": False, "last_error": None, "page_message": "start requested", + "stimulus_mode": "start", "start_requested_at": time.time(), }) self.write_status() return self.snapshot() + def request_preview(self, seconds: int) -> dict: + with self.lock: + self.preview_token += 1 + self.preview_seconds = max(1, min(30, seconds)) + self.status.update({ + "started": False, + "completed": False, + "last_error": None, + "page_message": "preview requested", + "stimulus_mode": "preview", + "preview_requested_at": time.time(), + }) + self.write_status() + return self.snapshot() + def page_html() -> str: return """ @@ -128,6 +158,9 @@ let audioCtx = null; let oscillator = null; let gain = null; let startedAt = 0; +let stimulusMode = 'idle'; +let lastPulse = { active: false, pulseIndex: 0, widthCode: 0 }; +let previewToken = 0; const pulseColors = { 1: '#b81d24', 2: '#007a3d', @@ -167,16 +200,51 @@ function eventAt(elapsedMs, command) { const width = Math.min(command.pulse_period_ms - 1, command.pulse_width_ms * widthCode); return { active: offset < width, pulseIndex, widthCode }; } -async function runStimulus(command) { - if (running) return; +async function runStimulus(command, mode, token) { + if (running) { + await postJson('/status', { last_error: `${mode} token ${token} ignored because stimulus is already running`, page_message: 'stimulus already running' }); + return false; + } running = true; - ensureAudio(); - await audioCtx.resume(); + stimulusMode = mode; + try { + ensureAudio(); + await audioCtx.resume(); + if (audioCtx.state !== 'running') { + throw new Error(`AudioContext did not start; state=${audioCtx.state}`); + } + } catch (err) { + running = false; + stimulusMode = 'idle'; + stage.classList.remove('active'); + if (gain) gain.gain.setTargetAtTime(0, audioCtx ? audioCtx.currentTime : 0, 0.005); + await postJson('/status', { + ready: true, + started: false, + completed: false, + last_error: String(err && (err.stack || err)), + stimulus_mode: mode, + audio_state: audioCtx ? audioCtx.state : 'not-created', + page_message: `stimulus ${mode} failed`, + }); + return false; + } startedAt = performance.now(); - await postJson('/status', { ready: true, started: true, completed: false, page_message: 'stimulus running' }); + const tokenPayload = mode === 'preview' ? { observed_preview_token: token } : { observed_start_token: token }; + await postJson('/status', { + ready: true, + started: true, + completed: false, + last_error: null, + stimulus_mode: mode, + audio_state: audioCtx.state, + page_message: mode === 'preview' ? 'stimulus preview running' : 'stimulus running', + ...tokenPayload, + }); const tick = async () => { const elapsed = performance.now() - startedAt; const event = eventAt(elapsed, command); + lastPulse = event; const active = event.active; const pulseIndex = event.pulseIndex; const widthCode = event.widthCode; @@ -192,25 +260,51 @@ async function runStimulus(command) { stage.classList.remove('active'); gain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.005); running = false; - await postJson('/status', { completed: true, page_message: 'stimulus completed' }); + stimulusMode = 'idle'; + lastPulse = { active: false, pulseIndex, widthCode }; + const completedPayload = mode === 'preview' ? { completed_preview_token: token } : { completed_start_token: token }; + await postJson('/status', { + completed: true, + stimulus_mode: mode, + audio_state: audioCtx.state, + page_message: mode === 'preview' ? 'stimulus preview completed' : 'stimulus completed', + ...completedPayload, + }); setStatus('completed'); } }; requestAnimationFrame(tick); + return true; } async function pollCommand() { try { const command = await fetch('/command').then(r => r.json()); + if (command.preview_token !== previewToken) { + const requestedPreviewToken = command.preview_token; + previewToken = requestedPreviewToken; + const previewSeconds = Number(command.preview_seconds || 4); + await runStimulus({ ...command, duration_seconds: previewSeconds, warmup_seconds: 0 }, 'preview', requestedPreviewToken); + return; + } if (command.start_token !== startToken) { - startToken = command.start_token; - await runStimulus(command); + const requestedStartToken = command.start_token; + const didStart = await runStimulus(command, 'start', requestedStartToken); + if (didStart) startToken = requestedStartToken; } } catch (err) { await postJson('/status', { last_error: String(err), page_message: 'command poll failed' }); } } setInterval(pollCommand, 200); -setInterval(() => postJson('/status', { ready: true, page_message: running ? 'running heartbeat' : 'ready heartbeat' }), 1000); +setInterval(() => postJson('/status', { + ready: true, + page_message: running ? `${stimulusMode} heartbeat` : 'ready heartbeat', + stimulus_mode: stimulusMode, + audio_state: audioCtx ? audioCtx.state : 'not-created', + pulse_active: Boolean(lastPulse.active), + pulse_index: Number(lastPulse.pulseIndex || 0), + pulse_width_code: Number(lastPulse.widthCode || 0), +}), 1000); void postJson('/status', { ready: true, page_message: 'page ready' }); setStatus(`ready Point the real webcam at this window. @@ -231,29 +325,41 @@ class StimulusHandler(http.server.BaseHTTPRequestHandler): self.wfile.write(body) def do_GET(self) -> None: - if self.path in ("/", "/index.html"): + parsed = urllib.parse.urlparse(self.path) + if parsed.path in ("/", "/index.html"): self.state.update({"page_message": "html served"}) self._send(200, page_html().encode("utf-8"), "text/html; charset=utf-8") return - if self.path == "/command": + if parsed.path == "/command": self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) return - if self.path == "/status": + if parsed.path == "/status": self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) return self._send(404, b"not found", "text/plain; charset=utf-8") def do_POST(self) -> None: + parsed = urllib.parse.urlparse(self.path) length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(length) - if self.path == "/status": + if parsed.path == "/status": payload = json.loads(body.decode("utf-8")) self.state.update(payload) self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) return - if self.path == "/start": + if parsed.path == "/start": self._send(200, json.dumps(self.state.request_start()).encode("utf-8")) return + if parsed.path == "/preview": + query = urllib.parse.parse_qs(parsed.query) + seconds = 4 + if query.get("seconds"): + try: + seconds = int(query["seconds"][0]) + except ValueError: + seconds = 4 + self._send(200, json.dumps(self.state.request_preview(seconds)).encode("utf-8")) + return self._send(404, b"not found", "text/plain; charset=utf-8") def log_message(self, fmt: str, *args) -> None: diff --git a/scripts/manual/run_upstream_browser_av_sync.sh b/scripts/manual/run_upstream_browser_av_sync.sh index 818f6ff..e520d7e 100755 --- a/scripts/manual/run_upstream_browser_av_sync.sh +++ b/scripts/manual/run_upstream_browser_av_sync.sh @@ -30,6 +30,7 @@ REMOTE_RUNTIME_DIR=${REMOTE_RUNTIME_DIR:-/run/user/1000} REMOTE_DBUS_ADDRESS=${REMOTE_DBUS_ADDRESS:-} REMOTE_XAUTHORITY=${REMOTE_XAUTHORITY:-} READY_TIMEOUT_SECONDS=${READY_TIMEOUT_SECONDS:-120} +BROWSER_START_ATTEMPTS=${BROWSER_START_ATTEMPTS:-5} mkdir -p "${LOCAL_OUTPUT_DIR}" STAMP="$(date +%Y%m%d-%H%M%S)" @@ -101,6 +102,8 @@ user_pref("permissions.default.camera", 1); user_pref("permissions.default.microphone", 1); user_pref("media.autoplay.default", 0); user_pref("media.autoplay.blocking_policy", 0); +user_pref("media.autoplay.block-webaudio", false); +user_pref("media.autoplay.enabled.user-gestures-needed", false); user_pref("toolkit.telemetry.reportingpolicy.firstRun", false); user_pref("browser.shell.checkDefaultBrowser", false); user_pref("browser.tabs.warnOnClose", false); @@ -146,7 +149,16 @@ done echo "==> triggering browser recording" start_json="" -if ! start_json="$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl --max-time 10 -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start")"; then +start_status=1 +for attempt in $(seq 1 "${BROWSER_START_ATTEMPTS}"); do + if start_json="$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl --max-time 10 -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start")"; then + start_status=0 + break + fi + echo "browser consumer start attempt ${attempt}/${BROWSER_START_ATTEMPTS} failed; retrying" >&2 + sleep 2 +done +if (( start_status != 0 )); then status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true) echo "browser consumer start request failed or timed out" >&2 [[ -n "${status_json:-}" ]] && echo "last status: ${status_json}" >&2 diff --git a/scripts/manual/run_upstream_mirrored_av_sync.sh b/scripts/manual/run_upstream_mirrored_av_sync.sh index 1e86f61..55d95c9 100755 --- a/scripts/manual/run_upstream_mirrored_av_sync.sh +++ b/scripts/manual/run_upstream_mirrored_av_sync.sh @@ -47,10 +47,12 @@ LESAVKA_SYNC_CONFIRMATION_SEGMENTS=${LESAVKA_SYNC_CONFIRMATION_SEGMENTS:-1} LESAVKA_SYNC_REQUIRE_CONFIRMATION_PASS=${LESAVKA_SYNC_REQUIRE_CONFIRMATION_PASS:-${LESAVKA_SYNC_CONFIRM_AFTER_CALIBRATION}} STIMULUS_PORT=${STIMULUS_PORT:-18444} STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10} +LESAVKA_STIMULUS_PREVIEW_SECONDS=${LESAVKA_STIMULUS_PREVIEW_SECONDS:-4} LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"} SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"} LOCAL_BROWSER=${LOCAL_BROWSER:-firefox} LESAVKA_STIMULUS_BROWSER_KIOSK=${LESAVKA_STIMULUS_BROWSER_KIOSK:-1} +LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN=${LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN:-1} mkdir -p "${LOCAL_OUTPUT_DIR}" STAMP="$(date +%Y%m%d-%H%M%S)" @@ -192,6 +194,71 @@ PY done } +wait_for_stimulus_preview_complete() { + local preview_token=$1 + local timeout_seconds=$2 + local deadline=$(( $(date +%s) + timeout_seconds )) + local status_json="" + while true; do + status_json="$(curl -fsS "http://127.0.0.1:${STIMULUS_PORT}/status" 2>/dev/null || true)" + if [[ -n "${status_json}" ]]; then + local check_status=0 + STATUS_JSON="${status_json}" PREVIEW_TOKEN="${preview_token}" python3 - <<'PY' || check_status=$? +import json +import os +import sys + +status = json.loads(os.environ["STATUS_JSON"]) +token = int(os.environ["PREVIEW_TOKEN"]) +if status.get("last_error"): + print(status.get("last_error"), file=sys.stderr) + sys.exit(2) +sys.exit(0 if status.get("completed_preview_token") == token else 1) +PY + if (( check_status == 0 )); then + return 0 + fi + if (( check_status == 2 )); then + echo "local stimulus preview failed" >&2 + echo "last stimulus status: ${status_json}" >&2 + return 1 + fi + fi + if (( $(date +%s) >= deadline )); then + echo "local stimulus preview did not complete before timeout" >&2 + [[ -n "${status_json:-}" ]] && echo "last stimulus status: ${status_json}" >&2 + echo "stimulus server log: ${ARTIFACT_DIR}/stimulus-server.log" >&2 + echo "stimulus browser log: ${ARTIFACT_DIR}/stimulus-browser.log" >&2 + return 1 + fi + sleep 0.2 + done +} + +run_stimulus_preview() { + if [[ "${LESAVKA_STIMULUS_PREVIEW_SECONDS}" == "0" ]]; then + echo "==> local stimulus preview disabled" + return 0 + fi + if ! [[ "${LESAVKA_STIMULUS_PREVIEW_SECONDS}" =~ ^[1-9][0-9]*$ ]]; then + echo "LESAVKA_STIMULUS_PREVIEW_SECONDS must be a non-negative integer" >&2 + return 2 + fi + echo "==> verifying local stimulus output" + echo " You should see colored flashes and hear test tones for ${LESAVKA_STIMULUS_PREVIEW_SECONDS}s." + local preview_json preview_token + preview_json="$(curl -fsS -X POST "http://127.0.0.1:${STIMULUS_PORT}/preview?seconds=${LESAVKA_STIMULUS_PREVIEW_SECONDS}")" + preview_token="$(PREVIEW_JSON="${preview_json}" python3 - <<'PY' +import json +import os + +status = json.loads(os.environ["PREVIEW_JSON"]) +print(int(status.get("preview_token") or 0)) +PY +)" + wait_for_stimulus_preview_complete "${preview_token}" "$((LESAVKA_STIMULUS_PREVIEW_SECONDS + 10))" +} + start_server_tunnel_if_needed() { if [[ "${LESAVKA_SERVER_ADDR}" != "auto" ]]; then RESOLVED_LESAVKA_SERVER_ADDR="${LESAVKA_SERVER_ADDR}" @@ -632,6 +699,8 @@ start_local_stimulus() { cat >"${STIMULUS_PROFILE}/user.js" <<'PREFS' user_pref("media.autoplay.default", 0); user_pref("media.autoplay.blocking_policy", 0); +user_pref("media.autoplay.block-webaudio", false); +user_pref("media.autoplay.enabled.user-gestures-needed", false); user_pref("toolkit.telemetry.reportingpolicy.firstRun", false); user_pref("browser.shell.checkDefaultBrowser", false); user_pref("browser.tabs.warnOnClose", false); @@ -648,9 +717,10 @@ PREFS "${LOCAL_BROWSER}" "${browser_args[@]}" >"${ARTIFACT_DIR}/stimulus-browser.log" 2>&1 & STIMULUS_BROWSER_PID=$! wait_for_stimulus_page_ready 15 + run_stimulus_preview echo "==> position check" - echo " Point the real webcam at the stimulus window and keep the selected microphone hearing the tone." + echo " Point the real webcam at the stimulus window and keep the selected microphone hearing the tones." echo " Waiting ${STIMULUS_SETTLE_SECONDS}s before starting the mirrored capture." sleep "${STIMULUS_SETTLE_SECONDS}" } @@ -677,13 +747,83 @@ start_real_lesavka_client() { fi } +write_stimulus_driver_script() { + local driver_script=$1 + local wait_seconds=$2 + cat >"${driver_script}" </dev/null || true)" + if [[ -n "\${status_json}" ]]; then + check_status=0 + STATUS_JSON="\${status_json}" START_TOKEN="\${start_token}" python3 - <<'PY' || check_status=\$? +import json +import os +import sys + +status = json.loads(os.environ["STATUS_JSON"]) +token = int(os.environ["START_TOKEN"]) +if status.get("last_error"): + print(status.get("last_error"), file=sys.stderr) + sys.exit(2) +if status.get("observed_start_token") != token: + sys.exit(1) +if not status.get("started"): + sys.exit(1) +if status.get("audio_state") != "running": + sys.exit(1) +sys.exit(0) +PY + if (( check_status == 0 )); then + echo " ↪ local_stimulus_started=true" + break + fi + if (( check_status == 2 )); then + echo "local stimulus failed after /start" >&2 + echo "last stimulus status: \${status_json}" >&2 + exit 1 + fi + fi + if (( \$(date +%s) >= deadline )); then + echo "local stimulus did not observe /start before timeout" >&2 + [[ -n "\${status_json:-}" ]] && echo "last stimulus status: \${status_json}" >&2 + exit 1 + fi + sleep 0.2 +done + +sleep "\${WAIT_SECONDS}" +EOF + chmod +x "${driver_script}" +} + run_browser_capture_with_real_driver() { local segment_label="$1" local segment_output_dir="$2" local segment_index="${3:-1}" local record_seconds=$((PROBE_DURATION_SECONDS + 3)) local wait_seconds=$((PROBE_DURATION_SECONDS + 2)) - local driver_command="curl -fsS -X POST http://127.0.0.1:${STIMULUS_PORT}/start >/dev/null; sleep ${wait_seconds}" + local driver_script="${segment_output_dir}/trigger-local-stimulus.sh" + write_stimulus_driver_script "${driver_script}" "${wait_seconds}" + local driver_command="${driver_script}" local reuse_browser_session=0 local analysis_required=1 if [[ "${LESAVKA_SYNC_CONTINUOUS_BROWSER}" == "1" && "${segment_index}" != "1" ]]; then @@ -1442,6 +1582,26 @@ sys.exit(1) PY } +open_manual_review_in_dolphin() { + local review_dir="${ARTIFACT_DIR}/manual-review" + if [[ "${LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN}" != "1" ]]; then + return 0 + fi + if [[ ! -d "${review_dir}" ]]; then + return 0 + fi + echo "==> opening manual review captures" + echo " ↪ manual_review_dir=${review_dir}" + if command -v dolphin >/dev/null 2>&1; then + nohup dolphin "${review_dir}" >"${ARTIFACT_DIR}/dolphin.log" 2>&1 & + elif command -v xdg-open >/dev/null 2>&1; then + echo " ↪ dolphin not found; using xdg-open fallback" + nohup xdg-open "${review_dir}" >"${ARTIFACT_DIR}/dolphin.log" 2>&1 & + else + echo " ↪ no graphical file opener found; open ${review_dir} manually" + fi +} + echo "==> prebuilding real client and analyzer" ( cd "${REPO_ROOT}" @@ -1459,6 +1619,7 @@ run_mirrored_segments || run_status=$? print_upstream_sync_state "after mirrored run" "${ARTIFACT_DIR}/planner-after.env" print_upstream_calibration_state "after mirrored run" "${ARTIFACT_DIR}/calibration-after.env" summarize_adaptive_probe_metrics +open_manual_review_in_dolphin if ! check_confirmation_result; then run_status=1 fi diff --git a/server/Cargo.toml b/server/Cargo.toml index 4e24602..1a74cda 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.17.33" +version = "0.17.34" edition = "2024" autobins = false diff --git a/testing/tests/client_manual_sync_script_contract.rs b/testing/tests/client_manual_sync_script_contract.rs index 407d884..73d6135 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/testing/tests/client_manual_sync_script_contract.rs @@ -83,6 +83,8 @@ fn browser_sync_script_can_delegate_to_a_real_path_driver() { "BROWSER_START_TOKEN", "analysis-failure.json", "BROWSER_ANALYSIS_REQUIRED=${BROWSER_ANALYSIS_REQUIRED}", + "BROWSER_START_ATTEMPTS=${BROWSER_START_ATTEMPTS:-5}", + "browser consumer start attempt ${attempt}/${BROWSER_START_ATTEMPTS} failed; retrying", "--event-width-codes", "--report-dir \"${LOCAL_REPORT_DIR}\"", "for attempt in 1 2 3 4 5", @@ -134,6 +136,8 @@ fn mirrored_sync_script_uses_real_client_capture_path() { "LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE=${LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}", "LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}", "PROBE_AUDIO_GAIN=${PROBE_AUDIO_GAIN:-0.55}", + "LESAVKA_STIMULUS_PREVIEW_SECONDS=${LESAVKA_STIMULUS_PREVIEW_SECONDS:-4}", + "LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN=${LESAVKA_OPEN_MANUAL_REVIEW_DOLPHIN:-1}", "LESAVKA_SYNC_PROVISIONAL_CALIBRATION=${LESAVKA_SYNC_PROVISIONAL_CALIBRATION:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}", "LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS=${LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS:-3}", "LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS:-350}", @@ -158,6 +162,11 @@ fn mirrored_sync_script_uses_real_client_capture_path() { "BROWSER_CONSUMER_REUSE_SESSION=\"${reuse_browser_session}\"", "BROWSER_ANALYSIS_REQUIRED=\"${analysis_required}\"", "--audio-gain \"${PROBE_AUDIO_GAIN}\"", + "run_stimulus_preview", + "write_stimulus_driver_script", + "local_stimulus_started=true", + "observed_start_token", + "audio_state", "LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer", "run_mirrored_segments", "summarize_adaptive_probe_metrics", @@ -174,6 +183,9 @@ fn mirrored_sync_script_uses_real_client_capture_path() { "segment-events.jsonl", "manual-review", "manual_review_html", + "manual_review_dir", + "open_manual_review_in_dolphin", + "dolphin", "capture_path", "confirmation-summary.json", "confirmation_passed", @@ -228,6 +240,13 @@ fn local_stimulus_matches_sync_analyzer_pulse_contract() { "--pulse-width-ms", "--marker-tick-period", "--audio-gain", + "/preview", + "preview_token", + "observed_preview_token", + "completed_preview_token", + "stimulus preview running", + "stimulus preview completed", + "audio_state", "--event-width-codes", "event_width_codes", "audio_gain",