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.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');
});
+1 -1
View File
@@ -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():
+1 -1
View File
@@ -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
@@ -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;
overflow: hidden;
border-radius: 1rem;
clip-path: inset(0 round 1rem);
isolation: isolate;
background: #06070a;
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'} {
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
@@ -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) {
+2 -2
View File
@@ -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 <noreply@example.invalid>
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)
+20 -2
View File
@@ -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
@@ -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)
@@ -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",
@@ -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
+1 -1
View File
@@ -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
+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
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,
@@ -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
@@ -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())
+74 -1
View File
@@ -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