From c6693d6f442e0d2bb4ea3c5710db7af44d41b667 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 7 Sep 2026 21:13:17 +0300 Subject: [PATCH] Retain K1 connection between scans and admit the next named acquisition --- .../test/k1ManualControl.test.mjs | 16 +- apps/node-agent/packaging/build.py | 2 +- apps/node-agent/packaging/build_deb.py | 2 +- .../audits/2026-09-07-k1-between-scans-r16.md | 93 +++ packages/spatial-ui/src/spatial.css | 2 + .../frontend/src/sensors/presentation.ts | 10 +- .../frontend/src/useXgridsK1Runtime.ts | 5 +- plugins/xgrids-k1/packaging/build_deb.py | 4 +- src/k1link/device_plugins/xgrids_k1/facade.py | 22 +- .../device_plugins/xgrids_k1/node_sensor.py | 9 +- .../protocol/application_acceptance.py | 15 + .../xgrids_k1/protocol/application_session.py | 553 ++++++++++-------- tests/test_k1_installer.py | 2 +- tests/test_node_k1_bridge.py | 18 +- tests/test_xgrids_acquisition_lifecycle.py | 21 + tests/test_xgrids_application_acceptance.py | 11 + tests/test_xgrids_application_session.py | 75 ++- 17 files changed, 586 insertions(+), 274 deletions(-) create mode 100644 docs/audits/2026-09-07-k1-between-scans-r16.md diff --git a/apps/control-station/test/k1ManualControl.test.mjs b/apps/control-station/test/k1ManualControl.test.mjs index 4849e9f..59d4a05 100644 --- a/apps/control-station/test/k1ManualControl.test.mjs +++ b/apps/control-station/test/k1ManualControl.test.mjs @@ -30,7 +30,7 @@ test('manual K1 surface admits one START only after current control proof',()=>{ assert.doesNotMatch(pending,/Инициировать запуск|Настройки устройства|sensor-live-layout/); assert.match(pending,/Проверить состояние K1/); assert.match(pending,/авторизовать/); - assert.equal(presentation.k1Status(waiting,true).label,'Wi-Fi настроен · нет управления'); + assert.equal(presentation.k1Status(waiting,true).label,'Нет связи с K1'); assert.equal(presentation.k1ManualState(device,false).canStart,false); }); test('active acquisition exposes STOP and Rerun without another START',()=>{ @@ -58,10 +58,10 @@ test('calibration loader stays inside the primary action',()=>{ assert.match(markup,/K1 калибруется и готовит облако точек/); assert.doesNotMatch(markup,/Обновить просмотр/); }); -test('completed STOP remains visible while the control connection is checked again',()=>{ +test('connection loss is visible independently of completed STOP',()=>{ const stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}}; const markup=render(stopped); - assert.match(markup,/Устройство остановлено/); + assert.match(markup,/Нет связи с K1/); assert.match(markup,/Проверить состояние K1/); assert.doesNotMatch(markup,/Инициировать запуск|нет управления/); }); @@ -73,3 +73,13 @@ test('enrollment handoff opens only the verified current session',()=>{ assert.equal(enrolledDevice({...inventory,items:[{...device,verified:false}]},'exact-session'),null); assert.equal(enrolledDevice({...inventory,items:[device,device]},'exact-session'),null); }); + +test('completed scan with healthy control remains connected and permits a named next scan',()=>{ + const stopped={...device,control:{...device.control,phase:'completed'}}; + const markup=render(stopped); + assert.equal(presentation.k1Status(stopped,true).label,'Подключён'); + assert.equal(presentation.k1ManualState(stopped,true).canStart,true); + assert.match(markup,/Инициировать запуск/); + assert.doesNotMatch(markup,/Проверить состояние K1|Подключить устройство|Устройство остановлено/); + assert.equal(presentation.k1Status(stopped,false).tone,'neutral'); +}); diff --git a/apps/node-agent/packaging/build.py b/apps/node-agent/packaging/build.py index 13c09bc..6aa7d4c 100644 --- a/apps/node-agent/packaging/build.py +++ b/apps/node-agent/packaging/build.py @@ -11,7 +11,7 @@ import sys from build_deb import build, VERSION, BRAND_SHA256 ROOT = Path(__file__).resolve().parents[1] -DG_COMMIT = "5b882bc3d9b13a86e6c26111ef5ded687ea3a2fc" +DG_COMMIT = "26a1bf72a2a32b002e51f910e8faa300333bb6c3" def guideline_sources(): diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 2ab8136..ba7a0a9 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -11,7 +11,7 @@ import sys ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.8.12" +VERSION = "0.8.13" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package diff --git a/docs/audits/2026-09-07-k1-between-scans-r16.md b/docs/audits/2026-09-07-k1-between-scans-r16.md new file mode 100644 index 0000000..de335ec --- /dev/null +++ b/docs/audits/2026-09-07-k1-between-scans-r16.md @@ -0,0 +1,93 @@ +# K1 connection lifetime between recordings — R16 + +## Observed behavior and cause + +The owner reports two successful R15 connections and recordings, then a return +to the device showing “Устройство остановлено” and requiring a separate +connection check. R15 is Node 0.8.12 / K1 0.1.12. The services remained active +with zero restarts through the reported 20:17–20:23 MSK interval. A later bounded +Fleet read still showed the K1 acquisition idle, control phase completed, and +online/verified false. This is current state, not proof of a historical Wi-Fi +failure. + +The implementation explains the transition deterministically: the interactive +control worker concluded the entire MQTT dialogue after correlated STOP and +fresh unbound READY, set `completed`, and closed its transport in `finally`. +The facade then reduced every completed control session to control loss. The +Node projection correctly withheld connection authority, but the UI further +hid that distinction by prioritizing “device stopped” over connectivity and +instructing the owner to check connection after every completed recording. +No service restart or actual Wi-Fi disconnect is needed to produce this path. + +There is a real protocol constraint underneath the original single-scan +implementation: MQTT operation keys and response-correlation ownership are +one-shot within a dialogue. Reusing the old dialogue by clearing consumed keys +would weaken replay/correlation protection. R16 does not reset these guards. + +The bounded journal also records failures at 20:18:54 and 20:21:24 at the R15 +Node adapter's `Physical state requires explicit reconciliation` check. That +check ran even immediately after the adapter's own admitted START, before its +pending physical edge had settled. R16 returns that owned starting/scanning +state without another dispatch; pre-existing ambiguous physical state still +blocks a new START. + +## Corrected lifetime + +After confirmed STOP/READY, the completed recording is sealed by the existing +acquisition lifecycle. The command worker retains its socket and services the +existing subscriptions while idle. This wait sends no bootstrap, network +configuration, START or STOP. Real socket failure, stale bound status, identity +change and route/proof expiry keep their existing fail-closed checks. + +A subsequent explicit named START releases the next workspace checkpoint. Only +then does the owner retire the previous command socket and create a fresh +canonical dialogue for the same bound device/network. It receives fresh +DeviceInfo, enforces the identity/route and durable ledger checks, then follows +the existing workspace/project/START stages. The control generation advances; +old checkpoints cannot authorize the new cycle. BLE selection, GATT and Wi-Fi +provisioning are not part of this path. No extra operator recovery action is +needed for an uninterrupted connection. Actual loss retains the explicit +verification/recovery path; no automatic network write or physical command +retry was added. + +The facade accepts completed control as live only when its socket is retained +and the existing exact, fresh DeviceInfo/control proof satisfies the supervisor. +Legacy completed/closed sockets remain disconnected. Explicit network changes +may retire the retained idle socket through the existing local close boundary. +Both direct and Node START orchestration accept this completed-but-connected +checkpoint. Device inventory/detail badges now report connection; acquisition +progress remains in the spatial session controls. + +## Presentation + +- Canonical Design Guideline Field owns autofill styling. Browser autofill uses + the existing field material, theme text/caret and keyboard focus indicator. + No K1-only field override or new control was introduced. Registry, component + documentation and the living catalog were updated. +- The shared spatial viewport uses one explicit rounded compositing clip and + an isolated stacking context, including the native canvas/iframe and overlays. + This addresses corner leakage without decorative strokes or changes to the + scene grid, axes or camera resize handle. Confirmation against the owner's + actual GPU-rendered screenshot remains part of visual acceptance. + +## Verification and delivery + +Synthetic checks are not physical scanner acceptance. The final artifact and +installation evidence are recorded below. No physical scanner command, BLE +probe or credential extraction was performed from CLI. Existing onboard +credentials and pre-install guards are retained. Builds run sequentially on +the 18 GB Mac; Docker was not running. The canonical Core on port 8000 stays +available. + +Source checks: 167 control/protocol/Node/supervisor/coordinator tests; 22 selected +acquisition lifecycle tests; 809 Control Station unit tests. Regressions cover +retained idle connection, two explicit scans with distinct command dialogues, +no idle commands/replay, stale CAS, completed connection versus actual loss, +and admission of the owned pending START. Design Guideline production build and +registry validation passed. Production Core build and packaging checks follow. + +Full acquisition lifecycle regression then passed: 631 tests (includes the 22 +focused tests above). Installer/package lifecycle: 14 passed. Core TypeScript +and production build passed (8.29 s). Ruff and whitespace checks passed. +Design Guideline pin: `26a1bf72a2a32b002e51f910e8faa300333bb6c3`. +R16 package versions: Node 0.8.13 and K1 0.1.13. diff --git a/packages/spatial-ui/src/spatial.css b/packages/spatial-ui/src/spatial.css index fa2af6f..fcdc563 100644 --- a/packages/spatial-ui/src/spatial.css +++ b/packages/spatial-ui/src/spatial.css @@ -44,6 +44,8 @@ min-height: 0; overflow: hidden; border-radius: 1rem; + clip-path: inset(0 round 1rem); + isolation: isolate; background: #06070a; box-shadow: none; } diff --git a/plugins/xgrids-k1/frontend/src/sensors/presentation.ts b/plugins/xgrids-k1/frontend/src/sensors/presentation.ts index fea79f1..8823aa0 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/presentation.ts +++ b/plugins/xgrids-k1/frontend/src/sensors/presentation.ts @@ -2,19 +2,13 @@ import type {Sensor} from './runtime'; export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} { if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'}; - if(device.control?.phase==='completed'&&device.snapshot.acquisition==='idle')return {label:'Устройство остановлено',tone:'neutral'}; if(!device.online||!device.verified)return device.control?.network_applied - ?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'}; - if(device.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'}; - if(device.snapshot.acquisition==='streaming')return {label:'Идёт захват',tone:'success'}; - if(['preparing','starting'].includes(device.snapshot.acquisition))return {label:'Запускается',tone:'neutral'}; - if(device.snapshot.acquisition==='stopping')return {label:'Останавливается',tone:'neutral'}; - return {label:device.control?.can_start?'Готов к запуску':'Подключён',tone:'success'}; + ?{label:'Нет связи с K1',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'}; + return {label:'Подключён',tone:'success'}; } export function k1ConnectionNotice(device:Sensor,fresh:boolean):string { if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.'; - if(device.control?.phase==='completed')return 'Устройство остановлено. Проверьте связь с K1 перед следующим запуском; повторно вводить настройки Wi-Fi не нужно.'; if(device.control?.reason_code==='application_authority_unavailable') return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.'; return device.control?.network_applied diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts index 7ac999a..41c6d4a 100644 --- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts +++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts @@ -1516,13 +1516,14 @@ export function useXgridsK1Runtime(enabled: boolean) { ); } - if (["idle", "closed", "completed"].includes(phase)) { + if (["idle", "closed"].includes(phase)) { throw new ApiError( "K1 ещё не подключён. Сначала завершите подключение устройства в «Парке».", ); } - if (phase === "connection-ready") { + if (phase === "connection-ready" || (phase === "completed" + && nextState.application_control_session?.control_socket_open === true)) { const physical = nextState.application_control_session?.physical_command ?? nextState.physical_command; if (physical?.requires_reconciliation === true) { diff --git a/plugins/xgrids-k1/packaging/build_deb.py b/plugins/xgrids-k1/packaging/build_deb.py index 1bca640..77e6e80 100644 --- a/plugins/xgrids-k1/packaging/build_deb.py +++ b/plugins/xgrids-k1/packaging/build_deb.py @@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402 from debian import package # noqa: E402 from runtime_payload import files as runtime_files # noqa: E402 -VERSION = "0.1.12" +VERSION = "0.1.13" RESOURCES = ( "plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/plugin.manifest.json", @@ -135,7 +135,7 @@ Architecture: amd64 Maintainer: NODE.DC local build Section: admin Priority: optional -Depends: mission-core-node (>= 0.8.12), mission-core-node (<< 0.9.0), +Depends: mission-core-node (>= 0.8.13), mission-core-node (<< 0.9.0), systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg Breaks: mission-core-node (<< 0.8.0) Replaces: mission-core-node (<< 0.8.0) diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index 348dac7..72ce288 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -3814,6 +3814,18 @@ class XgridsK1CompatibilityService: expected_session_generation: int | None = None, expected_state_revision: int | None = None, ) -> None: + current = self._application_control_session.snapshot() + if current.get("state") == "completed" and current.get("control_socket_open") is True: + self._application_control_session.close_prestart( + expected_session_generation=expected_session_generation, + expected_state_revision=expected_state_revision, + ) + self._application_control_session.close() + retired = self._application_control_session.snapshot() + if retired.get("session_generation") != current.get("session_generation"): + raise ApplicationAcceptanceError("control session changed during retirement") + expected_session_generation = retired.get("session_generation") + expected_state_revision = retired.get("state_revision") self._application_control_session.retire_for_network_change( allow_terminal_failure=allow_terminal_failure, expected_session_generation=expected_session_generation, @@ -27534,7 +27546,10 @@ class XgridsK1CompatibilityService: } supervisor = self._connection_supervisor.snapshot() if ( - control_state in active_control_states + (control_state in active_control_states or ( + control_state == "completed" + and application_control_session.get("control_socket_open") is True + )) and isinstance(verified_control, Mapping) and supervisor.intent is not None and supervisor.endpoint.target is not None @@ -27703,7 +27718,10 @@ class XgridsK1CompatibilityService: supervisor = self._connection_supervisor.snapshot() if ( - control_state in {"idle", "completed", "closed", "failed"} + (control_state in {"idle", "closed", "failed"} or ( + control_state == "completed" + and application_control_session.get("control_socket_open") is not True + )) and supervisor.control_plane.state == "healthy" and supervisor.control_plane.session_id is not None and supervisor.intent is not None diff --git a/src/k1link/device_plugins/xgrids_k1/node_sensor.py b/src/k1link/device_plugins/xgrids_k1/node_sensor.py index a705771..abe0b02 100644 --- a/src/k1link/device_plugins/xgrids_k1/node_sensor.py +++ b/src/k1link/device_plugins/xgrids_k1/node_sensor.py @@ -241,12 +241,17 @@ class NodeK1Sensor: control = state.get("application_control_session") or {} phase = control.get("state") physical = control.get("physical_command") or state.get("physical_command") or {} + if ("acquisition.start" in dispatched + and phase in {"start-requested", "initializing", "scanning"}): + # The just-admitted START owns its pending physical edge. + # Returning its state does not dispatch or reconcile it again. + return project_sensor(state, node_id) if physical.get("requires_reconciliation"): raise ValueError("Physical state requires explicit reconciliation") acquisition = state.get("acquisition") or {} payload = {"expected_snapshot_runtime_id": runtime} next_action = None - if phase == "connection-ready": + if phase in {"connection-ready", "completed"}: if control.get("inspection_only"): next_action = "application-control.session.open" payload.update(acceptance, timezone_name="UTC") @@ -276,7 +281,7 @@ class NodeK1Sensor: expected_state_revision=acquisition["state_revision"], physical_acceptance=acceptance, ) - elif phase in {"failed", "idle", "closed", "completed"}: + elif phase in {"failed", "idle", "closed"}: raise ValueError("K1 control not ready") elif phase in {"start-requested", "initializing", "scanning"}: return project_sensor(state, node_id) diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py index b25976b..0c15bdb 100644 --- a/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py @@ -285,6 +285,7 @@ class PhysicalAcceptanceDialogueExecutor: self._active_authority: ApplicationControlAuthority | None = None self._active_binding: LiveDeviceControlBinding | None = None self._prepared_binding: LiveDeviceControlBinding | None = None + self._standby_binding: LiveDeviceControlBinding | None = None self._checkpoint_owner = object() self._issued_checkpoint: str | None = None self._start_permit_snapshot: dict[str, object] | None = None @@ -801,9 +802,23 @@ class PhysicalAcceptanceDialogueExecutor: allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC}, ) self._dialogue_stage = "standby-confirmed" + self._standby_binding = binding self._active_authority = None self._active_binding = None + def maintain_standby_until_next_acquisition(self, requested: Callable[[], bool]) -> None: + """Pump the retained socket after READY; issue no device commands.""" + if self._dialogue_stage != "standby-confirmed" or self._standby_binding is None: + raise ApplicationAcceptanceError("next acquisition requires confirmed standby") + while True: + self._transport.maintain_open_for( + CONTROL_NETWORK_PUMP_QUANTUM_SECONDS, + allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC}, + ) + self._transport.validate_bound_status(self._standby_binding) + if requested() and self._transport.pre_start_ready(self._standby_binding): + return + def snapshot(self) -> dict[str, object]: return { "mode": "physical-acceptance-only", diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py index ef169b3..a6d21ad 100644 --- a/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py @@ -170,8 +170,11 @@ class OperatorPresenceConfirmation: class InteractiveApplicationControlSession: - """Own one canonical K1 MQTT dialogue across explicit operator UI events. + """Own canonical acquisition dialogues and retain control between scans. + Each explicit new scan gets a fresh one-shot MQTT dialogue on the existing + device/network binding. Completed scans keep pumping their idle connection + until that request or explicit local retirement; no Wi-Fi setup is repeated. Only this background thread touches the MQTT client. UI requests merely release one named checkpoint. No checkpoint is advanced by elapsed time, and neither START nor STOP has an automatic retry path. @@ -361,7 +364,7 @@ class InteractiveApplicationControlSession: expected_session_generation=expected_session_generation, expected_state_revision=expected_state_revision, ) - self._require_phase_locked("connection-ready") + self._require_workspace_entry_locked() if self._inspection_only and not self._inspection_promotion_allowed: raise ApplicationAcceptanceError( "read-only inspection has not completed its Verify boundary" @@ -376,7 +379,7 @@ class InteractiveApplicationControlSession: expected_session_generation=expected_session_generation, expected_state_revision=expected_state_revision, ) - self._require_phase_locked("connection-ready") + self._require_workspace_entry_locked() if self._inspection_only and not self._inspection_promotion_allowed: raise ApplicationAcceptanceError( "read-only inspection has not completed its Verify boundary" @@ -385,6 +388,12 @@ class InteractiveApplicationControlSession: self._workspace_requested.set() return self.snapshot() + def _require_workspace_entry_locked(self) -> None: + if (self._phase == "completed" and self._transport is not None + and not self._cancel_requested): + return + self._require_phase_locked("connection-ready") + def validate_connection_binding(self) -> None: """Fail closed when the DeviceInfo-bound route lost command authority.""" @@ -695,6 +704,7 @@ class InteractiveApplicationControlSession: "connection-ready", "workspace-ready", "project-ready", + "completed", }: raise ApplicationAcceptanceError( "control session can be closed safely only between pre-START checkpoints" @@ -825,9 +835,13 @@ class InteractiveApplicationControlSession: "state": phase, "session_generation": self._run_generation, "state_revision": self._state_revision, - "control_socket_open": phase not in {"idle", "completed", "closed", "failed"}, + "control_socket_open": ( + self._transport is not None and phase not in {"idle", "closed", "failed"} + ), "can_open": self._can_open_locked(), - "can_enter_workspace": phase == "connection-ready", + "can_enter_workspace": phase == "connection-ready" or ( + phase == "completed" and self._transport is not None + ), "can_prepare_project": phase == "workspace-ready", "can_start": phase == "project-ready", "can_stop": phase == "scanning", @@ -854,6 +868,7 @@ class InteractiveApplicationControlSession: executor: PhysicalAcceptanceDialogueExecutor | None = None transport: ReviewedApplicationMqttTransport | None = None stop_publish_attempts_before_dispatch: int | None = None + completed_acquisition = False try: with self._lock: host = self._host @@ -862,265 +877,301 @@ class InteractiveApplicationControlSession: raise ApplicationAcceptanceError("control session inputs are unavailable") authority = self._authority_loader.load() - transport = self._transport_factory(host) coordinator = self._physical_command_coordinator - if coordinator is not None: - transport.install_evidence_observer(coordinator) - if self._connection_path_validator is not None: - self._validate_connection_path("control-open-preflight") - transport.install_dispatch_guard(self._acquire_connection_dispatch_lease) - with self._lock: - self._transport = transport - transport.open() - orchestrator = ShadowApplicationBootstrapOrchestrator( - authority, - epoch_seconds=self._epoch_seconds(), - timezone_name=timezone_name, - ) - executor = PhysicalAcceptanceDialogueExecutor(transport) - with self._lock: - inspection_only = self._inspection_only - # DeviceInfo (ordinal 1) is the only bootstrap request that may - # cross the socket before durable physical-target admission. In - # particular, ordinal 4 mutates the K1 clock, so the legacy - # collapsed ordinals 1-6 path must never run before the coordinator - # can reject a retired identity discovered under a fresh BLE - # transport UUID. - binding = executor.run_read_only_inspection_stage(orchestrator) - control_session_id = f"application-control-{generation}-{time.monotonic_ns()}" - with self._lock: - connection_binding = self._connection_binding - if coordinator is not None: - if connection_binding is None: - raise ApplicationAcceptanceError( - "durable physical control requires an exact connection binding" - ) - coordinator.bind_control_session( - PhysicalCommandRuntimeBinding( - vendor_device_id_sha256=hash_physical_identity(binding.vendor_device_id), - device_serial_sha256=hash_physical_identity(binding.device_serial), - compatibility_profile_id=COMPATIBILITY_PROFILE_ID, - intent_id=connection_binding.intent_id, - transport_ref=connection_binding.transport_ref, - connection_mode=connection_binding.connection_mode, - target_ipv4=connection_binding.target_ipv4, - target_port=connection_binding.target_port, - host_path_epoch=connection_binding.host_path_epoch, - control_session_id=control_session_id, - producer_generation=generation, - ) + while True: + transport = self._transport_factory(host) + if coordinator is not None: + transport.install_evidence_observer(coordinator) + if self._connection_path_validator is not None: + self._validate_connection_path("control-open-preflight") + transport.install_dispatch_guard(self._acquire_connection_dispatch_lease) + with self._lock: + self._transport = transport + transport.open() + completed_acquisition = False + orchestrator = ShadowApplicationBootstrapOrchestrator( + authority, + epoch_seconds=self._epoch_seconds(), + timezone_name=timezone_name, ) - if not inspection_only: - binding = executor.complete_connection_stage( - orchestrator, - expected_binding=binding, - ) - # Publish the identity proof and the phase under one lock so a - # consumer cannot observe connection-ready without its DeviceInfo - # evidence (or evidence while still claiming to be connecting). - with self._lock: - self._control_authority = authority - self._live_control_binding = binding - transport_snapshot = self._live_transport_snapshot_locked() - self._verified_control = { - "logical_device_id": binding.vendor_device_id, - "compatibility_profile_id": COMPATIBILITY_PROFILE_ID, - "control_session_id": control_session_id, - "producer_generation": generation, - "source": "mqtt-device-info", - **self._control_proof_fields(transport_snapshot), - **( - { - "intent_id": connection_binding.intent_id, - "transport_ref": connection_binding.transport_ref, - "host_path_epoch": connection_binding.host_path_epoch, - "target_ipv4": connection_binding.target_ipv4, - "target_port": connection_binding.target_port, - "connection_mode": connection_binding.connection_mode, - } - if connection_binding is not None - else {} - ), - } - self._set_phase_locked("connection-ready") - - workspace = executor.wait_for_operator_checkpoint( - "workspace-entered", - self._workspace_requested.is_set, - reconciled_active_observed=self._active_recovery_requested.is_set, - ) - if workspace is None: - self._validate_connection_binding("active-recovery-adoption") - executor.adopt_reconciled_scanning( - authority=authority, - binding=binding, - ) - with self._scanning_transition_gate: - # Recovery adopts an already-active physical acquisition. - # Its checkpoint deliberately retains an open transport - # gap until the post-Rerun PCL confirmation hook closes it; - # nevertheless an operator STOP must remain admissible in - # this interval and can cease that gap with terminal READY. - self._scanning_observer_confirmed = True - self._set_phase("scanning") - else: - if inspection_only: - self._validate_connection_binding_snapshot( - "inspection-promotion-pre-dispatch" + executor = PhysicalAcceptanceDialogueExecutor(transport) + with self._lock: + inspection_only = self._inspection_only + # DeviceInfo (ordinal 1) is the only bootstrap request that may + # cross the socket before durable physical-target admission. In + # particular, ordinal 4 mutates the K1 clock, so the legacy + # collapsed ordinals 1-6 path must never run before the coordinator + # can reject a retired identity discovered under a fresh BLE + # transport UUID. + binding = executor.run_read_only_inspection_stage(orchestrator) + control_session_id = f"application-control-{generation}-{time.monotonic_ns()}" + with self._lock: + connection_binding = self._connection_binding + if coordinator is not None: + if connection_binding is None: + raise ApplicationAcceptanceError( + "durable physical control requires an exact connection binding" + ) + coordinator.bind_control_session( + PhysicalCommandRuntimeBinding( + vendor_device_id_sha256=hash_physical_identity(binding.vendor_device_id), + device_serial_sha256=hash_physical_identity(binding.device_serial), + compatibility_profile_id=COMPATIBILITY_PROFILE_ID, + intent_id=connection_binding.intent_id, + transport_ref=connection_binding.transport_ref, + connection_mode=connection_binding.connection_mode, + target_ipv4=connection_binding.target_ipv4, + target_port=connection_binding.target_port, + host_path_epoch=connection_binding.host_path_epoch, + control_session_id=control_session_id, + producer_generation=generation, + ) ) + if not inspection_only: binding = executor.complete_connection_stage( orchestrator, expected_binding=binding, ) - self._validate_connection_binding_snapshot( - "inspection-promotion-post-response" + # Publish the identity proof and the phase under one lock so a + # consumer cannot observe connection-ready without its DeviceInfo + # evidence (or evidence while still claiming to be connecting). + with self._lock: + self._control_authority = authority + self._live_control_binding = binding + transport_snapshot = self._live_transport_snapshot_locked() + self._verified_control = { + "logical_device_id": binding.vendor_device_id, + "compatibility_profile_id": COMPATIBILITY_PROFILE_ID, + "control_session_id": control_session_id, + "producer_generation": generation, + "source": "mqtt-device-info", + **self._control_proof_fields(transport_snapshot), + **( + { + "intent_id": connection_binding.intent_id, + "transport_ref": connection_binding.transport_ref, + "host_path_epoch": connection_binding.host_path_epoch, + "target_ipv4": connection_binding.target_ipv4, + "target_port": connection_binding.target_port, + "connection_mode": connection_binding.connection_mode, + } + if connection_binding is not None + else {} + ), + } + self._set_phase_locked( + "workspace-requested" if self._workspace_requested.is_set() + else "connection-ready" ) - self._validate_connection_binding_snapshot("workspace-entry-pre-dispatch") - executor.run_workspace_entry_stage( - orchestrator, - workspace, - dispatch_guard=lambda: self._validate_connection_binding_snapshot( - "workspace-entry-dispatch" - ), - ) - self._validate_connection_binding_snapshot("workspace-entry-post-response") - self._set_phase("workspace-ready") - project = executor.wait_for_operator_checkpoint( - "project-prompt-opened", - self._project_requested.is_set, + workspace = executor.wait_for_operator_checkpoint( + "workspace-entered", + self._workspace_requested.is_set, + reconciled_active_observed=self._active_recovery_requested.is_set, ) - assert project is not None - self._validate_connection_binding_snapshot("project-prompt-pre-dispatch") - binding = executor.run_project_prompt_stage( - orchestrator, - project, - dispatch_guard=lambda: self._validate_connection_binding_snapshot( - "project-prompt-dispatch" - ), - ) - self._validate_connection_binding_snapshot("project-prompt-post-response") - self._set_phase("project-ready") - - start_checkpoint = executor.wait_for_operator_checkpoint( - "start-confirmed", - self._start_requested.is_set, - ) - assert start_checkpoint is not None - start_command, start_confirmation = self._start_request() - start_permit = PhysicalAcceptancePermit( - start_confirmation.checklist(ModelingAction.START) - ) - self._validate_connection_binding_snapshot("start-pre-dispatch") - self._set_phase("initializing") - start_active_observed = False - start_transition_gate_acquired = False - - def observe_start_active() -> None: - nonlocal start_active_observed, start_transition_gate_acquired - if start_active_observed: - return - self._scanning_transition_gate.acquire() - start_transition_gate_acquired = True - try: - if coordinator is not None: - coordinator.resolve("start") - # Activate the durable recovery checkpoint at the same - # exact SCANNING proof as the physical ledger. The - # gate remains held while the read-only ordinals 13-14 - # finish, so STOP cannot overtake the later public - # ``scanning`` transition. - if not self._scanning_observer_confirmed: - self._scanning_observer_confirmed = ( - self._notify_scanning_observer() - ) - start_active_observed = True - except BaseException: - self._scanning_transition_gate.release() - start_transition_gate_acquired = False - raise - - try: - executor.execute_canonical_start( - start_command, - build_canonical_post_start_observation(authority, binding), + if workspace is None: + self._validate_connection_binding("active-recovery-adoption") + executor.adopt_reconciled_scanning( authority=authority, binding=binding, - permit=start_permit, - checkpoint=start_checkpoint, - dispatch_guard=lambda: self._validate_connection_binding_snapshot( - "start-dispatch" - ), - start_active_observer=observe_start_active, ) - # Compatibility executors used by lower-level integrations may - # return without invoking the new proof callback. A normal - # production executor invokes it before the read-only refresh; - # this idempotent fallback remains strictly post-success. - observe_start_active() - self._validate_connection_binding_snapshot("start-post-response") - self._set_phase("scanning") - finally: - if start_transition_gate_acquired: - self._scanning_transition_gate.release() + with self._scanning_transition_gate: + # Recovery adopts an already-active physical acquisition. + # Its checkpoint deliberately retains an open transport + # gap until the post-Rerun PCL confirmation hook closes it; + # nevertheless an operator STOP must remain admissible in + # this interval and can cease that gap with terminal READY. + self._scanning_observer_confirmed = True + self._set_phase("scanning") + else: + if inspection_only: + self._validate_connection_binding_snapshot( + "inspection-promotion-pre-dispatch" + ) + binding = executor.complete_connection_stage( + orchestrator, + expected_binding=binding, + ) + self._validate_connection_binding_snapshot( + "inspection-promotion-post-response" + ) + self._validate_connection_binding_snapshot("workspace-entry-pre-dispatch") + executor.run_workspace_entry_stage( + orchestrator, + workspace, + dispatch_guard=lambda: self._validate_connection_binding_snapshot( + "workspace-entry-dispatch" + ), + ) + self._validate_connection_binding_snapshot("workspace-entry-post-response") + self._set_phase("workspace-ready") - executor.maintain_active_until_stop_requested(self._stop_requested.is_set) - ( - stop_command, - stop_confirmation, - stop_dispatch_admission_deadline_reached, - ) = self._stop_request() - stop_permit = PhysicalAcceptancePermit(stop_confirmation.checklist(ModelingAction.STOP)) - # Capture the exact transport counter before any remaining - # read-only validation. A deadline that expires during one of - # those checks is still deterministic zero-publish evidence. - stop_transport_before, stop_transport_before_available = ( - self._transport_snapshot_safely(transport) - ) - if stop_transport_before_available: - stop_publish_attempts_before_dispatch = self._json_int_or_none( - stop_transport_before.get("publish_attempts") + project = executor.wait_for_operator_checkpoint( + "project-prompt-opened", + self._project_requested.is_set, + ) + assert project is not None + self._validate_connection_binding_snapshot("project-prompt-pre-dispatch") + binding = executor.run_project_prompt_stage( + orchestrator, + project, + dispatch_guard=lambda: self._validate_connection_binding_snapshot( + "project-prompt-dispatch" + ), + ) + self._validate_connection_binding_snapshot("project-prompt-post-response") + self._set_phase("project-ready") + + start_checkpoint = executor.wait_for_operator_checkpoint( + "start-confirmed", + self._start_requested.is_set, + ) + assert start_checkpoint is not None + start_command, start_confirmation = self._start_request() + start_permit = PhysicalAcceptancePermit( + start_confirmation.checklist(ModelingAction.START) + ) + self._validate_connection_binding_snapshot("start-pre-dispatch") + self._set_phase("initializing") + start_active_observed = False + start_transition_gate_acquired = False + + def observe_start_active() -> None: + nonlocal start_active_observed, start_transition_gate_acquired + if start_active_observed: + return + self._scanning_transition_gate.acquire() + start_transition_gate_acquired = True + try: + if coordinator is not None: + coordinator.resolve("start") + # Activate the durable recovery checkpoint at the same + # exact SCANNING proof as the physical ledger. The + # gate remains held while the read-only ordinals 13-14 + # finish, so STOP cannot overtake the later public + # ``scanning`` transition. + if not self._scanning_observer_confirmed: + self._scanning_observer_confirmed = ( + self._notify_scanning_observer() + ) + start_active_observed = True + except BaseException: + self._scanning_transition_gate.release() + start_transition_gate_acquired = False + raise + + try: + executor.execute_canonical_start( + start_command, + build_canonical_post_start_observation(authority, binding), + authority=authority, + binding=binding, + permit=start_permit, + checkpoint=start_checkpoint, + dispatch_guard=lambda: self._validate_connection_binding_snapshot( + "start-dispatch" + ), + start_active_observer=observe_start_active, + ) + # Compatibility executors used by lower-level integrations may + # return without invoking the new proof callback. A normal + # production executor invokes it before the read-only refresh; + # this idempotent fallback remains strictly post-success. + observe_start_active() + self._validate_connection_binding_snapshot("start-post-response") + self._set_phase("scanning") + finally: + if start_transition_gate_acquired: + self._scanning_transition_gate.release() + + executor.maintain_active_until_stop_requested(self._stop_requested.is_set) + ( + stop_command, + stop_confirmation, + stop_dispatch_admission_deadline_reached, + ) = self._stop_request() + stop_permit = PhysicalAcceptancePermit( + stop_confirmation.checklist(ModelingAction.STOP) ) - self._require_stop_dispatch_deadline_open( - stop_dispatch_admission_deadline_reached - ) - self._validate_connection_binding_snapshot("stop-pre-dispatch") - self._require_stop_dispatch_deadline_open( - stop_dispatch_admission_deadline_reached - ) - self._set_phase("stopping") - logger.info( - "K1 STOP timing checkpoint", - extra={ - "event_code": "k1_stop_dispatch_timing", - "operation_stage": "pre-dispatch-validation-complete", - "device_command_sent": False, - }, - ) - executor.execute_canonical_stop( - stop_command, - stop_permit, - dispatch_guard=lambda: self._validate_connection_binding_snapshot( - "stop-dispatch" - ), - dispatch_admission_deadline_reached=( + # Capture the exact transport counter before any remaining + # read-only validation. A deadline that expires during one of + # those checks is still deterministic zero-publish evidence. + stop_transport_before, stop_transport_before_available = ( + self._transport_snapshot_safely(transport) + ) + if stop_transport_before_available: + stop_publish_attempts_before_dispatch = self._json_int_or_none( + stop_transport_before.get("publish_attempts") + ) + self._require_stop_dispatch_deadline_open( stop_dispatch_admission_deadline_reached - ), - ) - logger.info( - "K1 STOP timing checkpoint", - extra={ - "event_code": "k1_stop_dispatch_timing", - "operation_stage": "correlated-application-response", - "device_command_sent": True, - }, - ) - self._validate_connection_binding_snapshot("stop-post-response") - self._set_phase("awaiting-standby-confirmation") - executor.maintain_post_stop_until_standby() - if coordinator is not None: - coordinator.resolve("stop") - self._set_phase("completed") + ) + self._validate_connection_binding_snapshot("stop-pre-dispatch") + self._require_stop_dispatch_deadline_open( + stop_dispatch_admission_deadline_reached + ) + self._set_phase("stopping") + logger.info( + "K1 STOP timing checkpoint", + extra={ + "event_code": "k1_stop_dispatch_timing", + "operation_stage": "pre-dispatch-validation-complete", + "device_command_sent": False, + }, + ) + executor.execute_canonical_stop( + stop_command, + stop_permit, + dispatch_guard=lambda: self._validate_connection_binding_snapshot( + "stop-dispatch" + ), + dispatch_admission_deadline_reached=( + stop_dispatch_admission_deadline_reached + ), + ) + logger.info( + "K1 STOP timing checkpoint", + extra={ + "event_code": "k1_stop_dispatch_timing", + "operation_stage": "correlated-application-response", + "device_command_sent": True, + }, + ) + self._validate_connection_binding_snapshot("stop-post-response") + self._set_phase("awaiting-standby-confirmation") + executor.maintain_post_stop_until_standby() + if coordinator is not None: + coordinator.resolve("stop") + with self._lock: + # Clear only the acquisition checkpoints before publishing READY. + # Network ownership and DeviceInfo proof remain on this socket. + self._workspace_requested.clear() + self._active_recovery_requested.clear() + self._project_requested.clear() + self._start_requested.clear() + self._stop_requested.clear() + self._start_confirmation = None + self._stop_confirmation = None + self._stop_dispatch_admission_deadline_reached = None + self._prepared_start_command = None + self._prepared_stop_command = None + self._project_name = None + self._scanning_observer_confirmed = self._scanning_observer is None + completed_acquisition = True + self._set_phase_locked("completed") + executor.maintain_standby_until_next_acquisition(self._workspace_requested.is_set) + self._validate_connection_binding_snapshot("next-acquisition-preflight") + # Operation keys and response correlations are one-shot per MQTT + # dialogue. A new explicit scan gets a fresh socket, never reset + # consumption sets or ambiguous reuse of the old response IDs. + transport.close() + with self._lock: + self._transport = None + self._verified_control = None + self._run_generation += 1 + generation = self._run_generation + executor = None + stop_publish_attempts_before_dispatch = None except Exception as exc: dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely( executor @@ -1217,7 +1268,9 @@ class InteractiveApplicationControlSession: and stop_command_attempted is False and not diagnostic_evidence_unavailable ) - outcome_unknown = not definite_stop_admission_rejected_before_publish and ( + outcome_unknown = not ( + definite_stop_admission_rejected_before_publish or completed_acquisition + ) and ( isinstance(exc, ApplicationCommandOutcomeUnknown) or modeling_command_attempted is True or bool(diagnostic_evidence_unavailable) @@ -1288,7 +1341,7 @@ class InteractiveApplicationControlSession: ) is None ) - safe_to_retry = status_reconciled_prestart_failure or ( + safe_to_retry = completed_acquisition or status_reconciled_prestart_failure or ( not outcome_unknown and ( not transport_created diff --git a/tests/test_k1_installer.py b/tests/test_k1_installer.py index ea6462c..446967e 100644 --- a/tests/test_k1_installer.py +++ b/tests/test_k1_installer.py @@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member( position += 60 + length + length % 2 with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive: control = archive.extractfile("control").read().decode() - assert "Depends: mission-core-node (>= 0.8.12)" in control + assert "Depends: mission-core-node (>= 0.8.13)" in control assert "Replaces: mission-core-node (<< 0.8.0)" in control diff --git a/tests/test_node_k1_bridge.py b/tests/test_node_k1_bridge.py index e15acc7..b2e4044 100644 --- a/tests/test_node_k1_bridge.py +++ b/tests/test_node_k1_bridge.py @@ -438,10 +438,26 @@ def test_applied_wifi_does_not_grant_control_or_start_authority(): assert item["control"]["reason_code"] is None -def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence(): +@pytest.mark.parametrize("initial_phase", ["connection-ready", "completed"]) +@pytest.mark.parametrize("pending_start", [False, True]) +def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence( + initial_phase, pending_start, +): async def run(): device = bridge() + device.facade.current["application_control_session"]["state"] = initial_phase sensor = NodeK1Sensor(device, None) + original_invoke = device.invoke + + async def invoke(action, parameters, operation_id): + result = await original_invoke(action, parameters, operation_id) + if action == "acquisition.start" and pending_start: + result["application_control_session"]["physical_command"] = { + "requires_reconciliation": True, + } + return result + + device.invoke = invoke item = project_sensor(state(), "node-test") command = { "operation_id": "op_" + "a" * 32, diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index 2222a38..3ec4bf3 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -34792,3 +34792,24 @@ def test_power_loss_during_observation_preserves_audit_and_releases_ownership( is True ) assert write_calls == 1 + + +@pytest.mark.parametrize("socket_open", [True, False]) +def test_completed_acquisition_does_not_imply_control_loss(tmp_path, socket_open): + service, _ = service_with_fake_runtime(tmp_path) + binding = _seed_supervised_connection(service) + control = { + "state": "completed", "control_socket_open": socket_open, + "verified_control": _verified_control_for_binding( + binding, control_session_id="retained-control-session", + ), + } + service._reconcile_connection_supervisor(control, {"source_mode": "idle"}) # noqa: SLF001 + snapshot = service._connection_supervisor.snapshot() # noqa: SLF001 + assert snapshot.authority.control_allowed is socket_open + assert snapshot.authority.acquisition_start_allowed is socket_open + if socket_open: + service._reconcile_connection_supervisor( # noqa: SLF001 + {**control, "state": "failed"}, {"source_mode": "idle"}, + ) + assert not service._connection_supervisor.snapshot().authority.control_allowed # noqa: SLF001 diff --git a/tests/test_xgrids_application_acceptance.py b/tests/test_xgrids_application_acceptance.py index e6fcaaa..73d8b7b 100644 --- a/tests/test_xgrids_application_acceptance.py +++ b/tests/test_xgrids_application_acceptance.py @@ -367,6 +367,17 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest" assert response_evidence[-1]["operation_key"] == "modeling:stop" assert all("payload" not in item for item in response_evidence) + before_batches = list(transport.batches) + standby_pumps = len(transport.maintain_calls) + transport.pre_start_ready = lambda _binding: True + executor.maintain_standby_until_next_acquisition( + lambda: len(transport.maintain_calls) >= standby_pumps + 3, + ) + assert transport.batches == before_batches + assert len(transport.maintain_calls) == standby_pumps + 3 + # A next dialogue must use a fresh transport, not reset one-shot guards. + with pytest.raises(ApplicationAcceptanceError): + executor.run_connection_stage(orchestrator) assert APPLICATION_KEY not in str(executor.snapshot()) assert VENDOR_DEVICE_ID not in str(executor.snapshot()) TypeAdapter(JsonValue).validate_python(executor.snapshot()) diff --git a/tests/test_xgrids_application_session.py b/tests/test_xgrids_application_session.py index c792e15..e6eed33 100644 --- a/tests/test_xgrids_application_session.py +++ b/tests/test_xgrids_application_session.py @@ -372,6 +372,14 @@ class FakeExecutor: def maintain_post_stop_until_standby(self) -> None: self.records.append("wait:device-standby") + def maintain_standby_until_next_acquisition(self, requested: Callable[[], bool]) -> None: + while not requested(): + if self.transport.state == "closed": + raise RuntimeError("test transport closed") + if not self.transport.proof_fresh: + raise ApplicationControlProofStale("test control proof expired") + threading.Event().wait(0.005) + def snapshot(self) -> dict[str, object]: return { "records": list(self.records), @@ -2160,9 +2168,11 @@ def test_new_explicit_session_waits_for_old_worker_transport_retirement( _wait_phase(session, "scanning") session.request_stop(confirmation=_confirmation()) _wait_phase(session, "completed") + assert new_thread.is_alive() + session.close() new_thread.join(timeout=2.0) assert not new_thread.is_alive() - assert transports[1].close_calls == 1 + assert transports[1].close_calls == 2 # explicit close and idempotent worker cleanup def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed( @@ -2856,3 +2866,66 @@ def test_pretransport_authority_failure_remains_safe_after_worker_retirement() - assert failure["diagnostic_snapshot_unavailable"] == [] assert failure["diagnostic_evidence_unavailable"] == [] assert failure["safe_to_retry"] is True + + +@pytest.fixture(autouse=True) +def close_test_control_sessions(monkeypatch: pytest.MonkeyPatch): + sessions = [] + original = InteractiveApplicationControlSession.__init__ + + def tracked(self, *args, **kwargs): + original(self, *args, **kwargs) + sessions.append(self) + + monkeypatch.setattr(InteractiveApplicationControlSession, "__init__", tracked) + yield + for session in sessions: + session.close() + + +def test_two_named_scans_retain_idle_connection_and_require_new_dialogues(monkeypatch): + FakeExecutor.records = [] + monkeypatch.setattr(session_module, "PhysicalAcceptanceDialogueExecutor", FakeExecutor) + transports = [] + def create_transport(host): + transport = FakeTransport(host) + transports.append(transport) + return transport + session = InteractiveApplicationControlSession( + FakeAuthorityLoader(), transport_factory=create_transport, + ) + session.open(host="192.168.1.20", timezone_name="UTC", connection_binding=_connection_binding()) + _wait_phase(session, "connection-ready") + first_checkpoint = None + for project in ("FIRST", "SECOND"): + current = session.snapshot() + if first_checkpoint is not None: + with pytest.raises(ApplicationAcceptanceError): + session.enter_workspace(expected_session_generation=first_checkpoint[0], + expected_state_revision=first_checkpoint[1]) + first_checkpoint = (current["session_generation"], current["state_revision"]) + session.enter_workspace(expected_session_generation=current["session_generation"], + expected_state_revision=current["state_revision"]) + _wait_phase(session, "workspace-ready") + session.open_project_prompt() + _wait_phase(session, "project-ready") + session.request_start(project_name=project, confirmation=_confirmation()) + _wait_phase(session, "scanning") + session.request_stop(confirmation=_confirmation()) + finished = _wait_phase(session, "completed") + assert finished["control_socket_open"] is True + assert finished["can_enter_workspace"] is True + assert finished["verified_control"]["control_proof_fresh"] is True + before = list(FakeExecutor.records) + threading.Event().wait(0.025) + assert FakeExecutor.records == before # no timer-driven new acquisition + assert transports[-1].state == "ready" + assert len(transports) == (1 if project == "FIRST" else 2) + assert len(transports) == 2 + assert transports[0].state == "closed" + assert FakeExecutor.records.count("start:11-14") == 2 + assert FakeExecutor.records.count("stop") == 2 + transports[-1].proof_fresh = False + _wait_phase(session, "failed") + assert session.snapshot()["control_socket_open"] is False + assert len(transports) == 2 # no reconnect or physical command replay on real loss