diff --git a/.claude/nocache-server.py b/.claude/nocache-server.py index 367cc2ae..2b42dff8 100644 --- a/.claude/nocache-server.py +++ b/.claude/nocache-server.py @@ -11,7 +11,9 @@ """ import http.server import os +import socket import sys +import threading # Serve from the repo root (two levels up from .claude/). ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) @@ -31,8 +33,20 @@ def log_message(self, fmt, *args): def main(): port = int(sys.argv[1]) if len(sys.argv) > 1 else int(os.environ.get("PORT", "8091")) os.chdir(ROOT) - httpd = http.server.HTTPServer(("127.0.0.1", port), NoCacheHandler) - sys.stderr.write("Arena Studio dev server: http://127.0.0.1:%d/ (root: %s)\n" % (port, ROOT)) + # Threaded: with the single-threaded HTTPServer, Chrome's idle pre-connected sockets + # block every other request, so the Studio's module graph (≈ 60 imports) stalls and the + # whole - - - - + + + + @@ -3464,7 +3471,8 @@

Import error

- + + @@ -3692,7 +3700,7 @@

Import error

function renderRunEvent(ev) { if (!$('runlogBox') || !ev) return; const div = document.createElement('div'); - if (ev.phase === 'error') div.className = 'l-err'; + if (ev.phase === 'error' || ev.phase === 'bridge-lost') div.className = 'l-err'; else if (ev.phase === 'skip' || ev.phase === 'warn') div.className = 'l-warn'; // Reuse run-log.js's own transcript line if available, else a compact fallback. div.textContent = @@ -3701,6 +3709,13 @@

Import error

(ev.step && ev.step.conditionName ? ' · ' + ev.step.conditionName : '') + (ev.op ? ' ' + ev.op : '') + (ev.plugin_name ? ' ' + ev.plugin_name + (ev.command_name ? '.' + ev.command_name : '') : '') + + // Connector replies (BIAS etc.): outcome, round trip, and the artifact it produced. + (ev.op === 'connectorCommand' && ev.value + ? ' → ' + (ev.value.outcome || '?') + + (ev.value.rtt_ms != null ? ' · ' + Math.round(ev.value.rtt_ms) + ' ms' : '') + + (ev.value.value != null && typeof ev.value.value !== 'object' ? ' · ' + ev.value.value : '') + + (ev.value.artifact && ev.value.artifact.path ? ' · ' + ev.value.artifact.kind + ' ' + ev.value.artifact.path : '') + : '') + (ev.reason ? ' (' + ev.reason + ')' : ''); enqueueLogNode(div); } @@ -3740,7 +3755,7 @@

Import error

// broker has already aborted the runner (bench 2026-09-13, forced watchdog reset: the // terminal event arrived with fault null). The link being down at the terminal event // of an abort nobody requested is the same evidence — use either. - if (sm && !sm.fault && sm.aborted && !Studio._userStopRequested && (Studio._linkDropped || !(Studio.session && Studio.session.connected))) { + if (sm && !sm.fault && !sm.bridgeLost && sm.aborted && !Studio._userStopRequested && (Studio._linkDropped || !(Studio.session && Studio.session.connected))) { sm.fault = 'link_dropped'; sm.faultDetail = { kind: 'link_dropped', note: 'controller re-enumerated mid-run (watchdog self-reset?)' }; } @@ -3890,6 +3905,16 @@

Import error

// 2026-09-13: they used to land after the export and were lost from committed logs). const finalizeThenCommit = async () => { try { if (Studio.finalizeTrialQuality) await Studio.finalizeTrialQuality(log.meta && log.meta.run_id); } catch (_) { /* best effort */ } + // The run is over: close the bridge's run file (and, on the ROS bridge node, the + // MCAP bag) NOW — after the trial-quality finalizer and the post-mortem (this runs + // after both), before any upload, and whether or not the run is committed. A run + // with no course token, a Test run or an abort used to leave the file open until + // the next run. Only THIS run's file: a newer run may have opened its own since. + // "⇪ Push log" still works — the bridge exports its most recent file. + try { + const b = Studio.session && Studio.session.bridge; + if (b && b.connected && b.logging && Studio.activeRunLog === log) b.setLogging(false); + } catch (_) { /* best effort */ } await maybeCommitRunLog(log, summary); }; if (summary && summary.outcome === 'CONTROLLER_FAULT' && Studio.handleControllerFault) { @@ -3934,6 +3959,12 @@

Import error

rawLine('run log NOT auto-committed (' + outcome + ') — click "⇪ Push log" to commit it anyway', 'warn'); return; } + if (outcome === 'BRIDGE_LOST') { + // The bridge that wrote this run's file is gone; it cannot be exported from here. + rawLine('run log NOT auto-committed (BRIDGE_LOST) — the bridge’s file ' + + (log.bridgeFile || 'arena-log-*.jsonl') + ' stays on the bridge PC; ⬇ Save keeps this browser’s record', 'warn'); + return; + } await commitRunLog(log, {}); } // SHA-256 hex of a byte buffer (SubtleCrypto; secure-context only — the course @@ -4007,9 +4038,12 @@

Import error

return; } const bridge = Studio.session && Studio.session.bridge; + const lostRun = !!(log.summary && log.summary.outcome === 'BRIDGE_LOST'); if (!bridge || !bridge.connected) { showUploadModal('Run log not committed', { error: true, - msg: 'Bridge disconnected — the log file remains on the bridge machine. Reconnect the bridge (it re-serves the last run) and push again, or commit it from a local clone.' }); + msg: lostRun + ? 'This run lost its bridge. Its log file (' + (log.bridgeFile || 'arena-log-*.jsonl') + ') stays on the bridge PC — a restarted bridge cannot serve it. Commit it from there; ⬇ Save keeps this browser’s record.' + : 'Bridge disconnected — the log file remains on the bridge machine. Reconnect the bridge (it re-serves the last run) and push again, or commit it from a local clone.' }); return; } let exported; @@ -4022,6 +4056,19 @@

Import error

showUploadModal('Run-log export failed', { error: true, msg: m + '\n\nThe log stays on the bridge machine — nothing lost.' }); return; } + // log_export serves the bridge's current-or-most-recent file. After a bridge loss + // and restart that is a DIFFERENT file — never commit it under this run's name. + const runFile = log.bridgeFile || null; + if ((runFile && exported.name && exported.name !== runFile) || (lostRun && !runFile)) { + const m = runFile + ? 'The bridge served ' + exported.name + ', not this run’s log ' + runFile + + ' (the bridge was restarted or logged something else since).' + : 'This run lost its bridge, and the bridge did not name the run’s log file, so the file it serves now cannot be matched to this run.'; + rawLine('run log NOT committed — ' + m, 'err'); + showUploadModal('Run log not committed', { error: true, + msg: m + '\n\nThis run’s file is on the bridge PC (' + (runFile || 'arena-log-*.jsonl') + ') — commit it from there. ⬇ Save keeps this browser’s record.' }); + return; + } const stamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); const proto = GH.slug(String(log.meta.protocol_filename || 'protocol').replace(/\.ya?ml$/i, '')); const who = GH.slug(log.meta.experimenter || 'unknown'); @@ -4437,6 +4484,21 @@

Import error

setTimeout(() => b.classList.remove('show'), 8000); } Studio.showBanner = showBanner; + // Persistent alert for safety events (#runAlert): no auto-hide, not replaced by + // routine banners. Cleared by its ✕ or when the next run starts. + function showRunAlert(msg) { + const a = $('runAlert'); + if (!a) { showBanner(msg); return; } + $('runAlertTxt').textContent = msg; + a.classList.add('show'); + } + function hideRunAlert() { + const a = $('runAlert'); + if (a) a.classList.remove('show'); + } + Studio.showRunAlert = showRunAlert; + Studio.hideRunAlert = hideRunAlert; + if ($('runAlertClose')) $('runAlertClose').addEventListener('click', hideRunAlert); // ---- Safe mode (default student mode) ------------------------------------- // Safe mode is the DEFAULT: a plain load locks Edit, Console, the session-rig @@ -5828,6 +5890,29 @@

Import error

(d.lastError ? ' (' + d.lastError + ')' : ''), 'err'); showBanner('⚠ Controller stopped answering — run aborted. Post-mortem probes follow (see the run log).'); }); + // FicTrac bridge lost (LAB-303 S1): the socket dropped without a Disconnect. + // The broker has already stopped the run when it needed the bridge (closed loop, + // a FicTrac step, or a recorded run's log) — say so where nobody can miss it. + Studio.session.on('bridgelost', (e) => { + const info = (e && e.info) || {}; + const action = e && e.action; + const why = info.apply ? 'the closed loop was running' : 'the bridge connection dropped'; + const restart = ' Restart the bridge program on the rig PC before the next run.'; + let msg = null; + if (action === 'aborted') { + msg = '⚠ FicTrac bridge lost mid-run (' + why + ') — the run was STOPPED (outcome BRIDGE_LOST). ' + + 'The display was stopped and the closed loop is off. Devices the bridge drives (camera, microscope) ' + + 'were NOT told to stop — check them. The bridge’s log of this run ends at the loss; ' + + '⬇ Save keeps this browser’s record.' + restart; + } else if (action === 'continued') { + msg = '⚠ FicTrac bridge lost — this test run continues, but nothing after this point is in the bridge log.' + restart; + } else if (info.apply) { + msg = '⚠ FicTrac bridge lost — the closed loop stopped (the arena no longer follows the ball).' + restart; + } + rawLine('⚠ bridge lost' + (info.code != null ? ' (code ' + info.code + ')' : '') + + (action ? ' — run ' + (action === 'aborted' ? 'stopped' : action) : ''), 'err'); + if (msg) showRunAlert(msg); + }); Studio.session.on('disconnect', (info) => { if (info && info.involuntary) Studio._linkDropped = true; // consumed by runSteps (fault lifecycle) if (info && info.involuntary && Studio.activeRunLog && !Studio.activeRunLog.summary) { @@ -8667,8 +8752,11 @@

Import error

const PP = window.PatPreview; const BUILTIN_BASE = './patterns/g6_2x10/'; Studio.patternSet = null; -const webPreviewBySdName = new Map(); // filename → () => Promise -const repoSharedPreviewKeys = new Set(); // sources currently wired from course-repo patterns/ +// filename → () => Promise, LAYERED so precedence never depends on which +// listing arrives first: builtin < shared (repo patterns/) < colocated (_patterns/) +// < uploaded (js/studio-preview-sources.js). Reads see the effective source per filename. +const webPreviewBySdName = window.StudioPreviewSources.create(); +let sharedPreviewGen = 0; // the newest shared-library listing wins; an older reply is dropped // In-memory sources first (logical-name tolerant via webBytesForName). The // fallback for a pattern already on the card from a prior session is a live // 0x84 fetch of the WHOLE file (the controller has no ranged read), gated by @@ -9022,7 +9110,7 @@

Import error

const buf = u8.byteOffset === 0 && u8.byteLength === u8.buffer.byteLength ? u8.buffer : u8.slice().buffer; - webPreviewBySdName.set(f.name, () => Promise.resolve(buf)); + webPreviewBySdName.set('uploaded', f.name, () => Promise.resolve(buf)); }); patPreviewCache = {}; // drop cached renders so the new sources take effect const sel = $('cPatName'); @@ -9037,7 +9125,7 @@

Import error

m.source = 'manifest'; for (const n of Object.keys(m.patterns)) { const sd = m.patterns[n].sd_name; - webPreviewBySdName.set(sd, async () => { + webPreviewBySdName.set('builtin', sd, async () => { const r = await fetch(BUILTIN_BASE + 'patterns/' + sd, { cache: 'no-store' }); if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); @@ -9766,6 +9854,22 @@

Import error

.filter((p) => p && ((p.matlab && p.matlab.class === 'FicTracPlugin') || p.name === 'fictrac')) .map((p) => p.name); } +// Device plugins the BRIDGE drives for the runner (LAB-303 connectors): plugin instance +// name → connector id. 'bias' (BiasPlugin → the bridge's BIAS node) and 'scanimage' +// (ScanImagePlugin → the bridge's ScanImage node, the microscope's JSON server); the +// runner sends each command as connector_command and waits for the reply, so a camera or +// microscope plugin in a protocol acts for real instead of being skipped. +function connectorPluginNames(exp) { + const m = new Map(); + if (!exp || !Array.isArray(exp.plugins)) return m; + for (const p of exp.plugins) { + if (!p) continue; + const cls = p.matlab && p.matlab.class; + if (cls === 'BiasPlugin' || p.name === 'camera') m.set(p.name, 'bias'); + else if (cls === 'ScanImagePlugin' || p.name === 'scanimage') m.set(p.name, 'scanimage'); + } + return m; +} // ---- load / render a protocol ---- // The embedded designer (Studio.editor) is the single source of truth for the @@ -10246,7 +10350,7 @@

Import error

// Open loop: the controller steps the pattern itself (no 0x70s) — exposure is // in the telemetry FRAME rows, judged by telemetry-report.py, not here. const exercised = !closedLoop || applied >= Math.min(1000, 5 * durS); // ≥ 5 Hz average, or 1000 total - let outcome = !res ? 'not-started' : res.fault ? 'fault' : res.aborted ? 'aborted' : exercised ? 'completed' : 'unexercised'; + let outcome = !res ? 'not-started' : res.fault ? 'fault' : res.bridgeLost ? 'bridge-lost' : res.aborted ? 'aborted' : exercised ? 'completed' : 'unexercised'; logSoak({ phase: 'iteration-end', iteration: s.iteration, outcome, @@ -10266,7 +10370,8 @@

Import error

if (!pmo || (pmo.outcome !== 'recovered' && pmo.outcome !== 'self-reset')) { reason = pmo ? 'fault-' + pmo.outcome : 'fault'; break; } s.resets++; Studio.rawLog('soak: controller recovered (reset ' + s.resets + '/' + s.maxResets + ') — continuing', 'warn'); - } else if (res.aborted) { reason = 'operator-stop'; break; } + } else if (res.bridgeLost) { reason = 'bridge-lost'; break; } + else if (res.aborted) { reason = 'operator-stop'; break; } if (s.stop) { reason = s.stopReason || 'operator'; break; } // Gap + input freshness re-check (a dead simulator is a harness // problem, not a controller fault — pause, don't count it). @@ -10670,6 +10775,20 @@

Import error

const ackLevel = bridge.connected && bridge.waitForLogLevelAck ? await bridge.waitForLogLevelAck(800) : null; const logLevel = ackLevel || (wantLevel === 'behavior_v2' ? 'behavior_v1' : wantLevel); Studio._runLogFormat = bridge.connected ? logLevel : null; + // The file the bridge opened for THIS run (log_control_ack). A later push checks + // that log_export serves it — a bridge restarted after a loss serves another file. + if (Studio.activeRunLog) { + const runLog = Studio.activeRunLog; + runLog.bridgeFile = (bridge.connected && bridge.logFile) || null; + // A slow ack (> 800 ms on a loaded rig PC) names the file after this point: take + // it then, for THIS run only, so the push check is not silently skipped. + if (!runLog.bridgeFile && bridge.connected && typeof bridge.on === 'function') { + const off = bridge.on('loglevel', (info) => { + if (Studio.activeRunLog !== runLog) { off(); return; } + if (info && info.enabled && info.file) { runLog.bridgeFile = info.file; off(); } + }); + } + } const levelNote = !bridge.connected ? '' : ackLevel === wantLevel ? ' (' + ackLevel + ')' : ackLevel ? ' ⚠ bridge too old for ' + wantLevel + ' — logging ' + ackLevel + ' (restart `pixi run bridge` from the current checkout)' @@ -10726,6 +10845,7 @@

Import error

let summary = null; Studio._userStopRequested = false; Studio._linkDropped = false; + if (Studio.hideRunAlert) Studio.hideRunAlert(); // a new run starts with no stale bridge-lost alert if (Studio.trialQuality) Studio.trialQuality.reset(); // per-trial stimulus-quality verdicts start fresh Studio._tqFinal = null; // finalizeTrialQuality() runs once per run (first caller wins, others await it) // Bind the verdict to THIS run: a late terminal event from a previous (aborted) run must not finalize @@ -10745,6 +10865,11 @@

Import error

? Studio.resolveRuntimeCondition : undefined, fictracPluginNames: new Set(ftNames), + connectorPluginNames: connectorPluginNames(exp), + // A recorded run's record IS the bridge log (the run is gated on the bridge and + // the bridge is logging it), so losing the bridge stops it — not only when a + // FicTrac step needs it. With bridge logging off the record is in the browser. + bridgeRequired: !!isExperiment && !!(bridge && bridge.connected && bridge.logging), degPerFrame: Studio.rigDegPerFrame(), defaultCoupling: Studio._legacyDefaultCoupling == null ? 1 : Studio._legacyDefaultCoupling }); @@ -11840,23 +11965,20 @@

Import error

// as soon as its filename matches the repo library. Bytes stay lazy: this // request lists names only; the selected .pat is downloaded on first preview. // Public repos work signed out, while a token transparently enables private -// repos. Protocol-colocated sources are registered afterwards and may -// intentionally override a shared file with the same name. +// repos. Protocol-colocated sources override a shared file with the same name +// whenever either listing arrives (the 'colocated' layer outranks 'shared'). async function registerRepoSharedPatternPreviews(repo, token) { if (!repo) return; + const gen = ++sharedPreviewGen; try { const res = await GH.run(fetch, GH.reqGetContents(repo.owner, repo.name, 'patterns', null, token)); + if (gen !== sharedPreviewGen) return; // a newer listing (repo change / sign-in) was requested if (!res.ok || !Array.isArray(res.data)) return; - for (const key of repoSharedPreviewKeys) webPreviewBySdName.delete(key); - repoSharedPreviewKeys.clear(); - let n = 0; - res.data + const entries = res.data .filter((f) => f && f.type === 'file' && /\.pat$/i.test(f.name)) - .forEach((f) => { - n++; - repoSharedPreviewKeys.add(f.name); - webPreviewBySdName.set(f.name, () => repoFileBytes(repo, f.path)); - }); + .map((f) => [f.name, () => repoFileBytes(repo, f.path)]); + webPreviewBySdName.replaceLayer('shared', entries); + const n = entries.length; if (n) { patPreviewCache = {}; const sel = $('cPatName'); @@ -11890,7 +12012,7 @@

Import error

.forEach((f) => { n++; names.add(sdLogicalName(f.name)); - webPreviewBySdName.set(f.name, () => repoFileBytes(repo, f.path)); + webPreviewBySdName.set('colocated', f.name, () => repoFileBytes(repo, f.path)); }); if (n) { // The colocated _patterns/ set IS this protocol's web pattern set @@ -12150,13 +12272,27 @@

Import error

// bogus/stale params, stamping the {studio, mode} own-refresh marker, and // never clobbering a mode the user clicked during the awaits below. Studio._urlSuppress = true; + // S2 (ww18 2026-10-15, LAB-303): a tab opened at ?p=… showed no protocol until it was + // refreshed. The ?p= load used to wait behind the course repo's shared-pattern listing + // (a GitHub API call with no timeout). Rules now: (1) nothing that leaves this origin is + // awaited before the protocol load — the GitHub listings below are fire-and-forget, which + // is safe because preview sources are LAYERED (js/studio-preview-sources.js); (2) every + // same-origin pre-step is guarded — a failure in rig/vocab population logs a warning + // instead of silently skipping the protocol load. + const guarded = async (name, fn) => { + try { await fn(); } catch (e) { Studio.rawLog('init: ' + name + ' failed — ' + (e && e.message ? e.message : e), 'warn'); } + }; try { - await populateMetaDatalists(); - await populateSessionRig(); + await guarded('metadata vocabularies', populateMetaDatalists); + await guarded('session rig', populateSessionRig); wirePatPicker(); loadBuiltinPatternSet(); - await refreshRepoSharedPatternPreviews(); // shared patterns/ previews do not depend on a protocol - rewireRepoPatternPreviews(); // restore last-opened course protocol's colocated thumbs + // Best-effort GitHub listings — NEVER awaited here: a stalled api.github.com request + // used to hold the `?p=` load below until the page was refreshed (LAB-303 S2, ww18 + // 2026-10-15). Order no longer matters: preview sources are layered, so a late shared + // listing cannot override the colocated sources rewired here. + refreshRepoSharedPatternPreviews().catch(() => {}); // shared patterns/ previews + rewireRepoPatternPreviews().catch(() => {}); // restore last-opened course protocol's colocated thumbs let allowedKeys = []; let protoIndex = []; try { @@ -12202,6 +12338,10 @@

Import error

} catch (e) { Studio.showBanner('Protocol fetch failed: ' + e.message); } + } else { + // decode() dropped an unknown key with a rawLog warning only; say it where a + // student looks (S2 follow-up: a silent empty Run view is what "did not load" means). + Studio.showBanner('Protocol "' + state.p + '" is not in this site\'s protocol index (protocols/index.json).'); } } // ?rig= (#135): the per-setup bookmark. Applied AFTER the ?p= load so @@ -14918,7 +15058,14 @@

Import error

title: 'Stop the running pattern (STOP_DISPLAY 0x30) — best-effort queued via the shared session', onClick: () => { if (window.Studio.session) { window.Studio._userStopRequested = true; window.Studio.session.stop(); } } }, '■ Stop')); - const plugins = RunnerLib.listSkippedPlugins(cond); + // Plugins the runner drives are not "skipped": the built-in log, FicTrac, + // and connector devices (BiasPlugin through the bridge, LAB-303). + const driveable = new Set(['log']); + for (const p of experiment.plugins || []) { + if (p && p.matlab && (p.matlab.class === 'FicTracPlugin' || p.matlab.class === 'BiasPlugin')) driveable.add(p.name); + if (p && (p.name === 'fictrac' || p.name === 'camera')) driveable.add(p.name); + } + const plugins = RunnerLib.listSkippedPlugins(cond, driveable); wrap.appendChild(el('span', { class: 'arena-test-note' }, plugins.length ? 'unrecorded test · ' + plugins.length + ' plugin cmd' + @@ -18049,6 +18196,7 @@

Import error

'#btnAllOff': 'Turn every LED off.', '#btnLogSave': 'Download the run log as files (.json and .txt).', '#btnLogPush': 'Send the last run’s log to the course repo — use this if the automatic push was skipped or failed.', + '#runAlert': 'Something stopped your run or the closed loop (for example, the FicTrac bridge closed). The message says what happened and what to do; the run log keeps it too. ✕ hides it.', '#btnLogClear': 'Clear the log display (saved logs are not deleted).', '#btnLogCopy': 'Copy the log text to the clipboard.', '.seqlist': 'The experiment’s sequence. Each row is a condition — ▶ on a row tests just that one on the arena.', diff --git a/docs/development/arena-studio-release-notes.md b/docs/development/arena-studio-release-notes.md index 62e88c46..41e40f90 100644 --- a/docs/development/arena-studio-release-notes.md +++ b/docs/development/arena-studio-release-notes.md @@ -4,6 +4,21 @@ The Studio's footer used to carry the full changelog inline; it now shows one li history lives here. Newest first. (Per-session engineering detail stays in `arena-studio-handover.md` and the design docs — this file is the user-facing what-changed list.) +## v0.97 (2026-10-16) · A lost bridge ends the run (BRIDGE_LOST) · Shared `?p=` links load without waiting on GitHub (LAB-303 S1/S2) + + +- **Losing the FicTrac bridge mid-run now ends the run** instead of carrying on in silence. When the bridge's connection drops during a run that depends on it (a closed loop is armed, the protocol drives a camera or another connector, or the bridge is recording the run), the Studio shows a banner, stops the display, and records the run with the outcome **BRIDGE_LOST** (distinct from a controller fault, so no controller post-mortem runs). Restart the bridge, reconnect, and **⇪ Push log** to collect its record of the run. A user Disconnect, or a bridge the run never used, does not end the run. +- **A `?p=` link loads its protocol first.** Opening a shared protocol link no longer waits for the course repo's pattern listing from GitHub; on a slow or walled network that wait could leave the Run view empty until a refresh. A protocol key that is not in this site's index now says so in a banner. +- **Run-log pushes can no longer commit the wrong file after a bridge restart.** ⇪ Push log checks that the bridge serves this run's own log file and refuses otherwise, telling you where the file is on the bridge PC. +- **Protocols that list their variables (`- &name value`) no longer trigger a false "Variable … is defined more than once" warning.** The check read the wrong field on list-style variables; the warning named a JavaScript function instead of a variable. +- **Camera and microscope steps wait a little longer than their own `timeout_s`** before being called unknown, so a reply that lands right at the deadline counts as the outcome it was. + +## v0.96 (2026-10-16) · ScanImage as a protocol plugin (two-photon rigs, ROS bridge node) · Run file and MCAP bag close when the run ends (LAB-303) + + +- **A protocol can drive ScanImage.** Declare a `scanimage` plugin (class `ScanImagePlugin`) and use its commands in conditions: **Set file stem** (the TIFF name for the next grab), **Grab**, **Wait until idle**, **Abort**, and **Set property** for a few allowlisted settings (frames per grab, logging on/off, file path/stem/counter). The commands go through the bridge's ScanImage node to the microscope's own JSON server; every command and reply is in the run log, and the Grab reply names the TIFF stem and counter. Web runner only (MATLAB has no such plugin). First protocol: `g6_2p_color_led_sweep`. +- **The bridge's run file closes when the run ends,** not when the next run starts. On the ROS bridge node this also closes the MCAP bag, so a run without a course commit (no token, a Test run, an aborted run) no longer leaves its bag open indefinitely. "⇪ Push log" still exports the most recent file. The FicTrac panel's Log checkbox now reads off between runs; turn it on by hand to log Console work outside a run. + ## v0.95 (2026-10-13) · ISP time estimate ~5 s/panel, not ~20 s (#244) · Pattern Designer + Studio: partial-arena °/px and 3D/2D mapping, unmirrored icons, short SD-safe filenames, upload name checks (LAB-295) · First command after Connect no longer waits ~60 s behind the thumbnail download (LAB-298) · Console → Qwiic peripherals, rig peripherals:, live Console sliders, firmware-sized test display + color banks (LAB-214) diff --git a/fictrac-bridge/bridge.py b/fictrac-bridge/bridge.py index fd62d454..058aa756 100644 --- a/fictrac-bridge/bridge.py +++ b/fictrac-bridge/bridge.py @@ -173,10 +173,46 @@ class ConnectionClosed(Exception): # type: ignore[no-redef] BIAS_TYPES = ("none", "constant", "sine", "square") +# Wall clock for every run-log `ms` / `rx_ms` / t0. On Windows with Python <= 3.12, time.time() +# is GetSystemTimeAsFileTime, which advances once per system timer tick: 15.625 ms unless some +# other process (often a browser) has raised the timer rate. Measured on reiserlab-ww18 +# 2026-10-15 with nothing raising it: ~15.6 ms steps, ~2.3 ms behind true time. +# GetSystemTimePreciseAsFileTime is the same wall clock interpolated with the performance +# counter (~1 us, ~0.4 us per call), which is what Python 3.13 made time.time(). Everywhere +# else (macOS, Linux, Windows on 3.13+) now_ms() is time.time() unchanged. +_FILETIME_UNIX_EPOCH = 116444736000000000 # 100 ns ticks from 1601-01-01 to 1970-01-01 + + +def _windows_precise_now_ms(): + """A ms wall clock on GetSystemTimePreciseAsFileTime, or None where there is none + (not Windows, or Windows older than 8).""" + if os.name != "nt": + return None + try: + import ctypes + + fn = ctypes.WinDLL("kernel32").GetSystemTimePreciseAsFileTime + except (AttributeError, OSError): + return None + fn.argtypes = [ctypes.POINTER(ctypes.c_ulonglong)] + fn.restype = None + + def precise_now_ms() -> int: + ft = ctypes.c_ulonglong() + fn(ctypes.byref(ft)) + return (ft.value - _FILETIME_UNIX_EPOCH) // 10_000 + + return precise_now_ms + + def now_ms() -> int: return int(time.time() * 1000) +if sys.version_info < (3, 13): + now_ms = _windows_precise_now_ms() or now_ms # noqa: F811 + + def behavior_v1_row(fields: list[float], index: int, rel_ms: int, ft0: float | None) -> dict: """Build one behavior_v1 record from a parsed FicTrac line. PURE (no clocks, no I/O) so it is unit-testable offline — this is where the col-22 ns→ms diff --git a/js/arena-runner-g6.js b/js/arena-runner-g6.js index 94fa7f61..cd3a5b73 100644 --- a/js/arena-runner-g6.js +++ b/js/arena-runner-g6.js @@ -21,6 +21,9 @@ 'use strict'; var ArenaRunnerG6 = (function () { + // connectorCommand: added to a step's own timeout_s so the browser outlasts the device + // node's deadline (poll period 0.25 s + a status reconcile + two message hops). + const CONNECTOR_TIMEOUT_MARGIN_MS = 3000; // ---- pure helpers (hardware-free, unit-tested) ----------------------- /** @@ -46,10 +49,18 @@ var ArenaRunnerG6 = (function () { * warning"). * @returns {Array<{plugin_name:string, command_name:string}>} */ - function listSkippedPlugins(condition) { + function listSkippedPlugins(condition, driveablePlugins) { if (!condition || !Array.isArray(condition.commands)) return []; + // `driveablePlugins` (optional Set of plugin names): the ones the runner DOES + // execute — the built-in `log`, FicTracPlugin instances, and connector-driven + // devices (BiasPlugin via the bridge, LAB-303) — so the preflight note counts only + // what will really be skipped. Without it every plugin command is listed (legacy). + const drive = + driveablePlugins && typeof driveablePlugins.has === 'function' + ? driveablePlugins + : null; return condition.commands - .filter((c) => c && c.type === 'plugin') + .filter((c) => c && c.type === 'plugin' && !(drive && drive.has(c.plugin_name))) .map((c) => ({ plugin_name: c.plugin_name || '(unnamed)', command_name: c.command_name || '(unnamed)' @@ -714,6 +725,47 @@ var ArenaRunnerG6 = (function () { return out; } + // JSON-safe copy of a bridge 'lost' info for the run record (the summary and the + // bridge-lost event both serialize into the run log). + function lostDetail(info) { + const out = { kind: 'bridge_lost' }; + if (!info || typeof info !== 'object') return out; + for (const k of ['code', 'reason', 'wasClean', 'apply', 'logFile', 'url', 't']) { + const v = info[k]; + if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') out[k] = v; + } + return out; + } + + /** + * Does any condition this sequence runs carry a plugin command that acts through + * the bridge (FicTrac today)? Losing the bridge mid-run makes every later such + * step run blind, so ArenaRunner.onBridgeLost stops a run for which this is true. + * Scans the authored command lists (runtime controls change parameters, never + * plugin names), each condition once. + * @param {Array} steps + * @param {Map} conditionsByName + * @param {Set|null} bridgePluginNames plugin instance names routed via the bridge + * @returns {boolean} + */ + function sequenceUsesBridge(steps, conditionsByName, bridgePluginNames) { + if (!bridgePluginNames || typeof bridgePluginNames.has !== 'function') return false; + if (!bridgePluginNames.size) return false; + const seen = new Set(); + for (const step of Array.isArray(steps) ? steps : []) { + const name = step && step.conditionName; + if (seen.has(name)) continue; + seen.add(name); + const cond = conditionsByName && conditionsByName.get(name); + if (!cond || !Array.isArray(cond.commands)) continue; + for (const cmd of cond.commands) { + if (cmd && cmd.type === 'plugin' && bridgePluginNames.has(cmd.plugin_name)) + return true; + } + } + return false; + } + /** * Translate ONE v3 command into a wire-neutral instruction descriptor (the * "command IR"). PURE and wire-free so it is trivially unit-testable; the @@ -909,6 +961,27 @@ var ArenaRunnerG6 = (function () { }; } } + // Device connectors (LAB-303): plugins the BRIDGE drives on the runner's behalf + // (connector_command → connector_reply). opts.connectorPluginNames maps a plugin + // INSTANCE name → connector id ('bias' for BiasPlugin). The command goes through + // as written — the bridge / device node validates it and answers ok | error | + // unknown; `timeout_s` in params (optional) bounds the wait (LAB-303 semantics). + const connectors = opts.connectorPluginNames; + const connector = + connectors && typeof connectors.get === 'function' + ? connectors.get(cmd.plugin_name) + : null; + if (connector) { + const tS = Number(params.timeout_s); + return { + op: 'connectorCommand', + connector, + plugin_name: pname, + command_name: cname, + params: params && typeof params === 'object' ? params : {}, + timeoutS: Number.isFinite(tS) && tS > 0 ? tS : null + }; + } // Any other plugin: skipped-with-warning (unchanged — the browser can't // drive arbitrary MATLAB-side plugins). return { @@ -1065,6 +1138,154 @@ var ArenaRunnerG6 = (function () { await sleep((Number(remainderSec) || 0) * 1000); } + // ---- throttle-proof host-side wait timer ------------------------------ + // + // A page setTimeout is throttled once the tab is hidden or the window is + // occluded: Chrome aligns it to 1 s ticks (1 min after 5 min hidden). On the + // Bergamo 2P rig (2026-10-16) every host-timed 3 s step ran 4.00 s while the + // operator worked in ScanImage over the Studio window. Dedicated Workers are + // not throttled, so the wait is armed in a Worker built from an inline blob + // URL (no extra file — this module must stay a classic