Retain K1 connection between scans and admit the next named acquisition

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 21:13:17 +03:00
parent ed9a77ba73
commit c6693d6f44
17 changed files with 586 additions and 274 deletions
@@ -30,7 +30,7 @@ test('manual K1 surface admits one START only after current control proof',()=>{
assert.doesNotMatch(pending,/Инициировать запуск|Настройки устройства|sensor-live-layout/); assert.doesNotMatch(pending,/Инициировать запуск|Настройки устройства|sensor-live-layout/);
assert.match(pending,/Проверить состояние K1/); assert.match(pending,/Проверить состояние K1/);
assert.match(pending,/авторизовать/); 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); assert.equal(presentation.k1ManualState(device,false).canStart,false);
}); });
test('active acquisition exposes STOP and Rerun without another START',()=>{ 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.match(markup,/K1 калибруется и готовит облако точек/);
assert.doesNotMatch(markup,/Обновить просмотр/); 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 stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}};
const markup=render(stopped); const markup=render(stopped);
assert.match(markup,/Устройство остановлено/); assert.match(markup,/Нет связи с K1/);
assert.match(markup,/Проверить состояние K1/); assert.match(markup,/Проверить состояние K1/);
assert.doesNotMatch(markup,/Инициировать запуск|нет управления/); 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,verified:false}]},'exact-session'),null);
assert.equal(enrolledDevice({...inventory,items:[device,device]},'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');
});
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256 from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "5b882bc3d9b13a86e6c26111ef5ded687ea3a2fc" DG_COMMIT = "26a1bf72a2a32b002e51f910e8faa300333bb6c3"
def guideline_sources(): def guideline_sources():
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.12" VERSION = "0.8.13"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package from debian import package
@@ -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:1720: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.
+2
View File
@@ -44,6 +44,8 @@
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border-radius: 1rem; border-radius: 1rem;
clip-path: inset(0 round 1rem);
isolation: isolate;
background: #06070a; background: #06070a;
box-shadow: none; box-shadow: none;
} }
@@ -2,19 +2,13 @@ import type {Sensor} from './runtime';
export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} { export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'}; 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 if(!device.online||!device.verified)return device.control?.network_applied
?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'}; ?{label:'Нет связи с K1',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'};
if(device.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'}; return {label:'Подключён',tone:'success'};
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'};
} }
export function k1ConnectionNotice(device:Sensor,fresh:boolean):string { export function k1ConnectionNotice(device:Sensor,fresh:boolean):string {
if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.'; if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.';
if(device.control?.phase==='completed')return 'Устройство остановлено. Проверьте связь с K1 перед следующим запуском; повторно вводить настройки Wi-Fi не нужно.';
if(device.control?.reason_code==='application_authority_unavailable') if(device.control?.reason_code==='application_authority_unavailable')
return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.'; return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
return device.control?.network_applied return device.control?.network_applied
@@ -1516,13 +1516,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
); );
} }
if (["idle", "closed", "completed"].includes(phase)) { if (["idle", "closed"].includes(phase)) {
throw new ApiError( throw new ApiError(
"K1 ещё не подключён. Сначала завершите подключение устройства в «Парке».", "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 const physical = nextState.application_control_session?.physical_command
?? nextState.physical_command; ?? nextState.physical_command;
if (physical?.requires_reconciliation === true) { if (physical?.requires_reconciliation === true) {
+2 -2
View File
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
from debian import package # noqa: E402 from debian import package # noqa: E402
from runtime_payload import files as runtime_files # noqa: E402 from runtime_payload import files as runtime_files # noqa: E402
VERSION = "0.1.12" VERSION = "0.1.13"
RESOURCES = ( RESOURCES = (
"plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json", "plugins/xgrids-k1/plugin.manifest.json",
@@ -135,7 +135,7 @@ Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid> Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin Section: admin
Priority: optional 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 systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
Breaks: mission-core-node (<< 0.8.0) Breaks: mission-core-node (<< 0.8.0)
Replaces: mission-core-node (<< 0.8.0) Replaces: mission-core-node (<< 0.8.0)
+20 -2
View File
@@ -3814,6 +3814,18 @@ class XgridsK1CompatibilityService:
expected_session_generation: int | None = None, expected_session_generation: int | None = None,
expected_state_revision: int | None = None, expected_state_revision: int | None = 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( self._application_control_session.retire_for_network_change(
allow_terminal_failure=allow_terminal_failure, allow_terminal_failure=allow_terminal_failure,
expected_session_generation=expected_session_generation, expected_session_generation=expected_session_generation,
@@ -27534,7 +27546,10 @@ class XgridsK1CompatibilityService:
} }
supervisor = self._connection_supervisor.snapshot() supervisor = self._connection_supervisor.snapshot()
if ( 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 isinstance(verified_control, Mapping)
and supervisor.intent is not None and supervisor.intent is not None
and supervisor.endpoint.target is not None and supervisor.endpoint.target is not None
@@ -27703,7 +27718,10 @@ class XgridsK1CompatibilityService:
supervisor = self._connection_supervisor.snapshot() supervisor = self._connection_supervisor.snapshot()
if ( 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.state == "healthy"
and supervisor.control_plane.session_id is not None and supervisor.control_plane.session_id is not None
and supervisor.intent is not None and supervisor.intent is not None
@@ -241,12 +241,17 @@ class NodeK1Sensor:
control = state.get("application_control_session") or {} control = state.get("application_control_session") or {}
phase = control.get("state") phase = control.get("state")
physical = control.get("physical_command") or state.get("physical_command") or {} 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"): if physical.get("requires_reconciliation"):
raise ValueError("Physical state requires explicit reconciliation") raise ValueError("Physical state requires explicit reconciliation")
acquisition = state.get("acquisition") or {} acquisition = state.get("acquisition") or {}
payload = {"expected_snapshot_runtime_id": runtime} payload = {"expected_snapshot_runtime_id": runtime}
next_action = None next_action = None
if phase == "connection-ready": if phase in {"connection-ready", "completed"}:
if control.get("inspection_only"): if control.get("inspection_only"):
next_action = "application-control.session.open" next_action = "application-control.session.open"
payload.update(acceptance, timezone_name="UTC") payload.update(acceptance, timezone_name="UTC")
@@ -276,7 +281,7 @@ class NodeK1Sensor:
expected_state_revision=acquisition["state_revision"], expected_state_revision=acquisition["state_revision"],
physical_acceptance=acceptance, physical_acceptance=acceptance,
) )
elif phase in {"failed", "idle", "closed", "completed"}: elif phase in {"failed", "idle", "closed"}:
raise ValueError("K1 control not ready") raise ValueError("K1 control not ready")
elif phase in {"start-requested", "initializing", "scanning"}: elif phase in {"start-requested", "initializing", "scanning"}:
return project_sensor(state, node_id) return project_sensor(state, node_id)
@@ -285,6 +285,7 @@ class PhysicalAcceptanceDialogueExecutor:
self._active_authority: ApplicationControlAuthority | None = None self._active_authority: ApplicationControlAuthority | None = None
self._active_binding: LiveDeviceControlBinding | None = None self._active_binding: LiveDeviceControlBinding | None = None
self._prepared_binding: LiveDeviceControlBinding | None = None self._prepared_binding: LiveDeviceControlBinding | None = None
self._standby_binding: LiveDeviceControlBinding | None = None
self._checkpoint_owner = object() self._checkpoint_owner = object()
self._issued_checkpoint: str | None = None self._issued_checkpoint: str | None = None
self._start_permit_snapshot: dict[str, object] | None = None self._start_permit_snapshot: dict[str, object] | None = None
@@ -801,9 +802,23 @@ class PhysicalAcceptanceDialogueExecutor:
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC}, allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
) )
self._dialogue_stage = "standby-confirmed" self._dialogue_stage = "standby-confirmed"
self._standby_binding = binding
self._active_authority = None self._active_authority = None
self._active_binding = 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]: def snapshot(self) -> dict[str, object]:
return { return {
"mode": "physical-acceptance-only", "mode": "physical-acceptance-only",
@@ -170,8 +170,11 @@ class OperatorPresenceConfirmation:
class InteractiveApplicationControlSession: 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 Only this background thread touches the MQTT client. UI requests merely
release one named checkpoint. No checkpoint is advanced by elapsed time, release one named checkpoint. No checkpoint is advanced by elapsed time,
and neither START nor STOP has an automatic retry path. and neither START nor STOP has an automatic retry path.
@@ -361,7 +364,7 @@ class InteractiveApplicationControlSession:
expected_session_generation=expected_session_generation, expected_session_generation=expected_session_generation,
expected_state_revision=expected_state_revision, 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: if self._inspection_only and not self._inspection_promotion_allowed:
raise ApplicationAcceptanceError( raise ApplicationAcceptanceError(
"read-only inspection has not completed its Verify boundary" "read-only inspection has not completed its Verify boundary"
@@ -376,7 +379,7 @@ class InteractiveApplicationControlSession:
expected_session_generation=expected_session_generation, expected_session_generation=expected_session_generation,
expected_state_revision=expected_state_revision, 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: if self._inspection_only and not self._inspection_promotion_allowed:
raise ApplicationAcceptanceError( raise ApplicationAcceptanceError(
"read-only inspection has not completed its Verify boundary" "read-only inspection has not completed its Verify boundary"
@@ -385,6 +388,12 @@ class InteractiveApplicationControlSession:
self._workspace_requested.set() self._workspace_requested.set()
return self.snapshot() 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: def validate_connection_binding(self) -> None:
"""Fail closed when the DeviceInfo-bound route lost command authority.""" """Fail closed when the DeviceInfo-bound route lost command authority."""
@@ -695,6 +704,7 @@ class InteractiveApplicationControlSession:
"connection-ready", "connection-ready",
"workspace-ready", "workspace-ready",
"project-ready", "project-ready",
"completed",
}: }:
raise ApplicationAcceptanceError( raise ApplicationAcceptanceError(
"control session can be closed safely only between pre-START checkpoints" "control session can be closed safely only between pre-START checkpoints"
@@ -825,9 +835,13 @@ class InteractiveApplicationControlSession:
"state": phase, "state": phase,
"session_generation": self._run_generation, "session_generation": self._run_generation,
"state_revision": self._state_revision, "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_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_prepare_project": phase == "workspace-ready",
"can_start": phase == "project-ready", "can_start": phase == "project-ready",
"can_stop": phase == "scanning", "can_stop": phase == "scanning",
@@ -854,6 +868,7 @@ class InteractiveApplicationControlSession:
executor: PhysicalAcceptanceDialogueExecutor | None = None executor: PhysicalAcceptanceDialogueExecutor | None = None
transport: ReviewedApplicationMqttTransport | None = None transport: ReviewedApplicationMqttTransport | None = None
stop_publish_attempts_before_dispatch: int | None = None stop_publish_attempts_before_dispatch: int | None = None
completed_acquisition = False
try: try:
with self._lock: with self._lock:
host = self._host host = self._host
@@ -862,265 +877,301 @@ class InteractiveApplicationControlSession:
raise ApplicationAcceptanceError("control session inputs are unavailable") raise ApplicationAcceptanceError("control session inputs are unavailable")
authority = self._authority_loader.load() authority = self._authority_loader.load()
transport = self._transport_factory(host)
coordinator = self._physical_command_coordinator coordinator = self._physical_command_coordinator
if coordinator is not None: while True:
transport.install_evidence_observer(coordinator) transport = self._transport_factory(host)
if self._connection_path_validator is not None: if coordinator is not None:
self._validate_connection_path("control-open-preflight") transport.install_evidence_observer(coordinator)
transport.install_dispatch_guard(self._acquire_connection_dispatch_lease) if self._connection_path_validator is not None:
with self._lock: self._validate_connection_path("control-open-preflight")
self._transport = transport transport.install_dispatch_guard(self._acquire_connection_dispatch_lease)
transport.open() with self._lock:
orchestrator = ShadowApplicationBootstrapOrchestrator( self._transport = transport
authority, transport.open()
epoch_seconds=self._epoch_seconds(), completed_acquisition = False
timezone_name=timezone_name, orchestrator = ShadowApplicationBootstrapOrchestrator(
) authority,
executor = PhysicalAcceptanceDialogueExecutor(transport) epoch_seconds=self._epoch_seconds(),
with self._lock: timezone_name=timezone_name,
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: executor = PhysicalAcceptanceDialogueExecutor(transport)
binding = executor.complete_connection_stage( with self._lock:
orchestrator, inspection_only = self._inspection_only
expected_binding=binding, # DeviceInfo (ordinal 1) is the only bootstrap request that may
) # cross the socket before durable physical-target admission. In
# Publish the identity proof and the phase under one lock so a # particular, ordinal 4 mutates the K1 clock, so the legacy
# consumer cannot observe connection-ready without its DeviceInfo # collapsed ordinals 1-6 path must never run before the coordinator
# evidence (or evidence while still claiming to be connecting). # can reject a retired identity discovered under a fresh BLE
with self._lock: # transport UUID.
self._control_authority = authority binding = executor.run_read_only_inspection_stage(orchestrator)
self._live_control_binding = binding control_session_id = f"application-control-{generation}-{time.monotonic_ns()}"
transport_snapshot = self._live_transport_snapshot_locked() with self._lock:
self._verified_control = { connection_binding = self._connection_binding
"logical_device_id": binding.vendor_device_id, if coordinator is not None:
"compatibility_profile_id": COMPATIBILITY_PROFILE_ID, if connection_binding is None:
"control_session_id": control_session_id, raise ApplicationAcceptanceError(
"producer_generation": generation, "durable physical control requires an exact connection binding"
"source": "mqtt-device-info", )
**self._control_proof_fields(transport_snapshot), coordinator.bind_control_session(
**( PhysicalCommandRuntimeBinding(
{ vendor_device_id_sha256=hash_physical_identity(binding.vendor_device_id),
"intent_id": connection_binding.intent_id, device_serial_sha256=hash_physical_identity(binding.device_serial),
"transport_ref": connection_binding.transport_ref, compatibility_profile_id=COMPATIBILITY_PROFILE_ID,
"host_path_epoch": connection_binding.host_path_epoch, intent_id=connection_binding.intent_id,
"target_ipv4": connection_binding.target_ipv4, transport_ref=connection_binding.transport_ref,
"target_port": connection_binding.target_port, connection_mode=connection_binding.connection_mode,
"connection_mode": connection_binding.connection_mode, target_ipv4=connection_binding.target_ipv4,
} target_port=connection_binding.target_port,
if connection_binding is not None host_path_epoch=connection_binding.host_path_epoch,
else {} control_session_id=control_session_id,
), producer_generation=generation,
} )
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"
) )
if not inspection_only:
binding = executor.complete_connection_stage( binding = executor.complete_connection_stage(
orchestrator, orchestrator,
expected_binding=binding, expected_binding=binding,
) )
self._validate_connection_binding_snapshot( # Publish the identity proof and the phase under one lock so a
"inspection-promotion-post-response" # 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( workspace = executor.wait_for_operator_checkpoint(
"project-prompt-opened", "workspace-entered",
self._project_requested.is_set, self._workspace_requested.is_set,
reconciled_active_observed=self._active_recovery_requested.is_set,
) )
assert project is not None if workspace is None:
self._validate_connection_binding_snapshot("project-prompt-pre-dispatch") self._validate_connection_binding("active-recovery-adoption")
binding = executor.run_project_prompt_stage( executor.adopt_reconciled_scanning(
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, authority=authority,
binding=binding, 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 with self._scanning_transition_gate:
# return without invoking the new proof callback. A normal # Recovery adopts an already-active physical acquisition.
# production executor invokes it before the read-only refresh; # Its checkpoint deliberately retains an open transport
# this idempotent fallback remains strictly post-success. # gap until the post-Rerun PCL confirmation hook closes it;
observe_start_active() # nevertheless an operator STOP must remain admissible in
self._validate_connection_binding_snapshot("start-post-response") # this interval and can cease that gap with terminal READY.
self._set_phase("scanning") self._scanning_observer_confirmed = True
finally: self._set_phase("scanning")
if start_transition_gate_acquired: else:
self._scanning_transition_gate.release() 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) project = executor.wait_for_operator_checkpoint(
( "project-prompt-opened",
stop_command, self._project_requested.is_set,
stop_confirmation, )
stop_dispatch_admission_deadline_reached, assert project is not None
) = self._stop_request() self._validate_connection_binding_snapshot("project-prompt-pre-dispatch")
stop_permit = PhysicalAcceptancePermit(stop_confirmation.checklist(ModelingAction.STOP)) binding = executor.run_project_prompt_stage(
# Capture the exact transport counter before any remaining orchestrator,
# read-only validation. A deadline that expires during one of project,
# those checks is still deterministic zero-publish evidence. dispatch_guard=lambda: self._validate_connection_binding_snapshot(
stop_transport_before, stop_transport_before_available = ( "project-prompt-dispatch"
self._transport_snapshot_safely(transport) ),
) )
if stop_transport_before_available: self._validate_connection_binding_snapshot("project-prompt-post-response")
stop_publish_attempts_before_dispatch = self._json_int_or_none( self._set_phase("project-ready")
stop_transport_before.get("publish_attempts")
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( # Capture the exact transport counter before any remaining
stop_dispatch_admission_deadline_reached # read-only validation. A deadline that expires during one of
) # those checks is still deterministic zero-publish evidence.
self._validate_connection_binding_snapshot("stop-pre-dispatch") stop_transport_before, stop_transport_before_available = (
self._require_stop_dispatch_deadline_open( self._transport_snapshot_safely(transport)
stop_dispatch_admission_deadline_reached )
) if stop_transport_before_available:
self._set_phase("stopping") stop_publish_attempts_before_dispatch = self._json_int_or_none(
logger.info( stop_transport_before.get("publish_attempts")
"K1 STOP timing checkpoint", )
extra={ self._require_stop_dispatch_deadline_open(
"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 stop_dispatch_admission_deadline_reached
), )
) self._validate_connection_binding_snapshot("stop-pre-dispatch")
logger.info( self._require_stop_dispatch_deadline_open(
"K1 STOP timing checkpoint", stop_dispatch_admission_deadline_reached
extra={ )
"event_code": "k1_stop_dispatch_timing", self._set_phase("stopping")
"operation_stage": "correlated-application-response", logger.info(
"device_command_sent": True, "K1 STOP timing checkpoint",
}, extra={
) "event_code": "k1_stop_dispatch_timing",
self._validate_connection_binding_snapshot("stop-post-response") "operation_stage": "pre-dispatch-validation-complete",
self._set_phase("awaiting-standby-confirmation") "device_command_sent": False,
executor.maintain_post_stop_until_standby() },
if coordinator is not None: )
coordinator.resolve("stop") executor.execute_canonical_stop(
self._set_phase("completed") 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: except Exception as exc:
dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely( dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely(
executor executor
@@ -1217,7 +1268,9 @@ class InteractiveApplicationControlSession:
and stop_command_attempted is False and stop_command_attempted is False
and not diagnostic_evidence_unavailable 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) isinstance(exc, ApplicationCommandOutcomeUnknown)
or modeling_command_attempted is True or modeling_command_attempted is True
or bool(diagnostic_evidence_unavailable) or bool(diagnostic_evidence_unavailable)
@@ -1288,7 +1341,7 @@ class InteractiveApplicationControlSession:
) )
is None is None
) )
safe_to_retry = status_reconciled_prestart_failure or ( safe_to_retry = completed_acquisition or status_reconciled_prestart_failure or (
not outcome_unknown not outcome_unknown
and ( and (
not transport_created not transport_created
+1 -1
View File
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
position += 60 + length + length % 2 position += 60 + length + length % 2
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive: with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
control = archive.extractfile("control").read().decode() 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 assert "Replaces: mission-core-node (<< 0.8.0)" in control
+17 -1
View File
@@ -438,10 +438,26 @@ def test_applied_wifi_does_not_grant_control_or_start_authority():
assert item["control"]["reason_code"] is None 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(): async def run():
device = bridge() device = bridge()
device.facade.current["application_control_session"]["state"] = initial_phase
sensor = NodeK1Sensor(device, None) 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") item = project_sensor(state(), "node-test")
command = { command = {
"operation_id": "op_" + "a" * 32, "operation_id": "op_" + "a" * 32,
@@ -34792,3 +34792,24 @@ def test_power_loss_during_observation_preserves_audit_and_releases_ownership(
is True is True
) )
assert write_calls == 1 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
@@ -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[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
assert response_evidence[-1]["operation_key"] == "modeling:stop" assert response_evidence[-1]["operation_key"] == "modeling:stop"
assert all("payload" not in item for item in response_evidence) 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 APPLICATION_KEY not in str(executor.snapshot())
assert VENDOR_DEVICE_ID not in str(executor.snapshot()) assert VENDOR_DEVICE_ID not in str(executor.snapshot())
TypeAdapter(JsonValue).validate_python(executor.snapshot()) TypeAdapter(JsonValue).validate_python(executor.snapshot())
+74 -1
View File
@@ -372,6 +372,14 @@ class FakeExecutor:
def maintain_post_stop_until_standby(self) -> None: def maintain_post_stop_until_standby(self) -> None:
self.records.append("wait:device-standby") 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]: def snapshot(self) -> dict[str, object]:
return { return {
"records": list(self.records), "records": list(self.records),
@@ -2160,9 +2168,11 @@ def test_new_explicit_session_waits_for_old_worker_transport_retirement(
_wait_phase(session, "scanning") _wait_phase(session, "scanning")
session.request_stop(confirmation=_confirmation()) session.request_stop(confirmation=_confirmation())
_wait_phase(session, "completed") _wait_phase(session, "completed")
assert new_thread.is_alive()
session.close()
new_thread.join(timeout=2.0) new_thread.join(timeout=2.0)
assert not new_thread.is_alive() 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( 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_snapshot_unavailable"] == []
assert failure["diagnostic_evidence_unavailable"] == [] assert failure["diagnostic_evidence_unavailable"] == []
assert failure["safe_to_retry"] is True 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