Стабилизация переключения Bridge и Quick Connect

Безопасно восстанавливает управляющее подключение и локальные checkpoint без повторных команд сканеру.

Добавляет PCAP/Bridge guardrails и регрессионные проверки одношагового переподключения.

Известный дефект: после второго подключения интерфейс не присоединяется к новой генерации preview правой камеры. В живой Quick Connect-сессии STOP был принят, но READY не подтвердился до таймаута; автоматический повтор STOP запрещён.
This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 00:15:28 +03:00
parent 1001a31638
commit 0752e5c6bf
12 changed files with 3481 additions and 234 deletions
@@ -207,9 +207,16 @@ export function K1AcquisitionPipeline({
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
);
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
const protectedSoftwareCommandedTransition = Boolean(
activeAcquisition
&& activeAcquisition.control_mode === "plugin-commanded"
&& activeAcquisition.state !== "prepared"
&& !isTerminalAcquisitionState(activeAcquisition.state),
);
const localReceiverStopExecutable = Boolean(
connectionPolicyAllows(state, "stop-local-receiver")
&& preparedAcquisition === null,
&& preparedAcquisition === null
&& !protectedSoftwareCommandedTransition,
);
const terminalPhysicalStopPending = terminalPhysicalStopObserved
&& physicalStopInFlight;
@@ -306,6 +313,10 @@ export function K1AcquisitionPipeline({
: "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
: terminalReadOnlyRecovery
? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
: protectedSoftwareCommandedTransition && !physicalStopPresented
? physicalStopIntentSpent
? "Команда остановки уже была принята интерфейсом. Повторная команда и локальное завершение заблокированы до нового подтверждённого состояния K1."
: "K1 выполняет переход к сканированию. Кнопка остановки появится только после подтверждённого SCANNING."
: physicalStopGuidance
? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
: gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
@@ -572,7 +583,7 @@ export function K1AcquisitionPipeline({
: physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
</Button>
) : null}
{activeAcquisition ? (
{activeAcquisition && !protectedSoftwareCommandedTransition ? (
<Button variant="ghost" disabled={isBusy} onClick={() => void abort()}>
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
</Button>
@@ -100,6 +100,31 @@ export function emptyReadOnlyReconnectPresentation():
return null;
}
export const LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS = 2_000;
/**
* Keep short local lifecycle settlements visually silent. Safety interlocks
* still take effect immediately; only the progress presentation is deferred.
*/
export function useDelayedLocalOperationProgress(
active: boolean,
delayMilliseconds = LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
): boolean {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) {
setVisible(false);
return undefined;
}
const timer = globalThis.setTimeout(
() => setVisible(true),
delayMilliseconds,
);
return () => globalThis.clearTimeout(timer);
}, [active, delayMilliseconds]);
return active && visible;
}
export interface PhysicalReopenPresentation {
key: string;
snapshotRuntimeId: string;
@@ -1240,6 +1265,9 @@ export function K1ProvisioningPipeline({
targetMode: ConnectionMode;
} | null>(null);
const modeResetInFlight = pendingAction === "mode" || modeResetPending !== null;
const modeResetProgressVisible = useDelayedLocalOperationProgress(
modeResetInFlight,
);
const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState<BleDevice | null>(null);
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
@@ -1657,7 +1685,8 @@ export function K1ProvisioningPipeline({
? readOnlyReconnectPresentation
: null;
const connectionRecoveryVerificationPending =
currentReadOnlyReconnectPresentation?.kind === "connection";
currentReadOnlyReconnectPresentation?.kind === "connection"
&& currentReadOnlyReconnectPresentation.connectionMode === connectionMode;
const physicalRecoveryVerificationPending =
currentReadOnlyReconnectPresentation?.kind === "physical";
const provisioningMutationBusy = Boolean(
@@ -1796,7 +1825,7 @@ export function K1ProvisioningPipeline({
state,
connectionMode,
);
const connectionRecoveryTarget = !physicalRecoveryRequired
const recommendedConnectionRecoveryTarget = !physicalRecoveryRequired
&& !unresolvedAppliedAttempt
&& !selectedModeConnected
&& !reconfigurationActive
@@ -1804,6 +1833,13 @@ export function K1ProvisioningPipeline({
&& connectionRecoveryObservationAllowed
? recommendedConnectionRecoveryObservationTarget(state)
: null;
// A durable recovery target remains available when the operator returns to
// its mode, but it must never leak a Bridge reconnect action into a freshly
// selected Quick Connect draft (or vice versa).
const connectionRecoveryTarget =
recommendedConnectionRecoveryTarget?.connectionMode === connectionMode
? recommendedConnectionRecoveryTarget
: null;
const connectionRecoveryKey = connectionRecoveryEscapeKey({
snapshotRuntimeId,
attempt: connectionRecoveryAttempt,
@@ -3162,10 +3198,16 @@ export function K1ProvisioningPipeline({
disabled={modeResetInFlight}
variant="split"
/>
{modeResetInFlight ? (
<p className="safety-note" role="status">
Завершение прежней локальной границы. Новый поиск не начнётся автоматически.
</p>
{modeResetProgressVisible ? (
<div
className="connection-action-progress"
role="status"
aria-live="polite"
aria-busy="true"
>
<ActivityIndicator size="compact" />
<strong>Переключаем способ подключения…</strong>
</div>
) : null}
{activeScenarioReset ? (
<p className="safety-note" role="status">
@@ -197,6 +197,7 @@ export function K1SpatialControlsView({
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
);
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
const pluginCommandedAcquisition = acquisition?.control_mode === "plugin-commanded";
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
acquisition?.state ?? "",
);
@@ -327,7 +328,10 @@ export function K1SpatialControlsView({
</span>
</Button>
) : null}
{!physicalStopPresented && localReceiverStopAllowed && !stopping ? (
{!physicalStopPresented
&& localReceiverStopAllowed
&& !pluginCommandedAcquisition
&& !stopping ? (
<Button
size="compact"
variant="ghost"