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