fix(k1): admit live rerun only after real data

This commit is contained in:
DCCONSTRUCTIONS
2026-08-20 12:54:55 +03:00
parent 321cde70e8
commit aee60f12b9
5 changed files with 150 additions and 31 deletions
+2 -2
View File
@@ -780,8 +780,8 @@ export default function App() {
</StatusBadge> </StatusBadge>
) : activeDefinition.kind === "spatial" ? ( ) : activeDefinition.kind === "spatial" ? (
<div className="observation-header-tools"> <div className="observation-header-tools">
<StatusBadge tone={runtime.state?.sourceMode === "live" ? "success" : "neutral"}> <StatusBadge tone={["live", "replay"].includes(runtime.state?.sourceMode ?? "") ? "success" : "neutral"}>
{runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"} {runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"}
</StatusBadge> </StatusBadge>
{layoutSaveNotice || workspaceLayoutProfile.error ? ( {layoutSaveNotice || workspaceLayoutProfile.error ? (
<span <span
@@ -275,6 +275,40 @@ export function isUsableRecordedPlaybackRange(
); );
} }
/**
* A live receiver is presentable only after the exact browser store exposes
* real timeline data that the backend has also confirmed publishing.
* `WebViewer.start()` and a non-null active recording id are transport setup,
* not evidence that the spatial scene can render.
*/
export function isLiveRerunPresentationReady(
viewerStarted: boolean,
rangeNs: { min: number; max: number } | null,
backendActivitySequence: number | null,
): boolean {
return viewerStarted &&
Number.isSafeInteger(backendActivitySequence) &&
(backendActivitySequence ?? 0) > 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. */ /** Describe progressive archive availability without gating first rendering. */
export function recordedPlaybackBufferState( export function recordedPlaybackBufferState(
rangeNs: { min: number; max: number } | null, rangeNs: { min: number; max: number } | null,
@@ -938,6 +972,11 @@ export function RerunViewport({
presentationGate, presentationGate,
recordedArtifact !== null, recordedArtifact !== null,
); );
const liveReceiverBindingIdentity = liveRerunReceiverBindingIdentity(
sourceUrl,
liveStreamId,
followLive,
);
useEffect(() => subscribeToLiveViewerBuildFence(() => { useEffect(() => subscribeToLiveViewerBuildFence(() => {
uiBuildStaleRef.current = true; uiBuildStaleRef.current = true;
@@ -949,7 +988,7 @@ export function RerunViewport({
useEffect(() => { useEffect(() => {
liveRecoveryRef.current = initialLiveReceiverRecoveryState(); liveRecoveryRef.current = initialLiveReceiverRecoveryState();
}, [followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl]); }, [liveReceiverBindingIdentity]);
useEffect(() => { useEffect(() => {
const normalizedSource = sourceUrl.trim(); const normalizedSource = sourceUrl.trim();
@@ -1152,9 +1191,10 @@ export function RerunViewport({
recoveryAttempt: liveRecoveryRef.current.attempts || null, recoveryAttempt: liveRecoveryRef.current.attempts || null,
}); });
} }
const currentRecoveryAuthorityIdentity = liveRecoveryAuthorityRef.current;
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, { const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
activeAuthorityIdentity: liveRecoveryAuthorityRef.current, activeAuthorityIdentity: currentRecoveryAuthorityIdentity,
expectedAuthorityIdentity: liveRecoveryAuthorityIdentity, expectedAuthorityIdentity: currentRecoveryAuthorityIdentity,
disposed, disposed,
}); });
liveRecoveryRef.current = recovery.state; liveRecoveryRef.current = recovery.state;
@@ -1245,7 +1285,7 @@ export function RerunViewport({
stalledForMs: Math.round(observed.stalledForMs), stalledForMs: Math.round(observed.stalledForMs),
recoveryAttempt: Math.min( recoveryAttempt: Math.min(
liveRecoveryRef.current.attempts + 1, liveRecoveryRef.current.attempts + 1,
liveRecoveryAuthorityIdentity liveRecoveryAuthorityRef.current
? Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS, : LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
), ),
@@ -1356,17 +1396,9 @@ export function RerunViewport({
) return; ) return;
recordingOpened = true; recordingOpened = true;
if (!isRecordedSource) { if (!isRecordedSource) {
diagnosticLifecycle.markAdmitted(); // Store discovery only establishes a candidate. Admission is
if (liveRecoveryRef.current.awaitingRecovery) { // committed below after this recording exposes a usable live
diagnosticLifecycle.post({ // range backed by an actual published frame.
eventCode: "live_receiver_recovered",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
viewerRangeMaxNs: latestLiveRangeMaxNs,
recoveryAttempt: liveRecoveryRef.current.attempts,
});
}
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
} }
if ( if (
recordedBlueprintUrl && recordedBlueprintUrl &&
@@ -1479,7 +1511,11 @@ export function RerunViewport({
setRecordingBufferProgress(recordedBuffer.bufferProgress); setRecordingBufferProgress(recordedBuffer.bufferProgress);
} }
const readyToRender = followLive const readyToRender = followLive
? viewerStartResolved ? isLiveRerunPresentationReady(
viewerStartResolved,
rangeNs,
liveActivitySequenceRef.current,
)
: isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer); : isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer);
const presentationReady = presentationGateRef.current === "ready"; const presentationReady = presentationGateRef.current === "ready";
if (!followLive && (!readyToRender || !presentationReady) && playing) { if (!followLive && (!readyToRender || !presentationReady) && playing) {
@@ -1531,6 +1567,25 @@ export function RerunViewport({
}); });
if (readyToRender && !readyPublished) { if (readyToRender && !readyPublished) {
readyPublished = true; 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) clearRecordedAdmissionWatchdog();
if (!followLive) setRecordingBufferProgress(1); if (!followLive) setRecordingBufferProgress(1);
recordedSceneAdmitted = true; recordedSceneAdmitted = true;
@@ -1563,11 +1618,6 @@ export function RerunViewport({
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its // Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
// recording_open event to the JavaScript wrapper. The active store // recording_open event to the JavaScript wrapper. The active store
// is the authoritative fallback and avoids hiding a ready canvas. // 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({ admitRecording({
application_id: "nodedc_mission_core_spatial", application_id: "nodedc_mission_core_spatial",
recording_id: recordingId, recording_id: recordingId,
@@ -1716,10 +1766,8 @@ export function RerunViewport({
autoplayWhenReady, autoplayWhenReady,
expectedTimelineEndSeconds, expectedTimelineEndSeconds,
expectedTimelineStartSeconds, expectedTimelineStartSeconds,
followLive, liveReceiverBindingIdentity,
initialPlaybackStartSeconds, initialPlaybackStartSeconds,
liveStreamId,
liveRecoveryAuthorityIdentity,
onPlaybackChange, onPlaybackChange,
onPlaybackControllerChange, onPlaybackControllerChange,
onSelectionChange, onSelectionChange,
@@ -1729,7 +1777,6 @@ export function RerunViewport({
recordedArtifact?.sourceUrl, recordedArtifact?.sourceUrl,
recordedArtifact?.viewerSourceUrl, recordedArtifact?.viewerSourceUrl,
retryNonce, retryNonce,
sourceUrl,
]); ]);
useEffect(() => { useEffect(() => {
@@ -156,7 +156,7 @@ function SpatialWorkspace({
const [showDetections2d, setShowDetections2d] = useState(false); const [showDetections2d, setShowDetections2d] = useState(false);
const [showSegmentation, setShowSegmentation] = useState(false); const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = 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 const recordedSessionGate: RecordedAdmissionPhase = recordedSource
? recordedSessionAdmission?.phase ?? "loading" ? recordedSessionAdmission?.phase ?? "loading"
: "ready"; : "ready";
@@ -8,6 +8,8 @@ let server;
let claimExclusiveLiveViewer; let claimExclusiveLiveViewer;
let createRecordedOpenWatchdog; let createRecordedOpenWatchdog;
let createReentrantViewerDisposer; let createReentrantViewerDisposer;
let isLiveRerunPresentationReady;
let liveRerunReceiverBindingIdentity;
let recordedOpenWatchdogTimeoutMs; let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource; let rerunViewerInitialSource;
let rerunViewerOpenOptions; let rerunViewerOpenOptions;
@@ -23,6 +25,8 @@ before(async () => {
claimExclusiveLiveViewer, claimExclusiveLiveViewer,
createRecordedOpenWatchdog, createRecordedOpenWatchdog,
createReentrantViewerDisposer, createReentrantViewerDisposer,
isLiveRerunPresentationReady,
liveRerunReceiverBindingIdentity,
recordedOpenWatchdogTimeoutMs, recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource, rerunViewerInitialSource,
rerunViewerOpenOptions, rerunViewerOpenOptions,
@@ -70,6 +74,31 @@ test("only the live receiver opens on the native following edge", () => {
assert.equal(rerunViewerOpenOptions(false), null); 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", () => { test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
const smallDelay = recordedOpenWatchdogTimeoutMs(4); const smallDelay = recordedOpenWatchdogTimeoutMs(4);
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680); 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( assert.match(
source, 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( assert.match(
source, source,
@@ -221,6 +262,25 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
source, source,
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/, /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 () => { test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
@@ -1,6 +1,6 @@
# K1 operator flow: incremental acceptance # 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 This is the short regression anchor for changes to the existing K1 operator
flow. The authoritative connection and safety model remains 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. policy again admits Scan plus read-only configured-device observation.
- `K1-P0-RERUN-ADMISSION`: backend decoded and published PCL, but the browser - `K1-P0-RERUN-ADMISSION`: backend decoded and published PCL, but the browser
never admitted the active Rerun store; camera/counters were visible over an 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 - `K1-P1-STOP-STABILITY`: STOP authority visibly oscillated before the operator
clicked. Not fixed in the checkpoint increment. clicked. Not fixed in the checkpoint increment.
- `K1-P1-PENDING-FEEDBACK`: reconnect/search/select/provision transitions replace - `K1-P1-PENDING-FEEDBACK`: reconnect/search/select/provision transitions replace