Retain K1 connection between scans and admit the next named acquisition
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:17–20:23 MSK interval. A later bounded
|
||||
Fleet read still showed the K1 acquisition idle, control phase completed, and
|
||||
online/verified false. This is current state, not proof of a historical Wi-Fi
|
||||
failure.
|
||||
|
||||
The implementation explains the transition deterministically: the interactive
|
||||
control worker concluded the entire MQTT dialogue after correlated STOP and
|
||||
fresh unbound READY, set `completed`, and closed its transport in `finally`.
|
||||
The facade then reduced every completed control session to control loss. The
|
||||
Node projection correctly withheld connection authority, but the UI further
|
||||
hid that distinction by prioritizing “device stopped” over connectivity and
|
||||
instructing the owner to check connection after every completed recording.
|
||||
No service restart or actual Wi-Fi disconnect is needed to produce this path.
|
||||
|
||||
There is a real protocol constraint underneath the original single-scan
|
||||
implementation: MQTT operation keys and response-correlation ownership are
|
||||
one-shot within a dialogue. Reusing the old dialogue by clearing consumed keys
|
||||
would weaken replay/correlation protection. R16 does not reset these guards.
|
||||
|
||||
The bounded journal also records failures at 20:18:54 and 20:21:24 at the R15
|
||||
Node adapter's `Physical state requires explicit reconciliation` check. That
|
||||
check ran even immediately after the adapter's own admitted START, before its
|
||||
pending physical edge had settled. R16 returns that owned starting/scanning
|
||||
state without another dispatch; pre-existing ambiguous physical state still
|
||||
blocks a new START.
|
||||
|
||||
## Corrected lifetime
|
||||
|
||||
After confirmed STOP/READY, the completed recording is sealed by the existing
|
||||
acquisition lifecycle. The command worker retains its socket and services the
|
||||
existing subscriptions while idle. This wait sends no bootstrap, network
|
||||
configuration, START or STOP. Real socket failure, stale bound status, identity
|
||||
change and route/proof expiry keep their existing fail-closed checks.
|
||||
|
||||
A subsequent explicit named START releases the next workspace checkpoint. Only
|
||||
then does the owner retire the previous command socket and create a fresh
|
||||
canonical dialogue for the same bound device/network. It receives fresh
|
||||
DeviceInfo, enforces the identity/route and durable ledger checks, then follows
|
||||
the existing workspace/project/START stages. The control generation advances;
|
||||
old checkpoints cannot authorize the new cycle. BLE selection, GATT and Wi-Fi
|
||||
provisioning are not part of this path. No extra operator recovery action is
|
||||
needed for an uninterrupted connection. Actual loss retains the explicit
|
||||
verification/recovery path; no automatic network write or physical command
|
||||
retry was added.
|
||||
|
||||
The facade accepts completed control as live only when its socket is retained
|
||||
and the existing exact, fresh DeviceInfo/control proof satisfies the supervisor.
|
||||
Legacy completed/closed sockets remain disconnected. Explicit network changes
|
||||
may retire the retained idle socket through the existing local close boundary.
|
||||
Both direct and Node START orchestration accept this completed-but-connected
|
||||
checkpoint. Device inventory/detail badges now report connection; acquisition
|
||||
progress remains in the spatial session controls.
|
||||
|
||||
## Presentation
|
||||
|
||||
- Canonical Design Guideline Field owns autofill styling. Browser autofill uses
|
||||
the existing field material, theme text/caret and keyboard focus indicator.
|
||||
No K1-only field override or new control was introduced. Registry, component
|
||||
documentation and the living catalog were updated.
|
||||
- The shared spatial viewport uses one explicit rounded compositing clip and
|
||||
an isolated stacking context, including the native canvas/iframe and overlays.
|
||||
This addresses corner leakage without decorative strokes or changes to the
|
||||
scene grid, axes or camera resize handle. Confirmation against the owner's
|
||||
actual GPU-rendered screenshot remains part of visual acceptance.
|
||||
|
||||
## Verification and delivery
|
||||
|
||||
Synthetic checks are not physical scanner acceptance. The final artifact and
|
||||
installation evidence are recorded below. No physical scanner command, BLE
|
||||
probe or credential extraction was performed from CLI. Existing onboard
|
||||
credentials and pre-install guards are retained. Builds run sequentially on
|
||||
the 18 GB Mac; Docker was not running. The canonical Core on port 8000 stays
|
||||
available.
|
||||
|
||||
Source checks: 167 control/protocol/Node/supervisor/coordinator tests; 22 selected
|
||||
acquisition lifecycle tests; 809 Control Station unit tests. Regressions cover
|
||||
retained idle connection, two explicit scans with distinct command dialogues,
|
||||
no idle commands/replay, stale CAS, completed connection versus actual loss,
|
||||
and admission of the owned pending START. Design Guideline production build and
|
||||
registry validation passed. Production Core build and packaging checks follow.
|
||||
|
||||
Full acquisition lifecycle regression then passed: 631 tests (includes the 22
|
||||
focused tests above). Installer/package lifecycle: 14 passed. Core TypeScript
|
||||
and production build passed (8.29 s). Ruff and whitespace checks passed.
|
||||
Design Guideline pin: `26a1bf72a2a32b002e51f910e8faa300333bb6c3`.
|
||||
R16 package versions: Node 0.8.13 and K1 0.1.13.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user