diff --git a/AGENTS.md b/AGENTS.md index 0f471f7..79de71c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -344,3 +344,19 @@ Context: 0.17.14 can keep one Lesavka session alive across multiple measured seg - [x] Update manual probe contract tests for the adaptive artifacts and controls. - [x] Run focused script checks and package checks. - [x] Push clean semver `0.17.15` for installed client/server testing. + +## 0.17.16 Continuous Browser Evidence Checklist + +Context: 0.17.15 proved the adaptive/live-edit loop is structurally useful, but each segment restarted the Tethys browser/getUserMedia receiver. That contaminates calibration segments with receiver startup noise and keeps usable coded pairs too low (`3-5` pairs instead of the `8+` needed for safe calibration). 0.17.16 is scoped to making the probe evidence continuous and attributable before any more media playout changes. + +- [x] Keep 0.17.16 scoped to probe/tooling reliability; do not change server media playout policy, freshness ceilings, queue policy, or UAC smoothness. +- [x] Make adaptive mirrored runs keep one Tethys browser/getUserMedia session alive after the first segment. +- [x] Preserve single-segment/manual probe behavior by default. +- [x] Add an explicit `BROWSER_CONSUMER_REUSE_SESSION` control to the browser probe runner. +- [x] Verify browser uploads by start token so the fetched WebM belongs to the segment just triggered. +- [x] Track browser upload counts/tokens in `browser_consumer_probe.py` status JSON for post-run debugging. +- [x] Wire adaptive mirrored mode to reuse the existing Tethys receiver for segments 2+. +- [x] Keep calibration mutation behind the existing analyzer `calibration.ready=true` gate. +- [x] Update manual probe contract tests for continuous browser session behavior. +- [x] Run shell syntax checks, focused contract tests, and package checks. +- [ ] Push clean semver `0.17.16` for installed client/server testing. diff --git a/Cargo.lock b/Cargo.lock index 87ba547..03b48c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.17.15" +version = "0.17.16" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.17.15" +version = "0.17.16" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.17.15" +version = "0.17.16" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 284d175..0fed2ba 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.17.15" +version = "0.17.16" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index bca8b0e..611b22c 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.17.15" +version = "0.17.16" edition = "2024" build = "build.rs" diff --git a/scripts/manual/browser_consumer_probe.py b/scripts/manual/browser_consumer_probe.py index 04f8e5f..98fa89b 100755 --- a/scripts/manual/browser_consumer_probe.py +++ b/scripts/manual/browser_consumer_probe.py @@ -5,6 +5,7 @@ import json import socketserver import threading import time +import urllib.parse from pathlib import Path @@ -36,6 +37,9 @@ class ProbeState: "devices": [], "page_message": "booting", "last_update": time.time(), + "active_start_token": 0, + "uploaded_start_token": None, + "upload_count": 0, } self.write_status() @@ -62,6 +66,8 @@ class ProbeState: with self.lock: self.start_token += 1 self.status.update({ + "active_start_token": self.start_token, + "uploaded_start_token": None, "recording": False, "uploaded": False, "last_error": None, @@ -71,12 +77,15 @@ class ProbeState: self.write_status() return self.snapshot() - def store_upload(self, blob: bytes) -> dict: + def store_upload(self, blob: bytes, start_token: int | None) -> dict: with self.lock: self.output_path.parent.mkdir(parents=True, exist_ok=True) self.output_path.write_bytes(blob) + upload_count = int(self.status.get("upload_count") or 0) + 1 self.status.update({ "uploaded": True, + "uploaded_start_token": start_token, + "upload_count": upload_count, "recording": False, "page_message": f"capture uploaded to {self.output_path}", "upload_size": len(blob), @@ -224,7 +233,7 @@ async function maybeStartRecording() {{ recorder.ondataavailable = event => {{ if (event.data && event.data.size > 0) chunks.push(event.data); }}; recorder.onstop = async () => {{ const blob = new Blob(chunks, {{ type: recorder.mimeType || 'video/webm' }}); - await postBlob('/upload', blob); + await postBlob('/upload?start_token=' + encodeURIComponent(startToken), blob); recording = false; }}; recorder.start(250); @@ -257,7 +266,8 @@ class ProbeHandler(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"): snap = self.state.snapshot() self.state.update({ "page_message": "html served", @@ -265,27 +275,35 @@ class ProbeHandler(http.server.BaseHTTPRequestHandler): }) self._send(200, page_html(self.state.duration_seconds).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 self.path == "/upload": - self._send(200, json.dumps(self.state.store_upload(body)).encode("utf-8")) + if parsed.path == "/upload": + query = urllib.parse.parse_qs(parsed.query) + start_token = None + if query.get("start_token"): + try: + start_token = int(query["start_token"][0]) + except ValueError: + start_token = None + self._send(200, json.dumps(self.state.store_upload(body, start_token)).encode("utf-8")) return self._send(404, b"not found", "text/plain; charset=utf-8") diff --git a/scripts/manual/run_upstream_browser_av_sync.sh b/scripts/manual/run_upstream_browser_av_sync.sh index 6857f5d..95f07f8 100755 --- a/scripts/manual/run_upstream_browser_av_sync.sh +++ b/scripts/manual/run_upstream_browser_av_sync.sh @@ -15,6 +15,7 @@ LESAVKA_SERVER_ADDR=${LESAVKA_SERVER_ADDR:-https://38.28.125.112:50051} PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-15} BROWSER_RECORD_SECONDS=${BROWSER_RECORD_SECONDS:-${PROBE_DURATION_SECONDS}} BROWSER_SYNC_DRIVER_COMMAND=${BROWSER_SYNC_DRIVER_COMMAND:-} +BROWSER_CONSUMER_REUSE_SESSION=${BROWSER_CONSUMER_REUSE_SESSION:-0} SYNC_ANALYZE_EVENT_WIDTH_CODES=${SYNC_ANALYZE_EVENT_WIDTH_CODES:-} BROWSER_PORT=${BROWSER_PORT:-18443} REMOTE_SCRIPT=${REMOTE_SCRIPT:-/tmp/lesavka-browser-consumer-probe.py} @@ -44,7 +45,8 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ "${BROWSER_RECORD_SECONDS}" \ "${BROWSER_PORT}" \ "${DISPLAY_ENV}" \ - "${REMOTE_RUNTIME_DIR}" <<'REMOTE_SETUP' + "${REMOTE_RUNTIME_DIR}" \ + "${BROWSER_CONSUMER_REUSE_SESSION}" <<'REMOTE_SETUP' set -euo pipefail remote_script=$1 remote_capture=$2 @@ -54,8 +56,22 @@ duration=$5 port=$6 display_env=$7 runtime_dir=$8 +reuse_session=$9 dbus_address="" xauthority_path="" +if [[ "${reuse_session}" == "1" ]] && status_json="$(curl --max-time 2 -fsS "http://127.0.0.1:${port}/status" 2>/dev/null)"; then + if STATUS_JSON="${status_json}" python3 - <<'PY' +import json +import os +import sys + +status = json.loads(os.environ["STATUS_JSON"]) +sys.exit(0 if status.get("ready") else 1) +PY + then + exit 0 + fi +fi firefox_pid="$(pgrep -n -x firefox-esr || true)" if [[ -n "${firefox_pid}" && -r "/proc/${firefox_pid}/environ" ]]; then while IFS='=' read -r key value; do @@ -128,12 +144,22 @@ while true; do done echo "==> triggering browser recording" -if ! ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl --max-time 10 -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start >/dev/null"; then +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 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 exit 1 fi +browser_start_token="$(START_JSON="${start_json}" python3 - <<'PY' +import json +import os + +status = json.loads(os.environ["START_JSON"]) +print(int(status.get("start_token") or 0)) +PY +)" +echo " ↪ browser_start_token=${browser_start_token}" sleep 1 @@ -155,7 +181,17 @@ deadline_upload=$(( $(date +%s) + PROBE_DURATION_SECONDS + 60 )) while true; do status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true) if [[ -n "${status_json}" ]]; then - if STATUS_JSON="${status_json}" python3 -c 'import json, os, sys; status = json.loads(os.environ["STATUS_JSON"]); sys.exit(0 if status.get("uploaded") else 1)' + if STATUS_JSON="${status_json}" BROWSER_START_TOKEN="${browser_start_token}" python3 - <<'PY' +import json +import os +import sys + +status = json.loads(os.environ["STATUS_JSON"]) +expected = int(os.environ["BROWSER_START_TOKEN"]) +uploaded = bool(status.get("uploaded")) +uploaded_token = status.get("uploaded_start_token") +sys.exit(0 if uploaded and uploaded_token == expected else 1) +PY then echo "==> browser recording uploaded" break diff --git a/scripts/manual/run_upstream_mirrored_av_sync.sh b/scripts/manual/run_upstream_mirrored_av_sync.sh index dc2b365..695ed82 100755 --- a/scripts/manual/run_upstream_mirrored_av_sync.sh +++ b/scripts/manual/run_upstream_mirrored_av_sync.sh @@ -29,6 +29,7 @@ LESAVKA_SYNC_APPLY_CALIBRATION=${LESAVKA_SYNC_APPLY_CALIBRATION:-0} LESAVKA_SYNC_SAVE_CALIBRATION=${LESAVKA_SYNC_SAVE_CALIBRATION:-0} LESAVKA_SYNC_CALIBRATION_TARGET=${LESAVKA_SYNC_CALIBRATION_TARGET:-video} LESAVKA_SYNC_CALIBRATION_SEGMENTS=${LESAVKA_SYNC_CALIBRATION_SEGMENTS:-1} +LESAVKA_SYNC_CONTINUOUS_BROWSER=${LESAVKA_SYNC_CONTINUOUS_BROWSER:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}} LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3} STIMULUS_PORT=${STIMULUS_PORT:-18444} STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10} @@ -438,14 +439,21 @@ start_real_lesavka_client() { 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 reuse_browser_session=0 + if [[ "${LESAVKA_SYNC_CONTINUOUS_BROWSER}" == "1" && "${segment_index}" != "1" ]]; then + reuse_browser_session=1 + fi mkdir -p "${segment_output_dir}" echo "==> starting Tethys browser consumer and mirrored driver (${segment_label})" + echo " ↪ browser_consumer_reuse_session=${reuse_browser_session}" BROWSER_RECORD_SECONDS="${record_seconds}" \ PROBE_DURATION_SECONDS="${PROBE_DURATION_SECONDS}" \ BROWSER_SYNC_DRIVER_COMMAND="${driver_command}" \ + BROWSER_CONSUMER_REUSE_SESSION="${reuse_browser_session}" \ SYNC_ANALYZE_EVENT_WIDTH_CODES="${PROBE_EVENT_WIDTH_CODES}" \ LOCAL_OUTPUT_DIR="${segment_output_dir}" \ LESAVKA_SERVER_ADDR="${RESOLVED_LESAVKA_SERVER_ADDR}" \ @@ -462,7 +470,7 @@ run_mirrored_segments() { echo "==> mirrored calibration ${segment_label}" print_upstream_calibration_state "before ${segment_label}" "${segment_dir}/calibration-before.env" print_upstream_sync_state "before ${segment_label}" "${segment_dir}/planner-before.env" - if run_browser_capture_with_real_driver "${segment_label}" "${segment_dir}"; then + if run_browser_capture_with_real_driver "${segment_label}" "${segment_dir}" "${segment}"; then maybe_apply_probe_calibration "${segment_dir}" "${segment_label}" print_upstream_sync_state "after ${segment_label}" "${segment_dir}/planner-after.env" print_upstream_calibration_state "after ${segment_label}" "${segment_dir}/calibration-after.env" diff --git a/server/Cargo.toml b/server/Cargo.toml index ce6dd62..491848f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.17.15" +version = "0.17.16" 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 df1a4d4..d7da227 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/testing/tests/client_manual_sync_script_contract.rs @@ -73,9 +73,13 @@ fn browser_sync_script_can_delegate_to_a_real_path_driver() { for expected in [ "BROWSER_RECORD_SECONDS=${BROWSER_RECORD_SECONDS:-${PROBE_DURATION_SECONDS}}", "BROWSER_SYNC_DRIVER_COMMAND=${BROWSER_SYNC_DRIVER_COMMAND:-}", + "BROWSER_CONSUMER_REUSE_SESSION=${BROWSER_CONSUMER_REUSE_SESSION:-0}", "SYNC_ANALYZE_EVENT_WIDTH_CODES=${SYNC_ANALYZE_EVENT_WIDTH_CODES:-}", "==> running custom browser sync driver", "bash -lc \"${BROWSER_SYNC_DRIVER_COMMAND}\"", + "browser_start_token=${browser_start_token}", + "uploaded_start_token", + "BROWSER_START_TOKEN", "--event-width-codes", "--report-dir \"${LOCAL_REPORT_DIR}\"", "for attempt in 1 2 3 4 5", @@ -116,9 +120,12 @@ fn mirrored_sync_script_uses_real_client_capture_path() { "LESAVKA_SYNC_CALIBRATION_TARGET=${LESAVKA_SYNC_CALIBRATION_TARGET:-video}", "LESAVKA_SYNC_ADAPTIVE_CALIBRATION=${LESAVKA_SYNC_ADAPTIVE_CALIBRATION:-0}", "LESAVKA_SYNC_CALIBRATION_SEGMENTS=${LESAVKA_SYNC_CALIBRATION_SEGMENTS:-1}", + "LESAVKA_SYNC_CONTINUOUS_BROWSER=${LESAVKA_SYNC_CONTINUOUS_BROWSER:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}", "LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}", "LESAVKA_SYNC_ADAPTIVE_CALIBRATION", "LESAVKA_SYNC_CALIBRATION_SEGMENTS=4", + "browser_consumer_reuse_session=${reuse_browser_session}", + "BROWSER_CONSUMER_REUSE_SESSION=\"${reuse_browser_session}\"", "LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer", "run_mirrored_segments", "summarize_adaptive_probe_metrics",