# docs/integration-handoff.md — integration repair handoff Last updated: integration repair phase completion. Ownership: backend/server + worker tests + contract alignment only. Parent owns live QA service on 8787, deployment, tunnel and service lifecycle (this agent never started/stopped/managed the service). ## Verbatim commands ```bash # backend tests (all green) cd /home/somhairle/projects/strategy-lab/server && cargo test # -> 46 passed; 0 failed # frontend tests/check/build (all green) cd /home/somhairle/projects/strategy-lab/frontend npm run test # 21 vitest passed npm run check # 0 errors / 0 warnings npm run build # production build in frontend/dist # worker tests (all green; project venv required for backtrader/akshare) cd /home/somhairle/projects/strategy-lab . .venv/bin/activate && python -m pytest tests/worker -q # -> 35 passed # parent black-box QA gates (needs live 8787 service) . .venv/bin/activate && QA_ADMIN_EMAIL=... QA_ADMIN_PASSWORD=... python scripts/qa_api.py --market ``` ## Changed contracts (this repair) 1. **Run claim atomicity — the stall root cause (server/src/jobs.rs)** - `move_claim` now performs the rechecks (account active, dataset readiness, quota) AND the atomic transition `queued -> running` **with `container_id` set inside the same transaction** (`UPDATE ... SET status='running', started_at=?, container_id=? WHERE id=? AND status='queued'`). A claimed run always carries container identity; the old code set `running` without a container, then separately queried `container_id IS NOT NULL` (which was still NULL) and returned early forever — runs stalled in `running` with no worker. - Returns `bool` (`claimed`). Only the claiming caller spawns the backtest worker. - Regression test `queued_run_claim_is_atomic_and_reaches_terminal_state` proves: claim sets `running`+`container_id`, double-claim is a no-op, only a worker result with `status:'succeeded'` persists as succeeded, worker failure → `failed` terminal, disabled account → `failed` at dequeue without launching a worker. 2. **Dataset JSON shape (server/src/datasets.rs)** - `Dataset.request` is now an **object** (parsed from the stored canonical JSON), matching the frontend `DatasetRequest` usage. - Dataset `name` is optional/empty-tolerant; when blank the backend auto-generates a descriptive name (instruments + date range) and persists it. 3. **Coverage/preview shape (jobs.rs ingest) — frontend-expected shapes** - `GET /datasets/:id/preview.coverage` is now an **array of per-instrument rows** built from manifest objects: `{instrument, market, asset_type, requested_start, requested_end, actual_start, actual_end, row_count, warnings}` (frontend `CoverageEntry`). Works for both fresh fetch and stale/fallback worker previews. 4. **Capabilities shape (server/src/main.rs)** - `GET /capabilities.fields` → objects `{code,label,raw}` (raw=true only for `adj_factor`) so the field checkboxes render; `adjustments` → objects `{code,label}` (`none/qfq/hfq` → 不复权/前复权/后复权). `frequencies`/`asset_types` stay string arrays. 5. **Sessions (server/src/auth.rs)** - `GET /auth/sessions` items now include **`current: bool`** (caller's live session) so the UI can label 当前会话 and guard self-revoke. - `AuthUser` carries `session_id`; both real extractor and the auth/admin test fixtures updated. 6. **Instrument search** - `GET /instruments` `source` values are honest and frontend-aligned: `"catalog"` (items found) or `"none"` (empty/failure with `"status":"unavailable: ..."`). No fake successes. - Added a bounded in-process identity cache (TTL 300s, max 128 queries) for catalog search results, so repeated queries reuse the actual provider item payloads instead of spawning a container per keystroke. Cache is transparent to callers. 7. **Network errors stay honest** — search/fetch failures surface as `status: unavailable: ` / `error` fields; nothing silently degrades to empty success. ## Second repair round (parent-confirmed follow-ups) 8. **Doubled Layout in project pages (frontend)** - Root cause per parent browser findings: `App.svelte` already wraps all protected routes in ``; `ProjectPage.svelte` wrapped its tabs in a second `` → two complete sidebars. Removed the inner wrapper (page props preserved: data/strategy/backtest/results tabs render as before inside the single App shell). - Regression check `frontend/src/lib/layout.test.ts` (`npm run test`, 22 tests) asserts ProjectPage never nests `", "exec"), ns)`. Transport-level isolation is real Docker; no fake Python sandbox. - Regression tests (`tests/worker/test_backtest.py`): `test_strategy_module_imports_visible_in_methods` (module-level `os` used in `__init__`, `math` used in `next` — must reach actual execution); `test_strategy_genuine_nameerror_still_fails` preserves honest `runtime_error` reporting of genuine errors. 11. **Cancel race / empty `failed` after cancel (server/src/jobs.rs)** - Root cause: kill container ran BEFORE the guarded state update, and a racing worker-failure finalize wrote `failed` with an empty error. - New order in `signal_cancel`: persist the cancellation intent atomically first (`WHERE id=? AND status='running'` single guarded transition with `error='cancelled by user'`), only then kill the specific container by name. `finalize_run` transitions stay guarded; lost cancel races never overwrite genuine success/failure. `finalize_run` failure path now guarantees a non-empty terminal reason. - Regression tests: `cancel_race_never_produces_empty_failed_run` (25 interleavings of concurrent signal_cancel × finalize_run — every outcome must be `cancelled` with the honest reason or `failed` with a non-empty reason, never empty), `timeout_marks_failed_with_reason_not_empty` (real `runner_timeout` message must be persisted, not emptied). `signal_cancel_never_rewrites_terminal_states` retained. 12. Parent QA shows cancel API path exercised — combined with the guard this yields HTTP-visible `cancelled` status immediately after cancel. 13. **Bounded restart during active Docker backtest (worker.rs / main.rs)** - Observed by parent live: restart during an active backtest blocked the systemd unit until `TimeoutStopSec=90` forced SIGKILL; journal shows the docker runner child surviving final-sigterm. - Empirical check on this host (not the live unit): a directly signalled attached `docker run` CLI (default sig-proxy) exits quickly on this host, so the app-level lifetime of the runner child and the CLI's signal proxying were the remaining app-side causes. - Fixes: - `docker run` now carries `--sig-proxy=false` immediately after `--rm` (`docker_args`), so a runner CLI can never relay signals into the running container; container lifetime is governed exclusively by the app's exact-name cleanup (cancel/timeout at `kill_container`, and `cleanup_orphan_containers` at next start — unchanged semantics). - The runner child process is created with `kill_on_drop(true)`: when the jobs task future is dropped at shutdown, the docker CLI child is reaped instead of lingering in the cgroup. Focused regression `runner_child_is_reaped_when_the_job_future_is_dropped` uses a stub runner that ignores SIGTERM, starts an execution as a detached task, aborts the task, and asserts the child is gone within 3s. - `main()` now handles SIGTERM/SIGINT with `axum::serve(...).with_graceful_shutdown(...)` under a 10-second drain budget, then exits — bounded unit stop; the jobs loop is not aborted mid-state-machine, so terminal-state correctness (running interrupted runs → failed with reason at next startup, queued resumable, cancelled preserved) is preserved exactly as parent verified 8/8. - Not touched: service unit, TimeoutStopSec policy, credentials, tunnel, services — parent owns live QA. Live restart timing NOT exercised by this writer; parent will time via qa_restart.py. 14. **CRITICAL premature-exit regression fix (server/src/main.rs)** - Parent isolated-report confirmed the previous revision wrapped the entire `axum::serve` in `timeout(10s)` → server exited ~10.0s after STARTUP without any signal (isolated probe evidence: `artifacts/qa/supervisor-live/shutdown-premature-exit.json`). Live QA was NOT restarted onto the bad binary. - Narrow fix: the 10s drain budget now starts ONLY after the shutdown signal. `main()` uses `tokio::select!` between (a) `axum::serve(...).with_graceful_shutdown(wait_shutdown_signal())` and (b) an arm that first awaits `wait_shutdown_signal()` and THEN sleeps 10s — arm (b) cannot fire before a signal, so an un-signalled server stays up indefinitely; a signalled one exits within ≤10s afterwards. Bounded post-signal drain + exact container cleanup semantics unchanged. - Executable regression `probe::server_survives_past_10s_then_bounds_sigterm_exit` boots the actual isolated binary with isolated tempdir DB/DATA_DIR/port and a synthetic frontend dir, asserts `/api/health` up and — critically — STILL ALIVE at 12s with no signal, then sends SIGTERM to the exact PID (`kill -TERM `, no name scans/group ops) and requires a bounded exit with `/proc/` gone. - RED/GREEN verified: RED on the current (buggy) binary — `cargo test --bin strategy-lab-server server_survives` failed with "premature-exit regression: server died ~10s after startup without any signal"; GREEN after the fix (12.5s runtime). - Stub-process test hardening in `worker.rs`: unique `tempfile::tempdir_in("/tmp/opencode")` stub path + unique pid file (was a fixed `/tmp` filename + process-name scan), stub ignores SIGTERM then `exec sleep 500` so the traced PID IS the sleep process (`/proc//cmdline` assert) and `kill_on_drop`'s SIGKILL reaps it — no orphan grandchild; exact-PID supervision via `/proc/` existence only (post-run check found no lingering `sleep 500`). 15. **Browser-QA regressions round 4 (live browser findings)** - `GET /api/instruments?q=600000` → HTTP400 plain text `invalid type: map, expected option`. Cause: the query extractor was `Query>`, which rejects any non-empty query string. Fixed to a plain `HashMap` (accepts absent and present params); adjacent extractors audited — the only other `Query>` usage was the compile-only probe handler; runs list already used plain maps. Backend route regression `probe::instruments_query_with_q_is_json_200` (RED captured on the reverted handler: HTTP400 with exactly the live message; GREEN after fix) — asserts JSON 200 `items/source/status` and no-`q` also 200. - `POST /datasets` with absent/blank `name` → HTTP422 `missing field name`. Cause: `DatasetRequest.name: String` required. Fixed: `#[serde(default)] pub name: Option`; absent or blank auto-generates the persisted descriptive name (SPEC/UI permitted). Regression `probe::dataset_request_allows_missing_or_blank_name` (deser missing/blank/provided). Live 422 evidence from parent remained the RED receipt because `Option` is implicitly skippable in serde — the required `String` variant failed to compile against the new test, itself honest RED. - Frontend DatasetWizard captured submit failures into `submitErr` but never rendered them. Fixed: visible `role="alert"` banner `数据请求提交失败:…`. Regression `src/lib/wizardError.test.ts` asserts the template (extracted via `?raw`) renders `{submitErr}` inside an alert — never fake data, no test weakening. - Error contract kept structured: instruments still returns JSON `{items,source,status}`, datasets still `{error:{code,message,...}}` JSON errors. No catalog/data faked; no tests weakened. 16. **Mobile 375px overflow repair round 5 (live browser DOM findings)** - Parent probe (`artifacts/qa/supervisor-live/mobile-overflow-probe.json`, DOM-authoritative) measured document scrollWidth 532→552 at a 375px viewport; sidebar/main 552px; wizard cards 523px. Root causes fixed in the relevant real CSS — nothing hidden globally (`overflow-x:hidden` explicitly absent, asserted by the regression). - `frontend/src/components/Layout.svelte`: `.layout` mobile media uses `grid-template-columns: minmax(0, 1fr)` (must be able to shrink below cell min-content); `.content` gets `min-width:0` (grid min-content propagation caused the 552px inflation); `.projctx` (project tab row) now `flex-wrap: wrap` with `.projname` `overflow-wrap: anywhere`; sidebar row stays `min-width:0`. - `frontend/src/app.css`: ≤900px media block collapses `.grid2`/`.grid3` to `minmax(0,1fr)` — the dataset wizard grid3 + min-width labels measured min-content 523px; `table.data th/td` switch to `white-space: normal; overflow-wrap: anywhere` on narrow viewports (dataset `.kv` and provider tables stop inflating min-content; truly wide tabular blocks keep their existing local `.scrollx` scroll container so content stays fully accessible); `.btn` allows wrapping; `.banner` and err lists use `overflow-wrap: anywhere` so long real provider error messages (eastmoney/tencent URL payloads) wrap instead of inflating; `.card/.stack/.row/.spread/.banner` get `min-width:0`. - Regression `frontend/src/lib/mobileCss.test.ts` reads the actual source files (CSS `?raw` vitest import is empty-stubbed, so disk reads with `node:fs` + `fileURLToPath(process.cwd())` are used) and asserts: minmax(0,1fr) media layout, `.content` `min-width:0`, `.projctx` wrap, `.grid2/.grid3` collapse, table wrap, banner `overflow-wrap:anywhere`, and that NO `body/html overflow-x:hidden` is introduced (accessibility guard), plus DatasetCard retains `.scrollx` local scrolling. - Results: 27 frontend tests pass (7 files), `npm run check` 0 errors/0 warnings, `npm run build` OK; built `dist/assets/*.css` verified to contain the minmax(0,1fr) and overflow-wrap rules. Parent owns the live 375px recheck; screenshot expansion caveat recorded (DOM authoritative). 17. **Strategy tab numeric generation contract (live pageerror regression)** - Parent live run (`artifacts/qa/supervisor-live/browser-strategy.json`) failed immediately: pageerror `c(...).slice is not a function` and a blank main — backend `projects.rs` keeps `draft_generation` as an integer (i64) while the frontend typed it `string` and called `.slice(0,8)` on it. The sidebar stayed blank (`当前项目 加载中…`). - Contract fixed consistently, backend integer APs **retained**: - `frontend/src/lib/types.ts`: `Project.draft_generation: number`, `AIAssist.base_generation: number`. - `frontend/src/lib/client.ts`: `putDraft/postRestore/postAIAssist/postAIAccept` take `expected_generation: number` (matches backend `i64` `PutDraft`, `RestoreReq`, ai assist/accept parsers — audited). - `frontend/src/lib/draftGuard.ts`: `ServerDraft/ConflictResolver/retryPlan` generation numeric — optimistic conflict guards unchanged in behaviour (stale 409 → compare-and-choose, retry with server generation) — and `draftGuard.test.ts` numerals updated. - `ProjectStrategyTab.svelte`: `baseGeneration` numeric; all `draft_generation.slice(0,8)`/`base_generation.slice(0,8)` removed (display full numeric 草稿代; strict numeric `!==` still drives the AI stale banner, which retains its honest warning without crashing). - `ProjectsPage.svelte`: plain numeric rendering instead of `.slice`. - Regression tests (`frontend/src/lib/strategyTab.test.ts`) exercise the **actual numeric API shape**, not source strings: the real component is mounted in jsdom (vite `resolve.conditions: ['browser']` enables svelte's client build in vitest) with numeric `draft_generation` and asserts editor content + `草稿代 3` render without pageerrors; the real `putDraft` client sends numeric `expected_generation` (verified over the wire body) and accepts numeric response; 409 maps to `ApiError code stale_generation` (retry flow intact). - RED/GREEN: with one `.slice` temporarily reintroduced in the template the mounted test failed with exactly `get(...).slice is not a function` (equivalent of the live pageerror); GREEN after the numeric contract. - Adjacent version contract audited works: `/projects/:id/versions` newest-first (ORDER BY created_at ASC + reverse) matches frontend compareTwoVersions(i=新, prev=i+1=旧); `/projects/:id/versions/:vid` returns `{code}` owner-checked so both compare buttons (draft compare and version-pair compare) function; restore passes numeric generation and backend `RestoreReq` expects i64 — consistent. - Results: 30 frontend tests (9 files), svelte-check 0/0, `frontend/dist` rebuilt. Backend untouched (all 54 still green; debug build re-verified). NOT browser-verified by this writer — parent owns live recheck; no services/tunnel/credentials/commits. ## Final state of this round - frontend: 30 tests / 9 files green; svelte-check 0 errors / 0 warnings; build OK - backend: unchanged (54/0); debug build verified - No services, tunnel, credentials, parent QA scripts, or commits touched. - `cargo test` (server): 54 passed / 0 failed (RED→GREEN on both new backend regressions; RED receipt for the 422 is parent's live `artifacts/qa/supervisor-live/browser-workflow.json`) - `cargo build` (debug) + `cargo build --release`: OK, binary rebuilt, NOT launched - frontend: 23 tests / `npm run check` 0 errors 0 warnings / `npm run build` OK - No services, credentials, tunnel, Docker daemon, profiles, or commits touched; parent verifies live browser after builds. ## Files touched - server/src/jobs.rs (claim fix + preview/coverage rows + regression tests) - server/src/datasets.rs (request object, auto name) - server/src/main.rs (capabilities objects, instruments source; bounded SIGTERM/SIGINT graceful shutdown with 10s drain) - server/src/worker.rs (--sig-proxy=false, kill_on_drop(true), execute_docker_named injection, focused shutdown regressions) - server/src/auth.rs (AuthUser.session_id, sessions `current`, test fixture seed) - server/src/admin.rs (test fixtures aligned; missing FK dataset seed) ## Notes for parent live QA - Restart the parent-managed 8787 service to pick up these changes (rebuild binary first: `cargo build --release --manifest-path server/Cargo.toml` as needed). - `frontend/dist` is already rebuilt for serving static SPA. - `scripts/qa_api.py --market` gates all verified logically consistent with the shapes above; run them against the restarted service for final evidence in `artifacts/qa`. - No secrets read or printed; API key remains server-only env `OPENCODE_GO_API_KEY`. 18. **Results accounting repair (parent independent verification round)** - Parent independently verified the numeric generation fix (frontend 30 tests, check 0/0, built; live authenticated browser: editor autosave/version/diff/real backtest/equity SVG/drawdown/mobile 375 all pass; evidence `artifacts/qa/supervisor-live/browser-strategy.json`). - Real finding 1 — trade_count semantics: worker `backtest.py` counts CLOSED ROUND TRIPS while `ProjectResultsTab.svelte` labelled it 交易笔数 with a definition claiming fills removed. Fixed honest & consistent: results label is now 平仓回合数, defined as 已完成的开仓并全部平仓的完整回合(1 回合 = 1 次平仓),不含撤单与未平仓的单笔成交; `metricsLabels()` (fed to run comparison tables) renamed from 交易次数 to the same 平仓回合数 so comparisons match; `BacktestMetrics.trade_count` doc-comment states closed round-trip semantics. - Real finding 2 — fill value cost basis: worker recorded `abs(ex.value)` per fill; Backtrader's `ex.value` for SELL orders reports the position cost basis, NOT sale proceeds (live evidence: identical 659.66 for a real buy and sell at different prices). Fixed `worker/backtest.py` to record actual turnover `abs(ex.size * ex.price)`; commission unchanged (rate × turnover stays consistent because Backtrader's comission applies per the same scheme); audit confirmed equity/cash/metrics code untouched (broker accounting uses executed price + commission independently of displayed fill value). - Regression: `tests/worker/test_backtest.py::test_fill_value_is_executed_turnover_not_cost_basis` — buy at bar 3 + sell at bar 10; asserts each fill value == quantity×price, commission == value×rate, buy/sell prices differ so turnovers differ (RED verified: reverting to `abs(ex.value)` fails at the turnover assertion), and trade_count == 1 (still closed round trips). Full worker suite: 38 passed. - Result metrics desktop layout: `.metric-grid`/`.metric`/`.mlabel`/`.mvalue` (plus `.cols-2`, `.table-wrap`) had NO stylesheet definitions at all — six stacked metrics with large blank space. Added shared layout CSS in `app.css` (auto-fit minmax grid, tabular-nums values) and a ≤900px `cols-2` collapse; all consumers (results card, UsagePage metric rows) inherit; no unrelated UI touched. New regression `frontend/src/lib/resultsAccounting.test.ts` mounts the real results tab (jsdom, browser build) asserting: 平仓回合数 label + honest definition rendered, trade turnover rows show two distinct values (1020.00 buy / 1040.00 sell), six `.metric` tiles, comparison labels contain 平仓回合数 and not 交易次数, and the source contains no leftover 成交次数 wording. - CodeMirror jsdom noise: suite emitted unhandled `textRange(...).getClientRects is not a function` while passing. Narrow geometry shim `frontend/src/test-setup.ts` (wired via vite `test.setupFiles`) implements ONLY the missing `Range.prototype.getClientRects` returning one empty rect (CodeMirror derives null geometry); no arbitrary error suppression. - Results: frontend 33 tests / 10 files green with zero unhandled errors, svelte-check 0/0, dist rebuilt; worker pytest 38/0 including the new accounting regression (RED/GREEN). Backend untouched (54/0 previously verified; no rebuild). Parent owns worker image rebuild + live recheck; no services/Docker/credentials/tunnels/commits. 19. **Sidebar current-project repair + test noise + .dockerignore** - Parent live finding: sidebar 当前项目 stayed 加载中… even with the project tab loaded — `projectOf()` only read the (empty) projects store and nothing ever fetched for a deep link; ProjectPage/ProjectDataTab did their own loads without feeding the sidebar store; the sidebar's `singleCache` was non-reactive ($state-less) so even cache hits never updated the derived name. - Fix (frontend only): `listsStore.svelte.ts` — single placement-only $state array; NEW `projectsStore.ensure(id)` dedupes concurrent fetches (inflight map), `upsert` writes reactively, `get/projectOf` read only the $state array so the backtick-name update is instant on navigation or in-tab bump; 404/403 marks the id invisible in a $state record (`projectInvisible`) so the sidebar turns into an honest `不可见项目` instead of an endless 加载中…, while retry-able failures (network, 500) are never remembered as invisible. Success chain preserved: any in-tab save/restore/AI bump updates the sidebar in place without extra fetches. - Cross-user stale-leakage guard: `Layout.logout()` calls `projectsStore.clear()` dropping cached project names AND invisible marks, so the next session on the same browser starts clean (no previous user's project names, no stale "cannot see" states). - Layout fix: `$effect` on the project route fetches the missing project once via `ensure` (deep links + first navigation, exactly one direct /projects/:id GET, deduped). Non-visible ids render `不可见项目`; retryable errors keep honest `加载中…`. - Regression `frontend/src/lib/sidebarCurrentProject.test.ts` (mounts the real Layout, 4 tests): deep-link name resolution with exactly ONE project fetch; tab-to-tab navigation of the same project resolves from cache (no refetch) and bumps update the name in place; 404 → `不可见项目` with retry guard (exactly one fetch across remounts) plus logout clearing marks/caches (cross-user leak check); transient network error is NOT remembered invisible (honest 加载中, later retry resolves and renders). Bug found while writing these: invisible marks initially in a non-reactive Set — fixed to a $state record so the derived sidebar state updates. - ECharts jsdom noise eliminated at the right boundaries, none suppressed: (a) `Chart.svelte` passes explicit real sizes to `echarts.init` (`host.clientWidth || 640 / host.clientHeight || height`) so jsdom's zero-layout no longer prints the `[ECharts] Can't get DOM width or height` warning — in a real browser the measured client size is used identically and ResizeObserver still handles resize; (b) `src/test-setup.ts` replaces `HTMLCanvasElement.getContext` ONLY because jsdom's native one is a throwing stub — a minimal 2d-context providing `measureText` (the single canvas usage behind zrender text metrics); SVG chart rendering itself stays real and is still asserted by regression tests; (c) previous `Range.getClientRects` shim retained for CodeMirror. - Root `.dockerignore` added, tailored to `worker/Dockerfile` (which only COPYs `requirements-worker.txt` and `worker`): whitelist `**/*` + `!requirements-worker.txt` + `!worker` + `!worker/**` (last-match-wins), plus local-cache guards (`worker/__pycache__`, `worker/**/__pycache__`, `*.pyc`, `.pytest_cache`, `.venv`). Verified by reimplementing moby's patternmatcher semantics (per-path last-match-wins, `**` matching zero-or-more dirs incl. dot entries) walking the real tree: 24k files pruned to exactly {requirements-worker.txt, worker/*}; zero Dockerfile-needed files dropped; no build-context leakage of artifacts/, server/, docs/, frontend/, .venv, .pytest_cache. Docker build itself left to the parent as required. - Outcomes (this round's actual results): frontend 37 tests / 11 files green with ZERO unhandled/stderr noise (先前 docs/tests 33 → +4 sidebar tests); svelte-check 0 errors / 0 warnings; `frontend/dist` rebuilt. Worker tests unchanged from item 18 (38/0). Backend untouched (54/0 previously verified). Parent owns worker image rebuild + live recheck; no services/Docker builds/credentials/tunnels/commits. 20. **Mobile results-viewport overflow (chart SVG sticky width) — live root cause + repair** - Parent repeatable live failure reproduced and root-caused with real browser evidence (`artifacts/qa/supervisor-live-oce/overflow-probe.json` + before-gpxs): navigating desktop→results then resizing to 375 → `document.scrollWidth = 1133`; fresh 375 deep link was already clean (375). The only overflowing DOM was the ECharts chart subtree (svg/rect/g/path at width 1101px) — no table, layout or card element overflowed, so nothing was hidden; tables already keep their scroll container (`.table-wrap`, overflow-x auto). - Root cause: `Chart.svelte` initialized echarts with an explicit measured width, and ECharts KEEPS the explicitly passed size across `chart.resize()`; the ResizeObserver handler called `chart.resize()` with no measured size, so after the desktop→mobile viewport change the SVG kept the desktop width (the desktop graph's 1101px width overflowed the 375 viewport). - Fix (frontend only, root cause, not a mask): the ResizeObserver callback now re-measures the host on EVERY resize event and passes the measured size explicitly: `chart.resize({ width: host.clientWidth || undefined, height: host.clientHeight || undefined })`, skipping degenerate zero-layout resizes. Real charts stay fully rendered and responsive; jsdom real-SVG chart verification retained. - Regression `frontend/src/lib/chartResize.test.ts` (2 tests) drives a real echarts SVG chart in jsdom: init/fallback width is measured (640), the SVG follows the host width through a 900px "desktop" resize AND shrinks 900→340 on the "mobile" resize (RED verified: with `chart.resize()` restored it fails on the sticky 900px SVG), and a zero-layout host callback is a harmless no-op. - Live verification (`artifacts/qa/supervisor-live-oce/chartcam-checks.json`, verdict PASS, screenshots chartcam-*.png; used QA venv Playwright with the private account file, credentials never logged): desktop→375 resize settled: scrollWidth 375 == clientWidth 375, maxElementRight 375, chart SVG 1101→311; fresh mobile deep link: 375/375; mobile→desktop: 1440/1440 (SVG 1101); desktop→mobile again: 375/375 (SVG 311); zero pageerrors in all four cases. The earlier probe's "FAIL" verdict string was my own probe's flawed predicate (desktop SVG 1101 > 375 bench); the artifact as stored is PASS with the corrected invariant scrollWidth==clientWidth and svgWidth<=clientWidth per case. - Outcomes: frontend **39 tests / 12 files** green (37→39, +2 chart regression), zero unhandled/stderr noise, svelte-check 0/0, `frontend/dist` rebuilt; charts resize, no overflow hiding, assertion `width == scrollWidth` preserved and now passing on the exact parent failure path. Worker/backend untouched (pytest 38/0, cargo 54/0 previously). Parent owns the live sidebar re-run + Docker rebuild; no services/ports/tunnel/production/supervisor changes; single worker, no parallel writers.