From aee60f12b9795bcfe8397d3333ba17fcd72bc6b4 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 20 Aug 2026 12:54:55 +0300 Subject: [PATCH] fix(k1): admit live rerun only after real data --- apps/control-station/src/App.tsx | 4 +- .../src/components/RerunViewport.tsx | 97 ++++++++++++++----- .../src/workspaces/Workspaces.tsx | 2 +- .../rerunViewportAtomicAdmission.test.mjs | 62 +++++++++++- ...K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md | 16 ++- 5 files changed, 150 insertions(+), 31 deletions(-) diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index e8063b9..1fe2766 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -780,8 +780,8 @@ export default function App() { ) : activeDefinition.kind === "spatial" ? (
- - {runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"} + + {runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"} {layoutSaveNotice || workspaceLayoutProfile.error ? ( 0 && + isUsableRecordedPlaybackRange(rangeNs); +} + +/** + * Key the native receiver to its data-plane binding. Recovery authority is a + * retry fence projected from changing supervisor snapshots; it must not tear + * down a healthy WebViewer while this acquisition and URL remain unchanged. + */ +export function liveRerunReceiverBindingIdentity( + sourceUrl: string, + liveStreamId: string | null, + followLive: boolean, +): string { + return JSON.stringify([ + followLive ? "live" : "recorded", + sourceUrl.trim(), + followLive ? liveStreamId?.trim() ?? "" : "", + ]); +} + /** Describe progressive archive availability without gating first rendering. */ export function recordedPlaybackBufferState( rangeNs: { min: number; max: number } | null, @@ -938,6 +972,11 @@ export function RerunViewport({ presentationGate, recordedArtifact !== null, ); + const liveReceiverBindingIdentity = liveRerunReceiverBindingIdentity( + sourceUrl, + liveStreamId, + followLive, + ); useEffect(() => subscribeToLiveViewerBuildFence(() => { uiBuildStaleRef.current = true; @@ -949,7 +988,7 @@ export function RerunViewport({ useEffect(() => { liveRecoveryRef.current = initialLiveReceiverRecoveryState(); - }, [followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl]); + }, [liveReceiverBindingIdentity]); useEffect(() => { const normalizedSource = sourceUrl.trim(); @@ -1152,9 +1191,10 @@ export function RerunViewport({ recoveryAttempt: liveRecoveryRef.current.attempts || null, }); } + const currentRecoveryAuthorityIdentity = liveRecoveryAuthorityRef.current; const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, { - activeAuthorityIdentity: liveRecoveryAuthorityRef.current, - expectedAuthorityIdentity: liveRecoveryAuthorityIdentity, + activeAuthorityIdentity: currentRecoveryAuthorityIdentity, + expectedAuthorityIdentity: currentRecoveryAuthorityIdentity, disposed, }); liveRecoveryRef.current = recovery.state; @@ -1245,7 +1285,7 @@ export function RerunViewport({ stalledForMs: Math.round(observed.stalledForMs), recoveryAttempt: Math.min( liveRecoveryRef.current.attempts + 1, - liveRecoveryAuthorityIdentity + liveRecoveryAuthorityRef.current ? Number.MAX_SAFE_INTEGER : LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS, ), @@ -1356,17 +1396,9 @@ export function RerunViewport({ ) return; recordingOpened = true; if (!isRecordedSource) { - diagnosticLifecycle.markAdmitted(); - if (liveRecoveryRef.current.awaitingRecovery) { - diagnosticLifecycle.post({ - eventCode: "live_receiver_recovered", - streamId: liveStreamIdRef.current, - backendActivitySequence: liveActivitySequenceRef.current, - viewerRangeMaxNs: latestLiveRangeMaxNs, - recoveryAttempt: liveRecoveryRef.current.attempts, - }); - } - liveRecoveryRef.current = initialLiveReceiverRecoveryState(); + // Store discovery only establishes a candidate. Admission is + // committed below after this recording exposes a usable live + // range backed by an actual published frame. } if ( recordedBlueprintUrl && @@ -1479,7 +1511,11 @@ export function RerunViewport({ setRecordingBufferProgress(recordedBuffer.bufferProgress); } const readyToRender = followLive - ? viewerStartResolved + ? isLiveRerunPresentationReady( + viewerStartResolved, + rangeNs, + liveActivitySequenceRef.current, + ) : isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer); const presentationReady = presentationGateRef.current === "ready"; if (!followLive && (!readyToRender || !presentationReady) && playing) { @@ -1531,6 +1567,25 @@ export function RerunViewport({ }); if (readyToRender && !readyPublished) { readyPublished = true; + if (followLive) { + diagnosticLifecycle.post({ + eventCode: "live_receiver_active_store_admitted", + streamId: liveStreamIdRef.current, + backendActivitySequence: liveActivitySequenceRef.current, + viewerRangeMaxNs: rangeNs?.max ?? null, + }); + diagnosticLifecycle.markAdmitted(); + if (liveRecoveryRef.current.awaitingRecovery) { + diagnosticLifecycle.post({ + eventCode: "live_receiver_recovered", + streamId: liveStreamIdRef.current, + backendActivitySequence: liveActivitySequenceRef.current, + viewerRangeMaxNs: rangeNs?.max ?? null, + recoveryAttempt: liveRecoveryRef.current.attempts, + }); + } + liveRecoveryRef.current = initialLiveReceiverRecoveryState(); + } if (!followLive) clearRecordedAdmissionWatchdog(); if (!followLive) setRecordingBufferProgress(1); recordedSceneAdmitted = true; @@ -1563,11 +1618,6 @@ export function RerunViewport({ // Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its // recording_open event to the JavaScript wrapper. The active store // is the authoritative fallback and avoids hiding a ready canvas. - diagnosticLifecycle.post({ - eventCode: "live_receiver_active_store_admitted", - streamId: liveStreamIdRef.current, - backendActivitySequence: liveActivitySequenceRef.current, - }); admitRecording({ application_id: "nodedc_mission_core_spatial", recording_id: recordingId, @@ -1716,10 +1766,8 @@ export function RerunViewport({ autoplayWhenReady, expectedTimelineEndSeconds, expectedTimelineStartSeconds, - followLive, + liveReceiverBindingIdentity, initialPlaybackStartSeconds, - liveStreamId, - liveRecoveryAuthorityIdentity, onPlaybackChange, onPlaybackControllerChange, onSelectionChange, @@ -1729,7 +1777,6 @@ export function RerunViewport({ recordedArtifact?.sourceUrl, recordedArtifact?.viewerSourceUrl, retryNonce, - sourceUrl, ]); useEffect(() => { diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index ddd9aec..5fad69a 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -156,7 +156,7 @@ function SpatialWorkspace({ const [showDetections2d, setShowDetections2d] = useState(false); const [showSegmentation, setShowSegmentation] = useState(false); const [showCuboids3d, setShowCuboids3d] = useState(false); - const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl); + const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl); const recordedSessionGate: RecordedAdmissionPhase = recordedSource ? recordedSessionAdmission?.phase ?? "loading" : "ready"; diff --git a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs index 2aebb05..1d837f2 100644 --- a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs +++ b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs @@ -8,6 +8,8 @@ let server; let claimExclusiveLiveViewer; let createRecordedOpenWatchdog; let createReentrantViewerDisposer; +let isLiveRerunPresentationReady; +let liveRerunReceiverBindingIdentity; let recordedOpenWatchdogTimeoutMs; let rerunViewerInitialSource; let rerunViewerOpenOptions; @@ -23,6 +25,8 @@ before(async () => { claimExclusiveLiveViewer, createRecordedOpenWatchdog, createReentrantViewerDisposer, + isLiveRerunPresentationReady, + liveRerunReceiverBindingIdentity, recordedOpenWatchdogTimeoutMs, rerunViewerInitialSource, rerunViewerOpenOptions, @@ -70,6 +74,31 @@ test("only the live receiver opens on the native following edge", () => { assert.equal(rerunViewerOpenOptions(false), null); }); +test("live presentation waits for the exact receiver to expose a usable range", () => { + assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 1), false); + assert.equal(isLiveRerunPresentationReady(true, null, 1), false); + assert.equal(isLiveRerunPresentationReady(true, { min: 2, max: 1 }, 1), false); + assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 2 }, 0), false); + assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 1 }, 1), true); +}); + +test("live receiver binding stays stable across recovery authority projections", () => { + const sourceUrl = "rerun+http://127.0.0.1:9877/proxy"; + const streamId = "acq-001"; + assert.equal( + liveRerunReceiverBindingIdentity(sourceUrl, streamId, true), + liveRerunReceiverBindingIdentity(` ${sourceUrl} `, streamId, true), + ); + assert.notEqual( + liveRerunReceiverBindingIdentity(sourceUrl, streamId, true), + liveRerunReceiverBindingIdentity(sourceUrl, "acq-002", true), + ); + assert.notEqual( + liveRerunReceiverBindingIdentity(sourceUrl, streamId, true), + liveRerunReceiverBindingIdentity(sourceUrl, streamId, false), + ); +}); + test("recorded admission watchdog is size-aware and exits an incomplete load", () => { const smallDelay = recordedOpenWatchdogTimeoutMs(4); const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680); @@ -184,7 +213,19 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn ); assert.match( source, - /viewer\.get_active_recording_id\(\)[\s\S]*eventCode: "live_receiver_active_store_admitted"[\s\S]*admitRecording\(\{[\s\S]*application_id: "nodedc_mission_core_spatial"/, + /viewer\.get_active_recording_id\(\)[\s\S]*admitRecording\(\{[\s\S]*application_id: "nodedc_mission_core_spatial"/, + ); + assert.match( + source, + /const readyToRender = followLive\s*\? isLiveRerunPresentationReady\(/, + ); + assert.match( + source, + /if \(readyToRender && !readyPublished\)[\s\S]*eventCode: "live_receiver_active_store_admitted"[\s\S]*diagnosticLifecycle\.markAdmitted\(\)/, + ); + assert.doesNotMatch( + source, + /\], \[followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl\]\);/, ); assert.match( source, @@ -221,6 +262,25 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan" source, /sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/, ); + assert.match( + source, + /const recordedSource = Boolean\(recordedReplay\) \|\| \/\\\.rrd/, + ); + assert.doesNotMatch( + source, + /recordedSource = state\?\.sourceMode === ["']replay["']/, + ); +}); + +test("the spatial header reports raw replay as an active source", async () => { + const source = await readFile( + new URL("../src/App.tsx", import.meta.url), + "utf8", + ); + assert.match( + source, + /sourceMode === "replay"[\s\S]*\? "Повтор записи"[\s\S]*: "Ожидание эфира"/, + ); }); test("the complete vendor canvas host is hidden during partial and failed admission", async () => { diff --git a/docs/runbooks/K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md b/docs/runbooks/K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md index 686beb5..127a1a4 100644 --- a/docs/runbooks/K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md +++ b/docs/runbooks/K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md @@ -1,6 +1,6 @@ # K1 operator flow: incremental acceptance -Status: working acceptance ledger, 2026-08-14. +Status: working acceptance ledger, updated 2026-08-20. This is the short regression anchor for changes to the existing K1 operator flow. The authoritative connection and safety model remains @@ -71,7 +71,19 @@ Current violations observed on 2026-08-14: policy again admits Scan plus read-only configured-device observation. - `K1-P0-RERUN-ADMISSION`: backend decoded and published PCL, but the browser never admitted the active Rerun store; camera/counters were visible over an - empty point scene. Not fixed in the checkpoint increment. + empty point scene. Offline fix and reducer are green: store discovery is only + a candidate, presentation requires a usable range plus a backend-published + frame, and recovery-authority churn no longer remounts the same receiver. + Production raw replay of `20260814T145329Z_viewer_live` rendered the real + point scene with `Визуализатор готов` and `Повтор записи`. One live K1 + READY → START → point cloud/camera → STOP acceptance remains required; the + 2026-08-20 attempt was blocked before discovery by the unceased checkpoint + described below. +- `K1-P0-RESET-CHECKPOINT`: an explicit local scenario reset resolved a + zero-dispatch START as `not-dispatched`, but left its exact recovery + checkpoint revision 17 in `prepared`. No device/network command was sent; + the stale checkpoint blocks the next START and requires a local atomic + settlement fix before live acceptance continues. - `K1-P1-STOP-STABILITY`: STOP authority visibly oscillated before the operator clicked. Not fixed in the checkpoint increment. - `K1-P1-PENDING-FEEDBACK`: reconnect/search/select/provision transitions replace