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.doesNotMatch(pending,/Инициировать запуск|Настройки устройства|sensor-live-layout/);
|
||||||
assert.match(pending,/Проверить состояние K1/);
|
assert.match(pending,/Проверить состояние K1/);
|
||||||
assert.match(pending,/авторизовать/);
|
assert.match(pending,/авторизовать/);
|
||||||
assert.equal(presentation.k1Status(waiting,true).label,'Wi-Fi настроен · нет управления');
|
assert.equal(presentation.k1Status(waiting,true).label,'Нет связи с K1');
|
||||||
assert.equal(presentation.k1ManualState(device,false).canStart,false);
|
assert.equal(presentation.k1ManualState(device,false).canStart,false);
|
||||||
});
|
});
|
||||||
test('active acquisition exposes STOP and Rerun without another START',()=>{
|
test('active acquisition exposes STOP and Rerun without another START',()=>{
|
||||||
@@ -58,10 +58,10 @@ test('calibration loader stays inside the primary action',()=>{
|
|||||||
assert.match(markup,/K1 калибруется и готовит облако точек/);
|
assert.match(markup,/K1 калибруется и готовит облако точек/);
|
||||||
assert.doesNotMatch(markup,/Обновить просмотр/);
|
assert.doesNotMatch(markup,/Обновить просмотр/);
|
||||||
});
|
});
|
||||||
test('completed STOP remains visible while the control connection is checked again',()=>{
|
test('connection loss is visible independently of completed STOP',()=>{
|
||||||
const stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}};
|
const stopped={...device,online:false,verified:false,control:{...device.control,phase:'completed',can_start:false,network_applied:true}};
|
||||||
const markup=render(stopped);
|
const markup=render(stopped);
|
||||||
assert.match(markup,/Устройство остановлено/);
|
assert.match(markup,/Нет связи с K1/);
|
||||||
assert.match(markup,/Проверить состояние K1/);
|
assert.match(markup,/Проверить состояние K1/);
|
||||||
assert.doesNotMatch(markup,/Инициировать запуск|нет управления/);
|
assert.doesNotMatch(markup,/Инициировать запуск|нет управления/);
|
||||||
});
|
});
|
||||||
@@ -73,3 +73,13 @@ test('enrollment handoff opens only the verified current session',()=>{
|
|||||||
assert.equal(enrolledDevice({...inventory,items:[{...device,verified:false}]},'exact-session'),null);
|
assert.equal(enrolledDevice({...inventory,items:[{...device,verified:false}]},'exact-session'),null);
|
||||||
assert.equal(enrolledDevice({...inventory,items:[device,device]},'exact-session'),null);
|
assert.equal(enrolledDevice({...inventory,items:[device,device]},'exact-session'),null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('completed scan with healthy control remains connected and permits a named next scan',()=>{
|
||||||
|
const stopped={...device,control:{...device.control,phase:'completed'}};
|
||||||
|
const markup=render(stopped);
|
||||||
|
assert.equal(presentation.k1Status(stopped,true).label,'Подключён');
|
||||||
|
assert.equal(presentation.k1ManualState(stopped,true).canStart,true);
|
||||||
|
assert.match(markup,/Инициировать запуск/);
|
||||||
|
assert.doesNotMatch(markup,/Проверить состояние K1|Подключить устройство|Устройство остановлено/);
|
||||||
|
assert.equal(presentation.k1Status(stopped,false).tone,'neutral');
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
from build_deb import build, VERSION, BRAND_SHA256
|
from build_deb import build, VERSION, BRAND_SHA256
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DG_COMMIT = "5b882bc3d9b13a86e6c26111ef5ded687ea3a2fc"
|
DG_COMMIT = "26a1bf72a2a32b002e51f910e8faa300333bb6c3"
|
||||||
|
|
||||||
|
|
||||||
def guideline_sources():
|
def guideline_sources():
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.8.12"
|
VERSION = "0.8.13"
|
||||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||||
from debian import package
|
from debian import package
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# K1 connection lifetime between recordings — R16
|
||||||
|
|
||||||
|
## Observed behavior and cause
|
||||||
|
|
||||||
|
The owner reports two successful R15 connections and recordings, then a return
|
||||||
|
to the device showing “Устройство остановлено” and requiring a separate
|
||||||
|
connection check. R15 is Node 0.8.12 / K1 0.1.12. The services remained active
|
||||||
|
with zero restarts through the reported 20: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;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
clip-path: inset(0 round 1rem);
|
||||||
|
isolation: isolate;
|
||||||
background: #06070a;
|
background: #06070a;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,13 @@ import type {Sensor} from './runtime';
|
|||||||
|
|
||||||
export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
export function k1Status(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||||
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'};
|
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'};
|
||||||
if(device.control?.phase==='completed'&&device.snapshot.acquisition==='idle')return {label:'Устройство остановлено',tone:'neutral'};
|
|
||||||
if(!device.online||!device.verified)return device.control?.network_applied
|
if(!device.online||!device.verified)return device.control?.network_applied
|
||||||
?{label:'Wi-Fi настроен · нет управления',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'};
|
?{label:'Нет связи с K1',tone:'warning'}:{label:'Связь не подтверждена',tone:'neutral'};
|
||||||
if(device.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'};
|
return {label:'Подключён',tone:'success'};
|
||||||
if(device.snapshot.acquisition==='streaming')return {label:'Идёт захват',tone:'success'};
|
|
||||||
if(['preparing','starting'].includes(device.snapshot.acquisition))return {label:'Запускается',tone:'neutral'};
|
|
||||||
if(device.snapshot.acquisition==='stopping')return {label:'Останавливается',tone:'neutral'};
|
|
||||||
return {label:device.control?.can_start?'Готов к запуску':'Подключён',tone:'success'};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function k1ConnectionNotice(device:Sensor,fresh:boolean):string {
|
export function k1ConnectionNotice(device:Sensor,fresh:boolean):string {
|
||||||
if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.';
|
if(!fresh)return 'Нет свежих сведений с БК. Ожидаем восстановления связи.';
|
||||||
if(device.control?.phase==='completed')return 'Устройство остановлено. Проверьте связь с K1 перед следующим запуском; повторно вводить настройки Wi-Fi не нужно.';
|
|
||||||
if(device.control?.reason_code==='application_authority_unavailable')
|
if(device.control?.reason_code==='application_authority_unavailable')
|
||||||
return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
|
return 'Wi-Fi настроен. Служба K1 на БК не смогла авторизовать подключение. Обновите интеграцию K1 на БК и проверьте состояние устройства.';
|
||||||
return device.control?.network_applied
|
return device.control?.network_applied
|
||||||
|
|||||||
@@ -1516,13 +1516,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["idle", "closed", "completed"].includes(phase)) {
|
if (["idle", "closed"].includes(phase)) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
"K1 ещё не подключён. Сначала завершите подключение устройства в «Парке».",
|
"K1 ещё не подключён. Сначала завершите подключение устройства в «Парке».",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "connection-ready") {
|
if (phase === "connection-ready" || (phase === "completed"
|
||||||
|
&& nextState.application_control_session?.control_socket_open === true)) {
|
||||||
const physical = nextState.application_control_session?.physical_command
|
const physical = nextState.application_control_session?.physical_command
|
||||||
?? nextState.physical_command;
|
?? nextState.physical_command;
|
||||||
if (physical?.requires_reconciliation === true) {
|
if (physical?.requires_reconciliation === true) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
|
|||||||
from debian import package # noqa: E402
|
from debian import package # noqa: E402
|
||||||
from runtime_payload import files as runtime_files # noqa: E402
|
from runtime_payload import files as runtime_files # noqa: E402
|
||||||
|
|
||||||
VERSION = "0.1.12"
|
VERSION = "0.1.13"
|
||||||
RESOURCES = (
|
RESOURCES = (
|
||||||
"plugins/xgrids-k1/profile_loader.py",
|
"plugins/xgrids-k1/profile_loader.py",
|
||||||
"plugins/xgrids-k1/plugin.manifest.json",
|
"plugins/xgrids-k1/plugin.manifest.json",
|
||||||
@@ -135,7 +135,7 @@ Architecture: amd64
|
|||||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||||
Section: admin
|
Section: admin
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Depends: mission-core-node (>= 0.8.12), mission-core-node (<< 0.9.0),
|
Depends: mission-core-node (>= 0.8.13), mission-core-node (<< 0.9.0),
|
||||||
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
|
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
|
||||||
Breaks: mission-core-node (<< 0.8.0)
|
Breaks: mission-core-node (<< 0.8.0)
|
||||||
Replaces: mission-core-node (<< 0.8.0)
|
Replaces: mission-core-node (<< 0.8.0)
|
||||||
|
|||||||
@@ -3814,6 +3814,18 @@ class XgridsK1CompatibilityService:
|
|||||||
expected_session_generation: int | None = None,
|
expected_session_generation: int | None = None,
|
||||||
expected_state_revision: int | None = None,
|
expected_state_revision: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
current = self._application_control_session.snapshot()
|
||||||
|
if current.get("state") == "completed" and current.get("control_socket_open") is True:
|
||||||
|
self._application_control_session.close_prestart(
|
||||||
|
expected_session_generation=expected_session_generation,
|
||||||
|
expected_state_revision=expected_state_revision,
|
||||||
|
)
|
||||||
|
self._application_control_session.close()
|
||||||
|
retired = self._application_control_session.snapshot()
|
||||||
|
if retired.get("session_generation") != current.get("session_generation"):
|
||||||
|
raise ApplicationAcceptanceError("control session changed during retirement")
|
||||||
|
expected_session_generation = retired.get("session_generation")
|
||||||
|
expected_state_revision = retired.get("state_revision")
|
||||||
self._application_control_session.retire_for_network_change(
|
self._application_control_session.retire_for_network_change(
|
||||||
allow_terminal_failure=allow_terminal_failure,
|
allow_terminal_failure=allow_terminal_failure,
|
||||||
expected_session_generation=expected_session_generation,
|
expected_session_generation=expected_session_generation,
|
||||||
@@ -27534,7 +27546,10 @@ class XgridsK1CompatibilityService:
|
|||||||
}
|
}
|
||||||
supervisor = self._connection_supervisor.snapshot()
|
supervisor = self._connection_supervisor.snapshot()
|
||||||
if (
|
if (
|
||||||
control_state in active_control_states
|
(control_state in active_control_states or (
|
||||||
|
control_state == "completed"
|
||||||
|
and application_control_session.get("control_socket_open") is True
|
||||||
|
))
|
||||||
and isinstance(verified_control, Mapping)
|
and isinstance(verified_control, Mapping)
|
||||||
and supervisor.intent is not None
|
and supervisor.intent is not None
|
||||||
and supervisor.endpoint.target is not None
|
and supervisor.endpoint.target is not None
|
||||||
@@ -27703,7 +27718,10 @@ class XgridsK1CompatibilityService:
|
|||||||
|
|
||||||
supervisor = self._connection_supervisor.snapshot()
|
supervisor = self._connection_supervisor.snapshot()
|
||||||
if (
|
if (
|
||||||
control_state in {"idle", "completed", "closed", "failed"}
|
(control_state in {"idle", "closed", "failed"} or (
|
||||||
|
control_state == "completed"
|
||||||
|
and application_control_session.get("control_socket_open") is not True
|
||||||
|
))
|
||||||
and supervisor.control_plane.state == "healthy"
|
and supervisor.control_plane.state == "healthy"
|
||||||
and supervisor.control_plane.session_id is not None
|
and supervisor.control_plane.session_id is not None
|
||||||
and supervisor.intent is not None
|
and supervisor.intent is not None
|
||||||
|
|||||||
@@ -241,12 +241,17 @@ class NodeK1Sensor:
|
|||||||
control = state.get("application_control_session") or {}
|
control = state.get("application_control_session") or {}
|
||||||
phase = control.get("state")
|
phase = control.get("state")
|
||||||
physical = control.get("physical_command") or state.get("physical_command") or {}
|
physical = control.get("physical_command") or state.get("physical_command") or {}
|
||||||
|
if ("acquisition.start" in dispatched
|
||||||
|
and phase in {"start-requested", "initializing", "scanning"}):
|
||||||
|
# The just-admitted START owns its pending physical edge.
|
||||||
|
# Returning its state does not dispatch or reconcile it again.
|
||||||
|
return project_sensor(state, node_id)
|
||||||
if physical.get("requires_reconciliation"):
|
if physical.get("requires_reconciliation"):
|
||||||
raise ValueError("Physical state requires explicit reconciliation")
|
raise ValueError("Physical state requires explicit reconciliation")
|
||||||
acquisition = state.get("acquisition") or {}
|
acquisition = state.get("acquisition") or {}
|
||||||
payload = {"expected_snapshot_runtime_id": runtime}
|
payload = {"expected_snapshot_runtime_id": runtime}
|
||||||
next_action = None
|
next_action = None
|
||||||
if phase == "connection-ready":
|
if phase in {"connection-ready", "completed"}:
|
||||||
if control.get("inspection_only"):
|
if control.get("inspection_only"):
|
||||||
next_action = "application-control.session.open"
|
next_action = "application-control.session.open"
|
||||||
payload.update(acceptance, timezone_name="UTC")
|
payload.update(acceptance, timezone_name="UTC")
|
||||||
@@ -276,7 +281,7 @@ class NodeK1Sensor:
|
|||||||
expected_state_revision=acquisition["state_revision"],
|
expected_state_revision=acquisition["state_revision"],
|
||||||
physical_acceptance=acceptance,
|
physical_acceptance=acceptance,
|
||||||
)
|
)
|
||||||
elif phase in {"failed", "idle", "closed", "completed"}:
|
elif phase in {"failed", "idle", "closed"}:
|
||||||
raise ValueError("K1 control not ready")
|
raise ValueError("K1 control not ready")
|
||||||
elif phase in {"start-requested", "initializing", "scanning"}:
|
elif phase in {"start-requested", "initializing", "scanning"}:
|
||||||
return project_sensor(state, node_id)
|
return project_sensor(state, node_id)
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||||||
self._active_authority: ApplicationControlAuthority | None = None
|
self._active_authority: ApplicationControlAuthority | None = None
|
||||||
self._active_binding: LiveDeviceControlBinding | None = None
|
self._active_binding: LiveDeviceControlBinding | None = None
|
||||||
self._prepared_binding: LiveDeviceControlBinding | None = None
|
self._prepared_binding: LiveDeviceControlBinding | None = None
|
||||||
|
self._standby_binding: LiveDeviceControlBinding | None = None
|
||||||
self._checkpoint_owner = object()
|
self._checkpoint_owner = object()
|
||||||
self._issued_checkpoint: str | None = None
|
self._issued_checkpoint: str | None = None
|
||||||
self._start_permit_snapshot: dict[str, object] | None = None
|
self._start_permit_snapshot: dict[str, object] | None = None
|
||||||
@@ -801,9 +802,23 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||||||
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
)
|
)
|
||||||
self._dialogue_stage = "standby-confirmed"
|
self._dialogue_stage = "standby-confirmed"
|
||||||
|
self._standby_binding = binding
|
||||||
self._active_authority = None
|
self._active_authority = None
|
||||||
self._active_binding = None
|
self._active_binding = None
|
||||||
|
|
||||||
|
def maintain_standby_until_next_acquisition(self, requested: Callable[[], bool]) -> None:
|
||||||
|
"""Pump the retained socket after READY; issue no device commands."""
|
||||||
|
if self._dialogue_stage != "standby-confirmed" or self._standby_binding is None:
|
||||||
|
raise ApplicationAcceptanceError("next acquisition requires confirmed standby")
|
||||||
|
while True:
|
||||||
|
self._transport.maintain_open_for(
|
||||||
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||||
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
|
)
|
||||||
|
self._transport.validate_bound_status(self._standby_binding)
|
||||||
|
if requested() and self._transport.pre_start_ready(self._standby_binding):
|
||||||
|
return
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
def snapshot(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"mode": "physical-acceptance-only",
|
"mode": "physical-acceptance-only",
|
||||||
|
|||||||
@@ -170,8 +170,11 @@ class OperatorPresenceConfirmation:
|
|||||||
|
|
||||||
|
|
||||||
class InteractiveApplicationControlSession:
|
class InteractiveApplicationControlSession:
|
||||||
"""Own one canonical K1 MQTT dialogue across explicit operator UI events.
|
"""Own canonical acquisition dialogues and retain control between scans.
|
||||||
|
|
||||||
|
Each explicit new scan gets a fresh one-shot MQTT dialogue on the existing
|
||||||
|
device/network binding. Completed scans keep pumping their idle connection
|
||||||
|
until that request or explicit local retirement; no Wi-Fi setup is repeated.
|
||||||
Only this background thread touches the MQTT client. UI requests merely
|
Only this background thread touches the MQTT client. UI requests merely
|
||||||
release one named checkpoint. No checkpoint is advanced by elapsed time,
|
release one named checkpoint. No checkpoint is advanced by elapsed time,
|
||||||
and neither START nor STOP has an automatic retry path.
|
and neither START nor STOP has an automatic retry path.
|
||||||
@@ -361,7 +364,7 @@ class InteractiveApplicationControlSession:
|
|||||||
expected_session_generation=expected_session_generation,
|
expected_session_generation=expected_session_generation,
|
||||||
expected_state_revision=expected_state_revision,
|
expected_state_revision=expected_state_revision,
|
||||||
)
|
)
|
||||||
self._require_phase_locked("connection-ready")
|
self._require_workspace_entry_locked()
|
||||||
if self._inspection_only and not self._inspection_promotion_allowed:
|
if self._inspection_only and not self._inspection_promotion_allowed:
|
||||||
raise ApplicationAcceptanceError(
|
raise ApplicationAcceptanceError(
|
||||||
"read-only inspection has not completed its Verify boundary"
|
"read-only inspection has not completed its Verify boundary"
|
||||||
@@ -376,7 +379,7 @@ class InteractiveApplicationControlSession:
|
|||||||
expected_session_generation=expected_session_generation,
|
expected_session_generation=expected_session_generation,
|
||||||
expected_state_revision=expected_state_revision,
|
expected_state_revision=expected_state_revision,
|
||||||
)
|
)
|
||||||
self._require_phase_locked("connection-ready")
|
self._require_workspace_entry_locked()
|
||||||
if self._inspection_only and not self._inspection_promotion_allowed:
|
if self._inspection_only and not self._inspection_promotion_allowed:
|
||||||
raise ApplicationAcceptanceError(
|
raise ApplicationAcceptanceError(
|
||||||
"read-only inspection has not completed its Verify boundary"
|
"read-only inspection has not completed its Verify boundary"
|
||||||
@@ -385,6 +388,12 @@ class InteractiveApplicationControlSession:
|
|||||||
self._workspace_requested.set()
|
self._workspace_requested.set()
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
|
def _require_workspace_entry_locked(self) -> None:
|
||||||
|
if (self._phase == "completed" and self._transport is not None
|
||||||
|
and not self._cancel_requested):
|
||||||
|
return
|
||||||
|
self._require_phase_locked("connection-ready")
|
||||||
|
|
||||||
def validate_connection_binding(self) -> None:
|
def validate_connection_binding(self) -> None:
|
||||||
"""Fail closed when the DeviceInfo-bound route lost command authority."""
|
"""Fail closed when the DeviceInfo-bound route lost command authority."""
|
||||||
|
|
||||||
@@ -695,6 +704,7 @@ class InteractiveApplicationControlSession:
|
|||||||
"connection-ready",
|
"connection-ready",
|
||||||
"workspace-ready",
|
"workspace-ready",
|
||||||
"project-ready",
|
"project-ready",
|
||||||
|
"completed",
|
||||||
}:
|
}:
|
||||||
raise ApplicationAcceptanceError(
|
raise ApplicationAcceptanceError(
|
||||||
"control session can be closed safely only between pre-START checkpoints"
|
"control session can be closed safely only between pre-START checkpoints"
|
||||||
@@ -825,9 +835,13 @@ class InteractiveApplicationControlSession:
|
|||||||
"state": phase,
|
"state": phase,
|
||||||
"session_generation": self._run_generation,
|
"session_generation": self._run_generation,
|
||||||
"state_revision": self._state_revision,
|
"state_revision": self._state_revision,
|
||||||
"control_socket_open": phase not in {"idle", "completed", "closed", "failed"},
|
"control_socket_open": (
|
||||||
|
self._transport is not None and phase not in {"idle", "closed", "failed"}
|
||||||
|
),
|
||||||
"can_open": self._can_open_locked(),
|
"can_open": self._can_open_locked(),
|
||||||
"can_enter_workspace": phase == "connection-ready",
|
"can_enter_workspace": phase == "connection-ready" or (
|
||||||
|
phase == "completed" and self._transport is not None
|
||||||
|
),
|
||||||
"can_prepare_project": phase == "workspace-ready",
|
"can_prepare_project": phase == "workspace-ready",
|
||||||
"can_start": phase == "project-ready",
|
"can_start": phase == "project-ready",
|
||||||
"can_stop": phase == "scanning",
|
"can_stop": phase == "scanning",
|
||||||
@@ -854,6 +868,7 @@ class InteractiveApplicationControlSession:
|
|||||||
executor: PhysicalAcceptanceDialogueExecutor | None = None
|
executor: PhysicalAcceptanceDialogueExecutor | None = None
|
||||||
transport: ReviewedApplicationMqttTransport | None = None
|
transport: ReviewedApplicationMqttTransport | None = None
|
||||||
stop_publish_attempts_before_dispatch: int | None = None
|
stop_publish_attempts_before_dispatch: int | None = None
|
||||||
|
completed_acquisition = False
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
host = self._host
|
host = self._host
|
||||||
@@ -862,265 +877,301 @@ class InteractiveApplicationControlSession:
|
|||||||
raise ApplicationAcceptanceError("control session inputs are unavailable")
|
raise ApplicationAcceptanceError("control session inputs are unavailable")
|
||||||
|
|
||||||
authority = self._authority_loader.load()
|
authority = self._authority_loader.load()
|
||||||
transport = self._transport_factory(host)
|
|
||||||
coordinator = self._physical_command_coordinator
|
coordinator = self._physical_command_coordinator
|
||||||
if coordinator is not None:
|
while True:
|
||||||
transport.install_evidence_observer(coordinator)
|
transport = self._transport_factory(host)
|
||||||
if self._connection_path_validator is not None:
|
if coordinator is not None:
|
||||||
self._validate_connection_path("control-open-preflight")
|
transport.install_evidence_observer(coordinator)
|
||||||
transport.install_dispatch_guard(self._acquire_connection_dispatch_lease)
|
if self._connection_path_validator is not None:
|
||||||
with self._lock:
|
self._validate_connection_path("control-open-preflight")
|
||||||
self._transport = transport
|
transport.install_dispatch_guard(self._acquire_connection_dispatch_lease)
|
||||||
transport.open()
|
with self._lock:
|
||||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
self._transport = transport
|
||||||
authority,
|
transport.open()
|
||||||
epoch_seconds=self._epoch_seconds(),
|
completed_acquisition = False
|
||||||
timezone_name=timezone_name,
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
)
|
authority,
|
||||||
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
epoch_seconds=self._epoch_seconds(),
|
||||||
with self._lock:
|
timezone_name=timezone_name,
|
||||||
inspection_only = self._inspection_only
|
|
||||||
# DeviceInfo (ordinal 1) is the only bootstrap request that may
|
|
||||||
# cross the socket before durable physical-target admission. In
|
|
||||||
# particular, ordinal 4 mutates the K1 clock, so the legacy
|
|
||||||
# collapsed ordinals 1-6 path must never run before the coordinator
|
|
||||||
# can reject a retired identity discovered under a fresh BLE
|
|
||||||
# transport UUID.
|
|
||||||
binding = executor.run_read_only_inspection_stage(orchestrator)
|
|
||||||
control_session_id = f"application-control-{generation}-{time.monotonic_ns()}"
|
|
||||||
with self._lock:
|
|
||||||
connection_binding = self._connection_binding
|
|
||||||
if coordinator is not None:
|
|
||||||
if connection_binding is None:
|
|
||||||
raise ApplicationAcceptanceError(
|
|
||||||
"durable physical control requires an exact connection binding"
|
|
||||||
)
|
|
||||||
coordinator.bind_control_session(
|
|
||||||
PhysicalCommandRuntimeBinding(
|
|
||||||
vendor_device_id_sha256=hash_physical_identity(binding.vendor_device_id),
|
|
||||||
device_serial_sha256=hash_physical_identity(binding.device_serial),
|
|
||||||
compatibility_profile_id=COMPATIBILITY_PROFILE_ID,
|
|
||||||
intent_id=connection_binding.intent_id,
|
|
||||||
transport_ref=connection_binding.transport_ref,
|
|
||||||
connection_mode=connection_binding.connection_mode,
|
|
||||||
target_ipv4=connection_binding.target_ipv4,
|
|
||||||
target_port=connection_binding.target_port,
|
|
||||||
host_path_epoch=connection_binding.host_path_epoch,
|
|
||||||
control_session_id=control_session_id,
|
|
||||||
producer_generation=generation,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if not inspection_only:
|
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||||
binding = executor.complete_connection_stage(
|
with self._lock:
|
||||||
orchestrator,
|
inspection_only = self._inspection_only
|
||||||
expected_binding=binding,
|
# DeviceInfo (ordinal 1) is the only bootstrap request that may
|
||||||
)
|
# cross the socket before durable physical-target admission. In
|
||||||
# Publish the identity proof and the phase under one lock so a
|
# particular, ordinal 4 mutates the K1 clock, so the legacy
|
||||||
# consumer cannot observe connection-ready without its DeviceInfo
|
# collapsed ordinals 1-6 path must never run before the coordinator
|
||||||
# evidence (or evidence while still claiming to be connecting).
|
# can reject a retired identity discovered under a fresh BLE
|
||||||
with self._lock:
|
# transport UUID.
|
||||||
self._control_authority = authority
|
binding = executor.run_read_only_inspection_stage(orchestrator)
|
||||||
self._live_control_binding = binding
|
control_session_id = f"application-control-{generation}-{time.monotonic_ns()}"
|
||||||
transport_snapshot = self._live_transport_snapshot_locked()
|
with self._lock:
|
||||||
self._verified_control = {
|
connection_binding = self._connection_binding
|
||||||
"logical_device_id": binding.vendor_device_id,
|
if coordinator is not None:
|
||||||
"compatibility_profile_id": COMPATIBILITY_PROFILE_ID,
|
if connection_binding is None:
|
||||||
"control_session_id": control_session_id,
|
raise ApplicationAcceptanceError(
|
||||||
"producer_generation": generation,
|
"durable physical control requires an exact connection binding"
|
||||||
"source": "mqtt-device-info",
|
)
|
||||||
**self._control_proof_fields(transport_snapshot),
|
coordinator.bind_control_session(
|
||||||
**(
|
PhysicalCommandRuntimeBinding(
|
||||||
{
|
vendor_device_id_sha256=hash_physical_identity(binding.vendor_device_id),
|
||||||
"intent_id": connection_binding.intent_id,
|
device_serial_sha256=hash_physical_identity(binding.device_serial),
|
||||||
"transport_ref": connection_binding.transport_ref,
|
compatibility_profile_id=COMPATIBILITY_PROFILE_ID,
|
||||||
"host_path_epoch": connection_binding.host_path_epoch,
|
intent_id=connection_binding.intent_id,
|
||||||
"target_ipv4": connection_binding.target_ipv4,
|
transport_ref=connection_binding.transport_ref,
|
||||||
"target_port": connection_binding.target_port,
|
connection_mode=connection_binding.connection_mode,
|
||||||
"connection_mode": connection_binding.connection_mode,
|
target_ipv4=connection_binding.target_ipv4,
|
||||||
}
|
target_port=connection_binding.target_port,
|
||||||
if connection_binding is not None
|
host_path_epoch=connection_binding.host_path_epoch,
|
||||||
else {}
|
control_session_id=control_session_id,
|
||||||
),
|
producer_generation=generation,
|
||||||
}
|
)
|
||||||
self._set_phase_locked("connection-ready")
|
|
||||||
|
|
||||||
workspace = executor.wait_for_operator_checkpoint(
|
|
||||||
"workspace-entered",
|
|
||||||
self._workspace_requested.is_set,
|
|
||||||
reconciled_active_observed=self._active_recovery_requested.is_set,
|
|
||||||
)
|
|
||||||
if workspace is None:
|
|
||||||
self._validate_connection_binding("active-recovery-adoption")
|
|
||||||
executor.adopt_reconciled_scanning(
|
|
||||||
authority=authority,
|
|
||||||
binding=binding,
|
|
||||||
)
|
|
||||||
with self._scanning_transition_gate:
|
|
||||||
# Recovery adopts an already-active physical acquisition.
|
|
||||||
# Its checkpoint deliberately retains an open transport
|
|
||||||
# gap until the post-Rerun PCL confirmation hook closes it;
|
|
||||||
# nevertheless an operator STOP must remain admissible in
|
|
||||||
# this interval and can cease that gap with terminal READY.
|
|
||||||
self._scanning_observer_confirmed = True
|
|
||||||
self._set_phase("scanning")
|
|
||||||
else:
|
|
||||||
if inspection_only:
|
|
||||||
self._validate_connection_binding_snapshot(
|
|
||||||
"inspection-promotion-pre-dispatch"
|
|
||||||
)
|
)
|
||||||
|
if not inspection_only:
|
||||||
binding = executor.complete_connection_stage(
|
binding = executor.complete_connection_stage(
|
||||||
orchestrator,
|
orchestrator,
|
||||||
expected_binding=binding,
|
expected_binding=binding,
|
||||||
)
|
)
|
||||||
self._validate_connection_binding_snapshot(
|
# Publish the identity proof and the phase under one lock so a
|
||||||
"inspection-promotion-post-response"
|
# consumer cannot observe connection-ready without its DeviceInfo
|
||||||
|
# evidence (or evidence while still claiming to be connecting).
|
||||||
|
with self._lock:
|
||||||
|
self._control_authority = authority
|
||||||
|
self._live_control_binding = binding
|
||||||
|
transport_snapshot = self._live_transport_snapshot_locked()
|
||||||
|
self._verified_control = {
|
||||||
|
"logical_device_id": binding.vendor_device_id,
|
||||||
|
"compatibility_profile_id": COMPATIBILITY_PROFILE_ID,
|
||||||
|
"control_session_id": control_session_id,
|
||||||
|
"producer_generation": generation,
|
||||||
|
"source": "mqtt-device-info",
|
||||||
|
**self._control_proof_fields(transport_snapshot),
|
||||||
|
**(
|
||||||
|
{
|
||||||
|
"intent_id": connection_binding.intent_id,
|
||||||
|
"transport_ref": connection_binding.transport_ref,
|
||||||
|
"host_path_epoch": connection_binding.host_path_epoch,
|
||||||
|
"target_ipv4": connection_binding.target_ipv4,
|
||||||
|
"target_port": connection_binding.target_port,
|
||||||
|
"connection_mode": connection_binding.connection_mode,
|
||||||
|
}
|
||||||
|
if connection_binding is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
self._set_phase_locked(
|
||||||
|
"workspace-requested" if self._workspace_requested.is_set()
|
||||||
|
else "connection-ready"
|
||||||
)
|
)
|
||||||
self._validate_connection_binding_snapshot("workspace-entry-pre-dispatch")
|
|
||||||
executor.run_workspace_entry_stage(
|
|
||||||
orchestrator,
|
|
||||||
workspace,
|
|
||||||
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
|
|
||||||
"workspace-entry-dispatch"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self._validate_connection_binding_snapshot("workspace-entry-post-response")
|
|
||||||
self._set_phase("workspace-ready")
|
|
||||||
|
|
||||||
project = executor.wait_for_operator_checkpoint(
|
workspace = executor.wait_for_operator_checkpoint(
|
||||||
"project-prompt-opened",
|
"workspace-entered",
|
||||||
self._project_requested.is_set,
|
self._workspace_requested.is_set,
|
||||||
|
reconciled_active_observed=self._active_recovery_requested.is_set,
|
||||||
)
|
)
|
||||||
assert project is not None
|
if workspace is None:
|
||||||
self._validate_connection_binding_snapshot("project-prompt-pre-dispatch")
|
self._validate_connection_binding("active-recovery-adoption")
|
||||||
binding = executor.run_project_prompt_stage(
|
executor.adopt_reconciled_scanning(
|
||||||
orchestrator,
|
|
||||||
project,
|
|
||||||
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
|
|
||||||
"project-prompt-dispatch"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self._validate_connection_binding_snapshot("project-prompt-post-response")
|
|
||||||
self._set_phase("project-ready")
|
|
||||||
|
|
||||||
start_checkpoint = executor.wait_for_operator_checkpoint(
|
|
||||||
"start-confirmed",
|
|
||||||
self._start_requested.is_set,
|
|
||||||
)
|
|
||||||
assert start_checkpoint is not None
|
|
||||||
start_command, start_confirmation = self._start_request()
|
|
||||||
start_permit = PhysicalAcceptancePermit(
|
|
||||||
start_confirmation.checklist(ModelingAction.START)
|
|
||||||
)
|
|
||||||
self._validate_connection_binding_snapshot("start-pre-dispatch")
|
|
||||||
self._set_phase("initializing")
|
|
||||||
start_active_observed = False
|
|
||||||
start_transition_gate_acquired = False
|
|
||||||
|
|
||||||
def observe_start_active() -> None:
|
|
||||||
nonlocal start_active_observed, start_transition_gate_acquired
|
|
||||||
if start_active_observed:
|
|
||||||
return
|
|
||||||
self._scanning_transition_gate.acquire()
|
|
||||||
start_transition_gate_acquired = True
|
|
||||||
try:
|
|
||||||
if coordinator is not None:
|
|
||||||
coordinator.resolve("start")
|
|
||||||
# Activate the durable recovery checkpoint at the same
|
|
||||||
# exact SCANNING proof as the physical ledger. The
|
|
||||||
# gate remains held while the read-only ordinals 13-14
|
|
||||||
# finish, so STOP cannot overtake the later public
|
|
||||||
# ``scanning`` transition.
|
|
||||||
if not self._scanning_observer_confirmed:
|
|
||||||
self._scanning_observer_confirmed = (
|
|
||||||
self._notify_scanning_observer()
|
|
||||||
)
|
|
||||||
start_active_observed = True
|
|
||||||
except BaseException:
|
|
||||||
self._scanning_transition_gate.release()
|
|
||||||
start_transition_gate_acquired = False
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
executor.execute_canonical_start(
|
|
||||||
start_command,
|
|
||||||
build_canonical_post_start_observation(authority, binding),
|
|
||||||
authority=authority,
|
authority=authority,
|
||||||
binding=binding,
|
binding=binding,
|
||||||
permit=start_permit,
|
|
||||||
checkpoint=start_checkpoint,
|
|
||||||
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
|
|
||||||
"start-dispatch"
|
|
||||||
),
|
|
||||||
start_active_observer=observe_start_active,
|
|
||||||
)
|
)
|
||||||
# Compatibility executors used by lower-level integrations may
|
with self._scanning_transition_gate:
|
||||||
# return without invoking the new proof callback. A normal
|
# Recovery adopts an already-active physical acquisition.
|
||||||
# production executor invokes it before the read-only refresh;
|
# Its checkpoint deliberately retains an open transport
|
||||||
# this idempotent fallback remains strictly post-success.
|
# gap until the post-Rerun PCL confirmation hook closes it;
|
||||||
observe_start_active()
|
# nevertheless an operator STOP must remain admissible in
|
||||||
self._validate_connection_binding_snapshot("start-post-response")
|
# this interval and can cease that gap with terminal READY.
|
||||||
self._set_phase("scanning")
|
self._scanning_observer_confirmed = True
|
||||||
finally:
|
self._set_phase("scanning")
|
||||||
if start_transition_gate_acquired:
|
else:
|
||||||
self._scanning_transition_gate.release()
|
if inspection_only:
|
||||||
|
self._validate_connection_binding_snapshot(
|
||||||
|
"inspection-promotion-pre-dispatch"
|
||||||
|
)
|
||||||
|
binding = executor.complete_connection_stage(
|
||||||
|
orchestrator,
|
||||||
|
expected_binding=binding,
|
||||||
|
)
|
||||||
|
self._validate_connection_binding_snapshot(
|
||||||
|
"inspection-promotion-post-response"
|
||||||
|
)
|
||||||
|
self._validate_connection_binding_snapshot("workspace-entry-pre-dispatch")
|
||||||
|
executor.run_workspace_entry_stage(
|
||||||
|
orchestrator,
|
||||||
|
workspace,
|
||||||
|
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
|
||||||
|
"workspace-entry-dispatch"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._validate_connection_binding_snapshot("workspace-entry-post-response")
|
||||||
|
self._set_phase("workspace-ready")
|
||||||
|
|
||||||
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
|
project = executor.wait_for_operator_checkpoint(
|
||||||
(
|
"project-prompt-opened",
|
||||||
stop_command,
|
self._project_requested.is_set,
|
||||||
stop_confirmation,
|
)
|
||||||
stop_dispatch_admission_deadline_reached,
|
assert project is not None
|
||||||
) = self._stop_request()
|
self._validate_connection_binding_snapshot("project-prompt-pre-dispatch")
|
||||||
stop_permit = PhysicalAcceptancePermit(stop_confirmation.checklist(ModelingAction.STOP))
|
binding = executor.run_project_prompt_stage(
|
||||||
# Capture the exact transport counter before any remaining
|
orchestrator,
|
||||||
# read-only validation. A deadline that expires during one of
|
project,
|
||||||
# those checks is still deterministic zero-publish evidence.
|
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
|
||||||
stop_transport_before, stop_transport_before_available = (
|
"project-prompt-dispatch"
|
||||||
self._transport_snapshot_safely(transport)
|
),
|
||||||
)
|
)
|
||||||
if stop_transport_before_available:
|
self._validate_connection_binding_snapshot("project-prompt-post-response")
|
||||||
stop_publish_attempts_before_dispatch = self._json_int_or_none(
|
self._set_phase("project-ready")
|
||||||
stop_transport_before.get("publish_attempts")
|
|
||||||
|
start_checkpoint = executor.wait_for_operator_checkpoint(
|
||||||
|
"start-confirmed",
|
||||||
|
self._start_requested.is_set,
|
||||||
|
)
|
||||||
|
assert start_checkpoint is not None
|
||||||
|
start_command, start_confirmation = self._start_request()
|
||||||
|
start_permit = PhysicalAcceptancePermit(
|
||||||
|
start_confirmation.checklist(ModelingAction.START)
|
||||||
|
)
|
||||||
|
self._validate_connection_binding_snapshot("start-pre-dispatch")
|
||||||
|
self._set_phase("initializing")
|
||||||
|
start_active_observed = False
|
||||||
|
start_transition_gate_acquired = False
|
||||||
|
|
||||||
|
def observe_start_active() -> None:
|
||||||
|
nonlocal start_active_observed, start_transition_gate_acquired
|
||||||
|
if start_active_observed:
|
||||||
|
return
|
||||||
|
self._scanning_transition_gate.acquire()
|
||||||
|
start_transition_gate_acquired = True
|
||||||
|
try:
|
||||||
|
if coordinator is not None:
|
||||||
|
coordinator.resolve("start")
|
||||||
|
# Activate the durable recovery checkpoint at the same
|
||||||
|
# exact SCANNING proof as the physical ledger. The
|
||||||
|
# gate remains held while the read-only ordinals 13-14
|
||||||
|
# finish, so STOP cannot overtake the later public
|
||||||
|
# ``scanning`` transition.
|
||||||
|
if not self._scanning_observer_confirmed:
|
||||||
|
self._scanning_observer_confirmed = (
|
||||||
|
self._notify_scanning_observer()
|
||||||
|
)
|
||||||
|
start_active_observed = True
|
||||||
|
except BaseException:
|
||||||
|
self._scanning_transition_gate.release()
|
||||||
|
start_transition_gate_acquired = False
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
executor.execute_canonical_start(
|
||||||
|
start_command,
|
||||||
|
build_canonical_post_start_observation(authority, binding),
|
||||||
|
authority=authority,
|
||||||
|
binding=binding,
|
||||||
|
permit=start_permit,
|
||||||
|
checkpoint=start_checkpoint,
|
||||||
|
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
|
||||||
|
"start-dispatch"
|
||||||
|
),
|
||||||
|
start_active_observer=observe_start_active,
|
||||||
|
)
|
||||||
|
# Compatibility executors used by lower-level integrations may
|
||||||
|
# return without invoking the new proof callback. A normal
|
||||||
|
# production executor invokes it before the read-only refresh;
|
||||||
|
# this idempotent fallback remains strictly post-success.
|
||||||
|
observe_start_active()
|
||||||
|
self._validate_connection_binding_snapshot("start-post-response")
|
||||||
|
self._set_phase("scanning")
|
||||||
|
finally:
|
||||||
|
if start_transition_gate_acquired:
|
||||||
|
self._scanning_transition_gate.release()
|
||||||
|
|
||||||
|
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
|
||||||
|
(
|
||||||
|
stop_command,
|
||||||
|
stop_confirmation,
|
||||||
|
stop_dispatch_admission_deadline_reached,
|
||||||
|
) = self._stop_request()
|
||||||
|
stop_permit = PhysicalAcceptancePermit(
|
||||||
|
stop_confirmation.checklist(ModelingAction.STOP)
|
||||||
)
|
)
|
||||||
self._require_stop_dispatch_deadline_open(
|
# Capture the exact transport counter before any remaining
|
||||||
stop_dispatch_admission_deadline_reached
|
# read-only validation. A deadline that expires during one of
|
||||||
)
|
# those checks is still deterministic zero-publish evidence.
|
||||||
self._validate_connection_binding_snapshot("stop-pre-dispatch")
|
stop_transport_before, stop_transport_before_available = (
|
||||||
self._require_stop_dispatch_deadline_open(
|
self._transport_snapshot_safely(transport)
|
||||||
stop_dispatch_admission_deadline_reached
|
)
|
||||||
)
|
if stop_transport_before_available:
|
||||||
self._set_phase("stopping")
|
stop_publish_attempts_before_dispatch = self._json_int_or_none(
|
||||||
logger.info(
|
stop_transport_before.get("publish_attempts")
|
||||||
"K1 STOP timing checkpoint",
|
)
|
||||||
extra={
|
self._require_stop_dispatch_deadline_open(
|
||||||
"event_code": "k1_stop_dispatch_timing",
|
|
||||||
"operation_stage": "pre-dispatch-validation-complete",
|
|
||||||
"device_command_sent": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
executor.execute_canonical_stop(
|
|
||||||
stop_command,
|
|
||||||
stop_permit,
|
|
||||||
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
|
|
||||||
"stop-dispatch"
|
|
||||||
),
|
|
||||||
dispatch_admission_deadline_reached=(
|
|
||||||
stop_dispatch_admission_deadline_reached
|
stop_dispatch_admission_deadline_reached
|
||||||
),
|
)
|
||||||
)
|
self._validate_connection_binding_snapshot("stop-pre-dispatch")
|
||||||
logger.info(
|
self._require_stop_dispatch_deadline_open(
|
||||||
"K1 STOP timing checkpoint",
|
stop_dispatch_admission_deadline_reached
|
||||||
extra={
|
)
|
||||||
"event_code": "k1_stop_dispatch_timing",
|
self._set_phase("stopping")
|
||||||
"operation_stage": "correlated-application-response",
|
logger.info(
|
||||||
"device_command_sent": True,
|
"K1 STOP timing checkpoint",
|
||||||
},
|
extra={
|
||||||
)
|
"event_code": "k1_stop_dispatch_timing",
|
||||||
self._validate_connection_binding_snapshot("stop-post-response")
|
"operation_stage": "pre-dispatch-validation-complete",
|
||||||
self._set_phase("awaiting-standby-confirmation")
|
"device_command_sent": False,
|
||||||
executor.maintain_post_stop_until_standby()
|
},
|
||||||
if coordinator is not None:
|
)
|
||||||
coordinator.resolve("stop")
|
executor.execute_canonical_stop(
|
||||||
self._set_phase("completed")
|
stop_command,
|
||||||
|
stop_permit,
|
||||||
|
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
|
||||||
|
"stop-dispatch"
|
||||||
|
),
|
||||||
|
dispatch_admission_deadline_reached=(
|
||||||
|
stop_dispatch_admission_deadline_reached
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"K1 STOP timing checkpoint",
|
||||||
|
extra={
|
||||||
|
"event_code": "k1_stop_dispatch_timing",
|
||||||
|
"operation_stage": "correlated-application-response",
|
||||||
|
"device_command_sent": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._validate_connection_binding_snapshot("stop-post-response")
|
||||||
|
self._set_phase("awaiting-standby-confirmation")
|
||||||
|
executor.maintain_post_stop_until_standby()
|
||||||
|
if coordinator is not None:
|
||||||
|
coordinator.resolve("stop")
|
||||||
|
with self._lock:
|
||||||
|
# Clear only the acquisition checkpoints before publishing READY.
|
||||||
|
# Network ownership and DeviceInfo proof remain on this socket.
|
||||||
|
self._workspace_requested.clear()
|
||||||
|
self._active_recovery_requested.clear()
|
||||||
|
self._project_requested.clear()
|
||||||
|
self._start_requested.clear()
|
||||||
|
self._stop_requested.clear()
|
||||||
|
self._start_confirmation = None
|
||||||
|
self._stop_confirmation = None
|
||||||
|
self._stop_dispatch_admission_deadline_reached = None
|
||||||
|
self._prepared_start_command = None
|
||||||
|
self._prepared_stop_command = None
|
||||||
|
self._project_name = None
|
||||||
|
self._scanning_observer_confirmed = self._scanning_observer is None
|
||||||
|
completed_acquisition = True
|
||||||
|
self._set_phase_locked("completed")
|
||||||
|
executor.maintain_standby_until_next_acquisition(self._workspace_requested.is_set)
|
||||||
|
self._validate_connection_binding_snapshot("next-acquisition-preflight")
|
||||||
|
# Operation keys and response correlations are one-shot per MQTT
|
||||||
|
# dialogue. A new explicit scan gets a fresh socket, never reset
|
||||||
|
# consumption sets or ambiguous reuse of the old response IDs.
|
||||||
|
transport.close()
|
||||||
|
with self._lock:
|
||||||
|
self._transport = None
|
||||||
|
self._verified_control = None
|
||||||
|
self._run_generation += 1
|
||||||
|
generation = self._run_generation
|
||||||
|
executor = None
|
||||||
|
stop_publish_attempts_before_dispatch = None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely(
|
dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely(
|
||||||
executor
|
executor
|
||||||
@@ -1217,7 +1268,9 @@ class InteractiveApplicationControlSession:
|
|||||||
and stop_command_attempted is False
|
and stop_command_attempted is False
|
||||||
and not diagnostic_evidence_unavailable
|
and not diagnostic_evidence_unavailable
|
||||||
)
|
)
|
||||||
outcome_unknown = not definite_stop_admission_rejected_before_publish and (
|
outcome_unknown = not (
|
||||||
|
definite_stop_admission_rejected_before_publish or completed_acquisition
|
||||||
|
) and (
|
||||||
isinstance(exc, ApplicationCommandOutcomeUnknown)
|
isinstance(exc, ApplicationCommandOutcomeUnknown)
|
||||||
or modeling_command_attempted is True
|
or modeling_command_attempted is True
|
||||||
or bool(diagnostic_evidence_unavailable)
|
or bool(diagnostic_evidence_unavailable)
|
||||||
@@ -1288,7 +1341,7 @@ class InteractiveApplicationControlSession:
|
|||||||
)
|
)
|
||||||
is None
|
is None
|
||||||
)
|
)
|
||||||
safe_to_retry = status_reconciled_prestart_failure or (
|
safe_to_retry = completed_acquisition or status_reconciled_prestart_failure or (
|
||||||
not outcome_unknown
|
not outcome_unknown
|
||||||
and (
|
and (
|
||||||
not transport_created
|
not transport_created
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
|
|||||||
position += 60 + length + length % 2
|
position += 60 + length + length % 2
|
||||||
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
||||||
control = archive.extractfile("control").read().decode()
|
control = archive.extractfile("control").read().decode()
|
||||||
assert "Depends: mission-core-node (>= 0.8.12)" in control
|
assert "Depends: mission-core-node (>= 0.8.13)" in control
|
||||||
assert "Replaces: mission-core-node (<< 0.8.0)" in control
|
assert "Replaces: mission-core-node (<< 0.8.0)" in control
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -438,10 +438,26 @@ def test_applied_wifi_does_not_grant_control_or_start_authority():
|
|||||||
assert item["control"]["reason_code"] is None
|
assert item["control"]["reason_code"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence():
|
@pytest.mark.parametrize("initial_phase", ["connection-ready", "completed"])
|
||||||
|
@pytest.mark.parametrize("pending_start", [False, True])
|
||||||
|
def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence(
|
||||||
|
initial_phase, pending_start,
|
||||||
|
):
|
||||||
async def run():
|
async def run():
|
||||||
device = bridge()
|
device = bridge()
|
||||||
|
device.facade.current["application_control_session"]["state"] = initial_phase
|
||||||
sensor = NodeK1Sensor(device, None)
|
sensor = NodeK1Sensor(device, None)
|
||||||
|
original_invoke = device.invoke
|
||||||
|
|
||||||
|
async def invoke(action, parameters, operation_id):
|
||||||
|
result = await original_invoke(action, parameters, operation_id)
|
||||||
|
if action == "acquisition.start" and pending_start:
|
||||||
|
result["application_control_session"]["physical_command"] = {
|
||||||
|
"requires_reconciliation": True,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
device.invoke = invoke
|
||||||
item = project_sensor(state(), "node-test")
|
item = project_sensor(state(), "node-test")
|
||||||
command = {
|
command = {
|
||||||
"operation_id": "op_" + "a" * 32,
|
"operation_id": "op_" + "a" * 32,
|
||||||
|
|||||||
@@ -34792,3 +34792,24 @@ def test_power_loss_during_observation_preserves_audit_and_releases_ownership(
|
|||||||
is True
|
is True
|
||||||
)
|
)
|
||||||
assert write_calls == 1
|
assert write_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("socket_open", [True, False])
|
||||||
|
def test_completed_acquisition_does_not_imply_control_loss(tmp_path, socket_open):
|
||||||
|
service, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
binding = _seed_supervised_connection(service)
|
||||||
|
control = {
|
||||||
|
"state": "completed", "control_socket_open": socket_open,
|
||||||
|
"verified_control": _verified_control_for_binding(
|
||||||
|
binding, control_session_id="retained-control-session",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
service._reconcile_connection_supervisor(control, {"source_mode": "idle"}) # noqa: SLF001
|
||||||
|
snapshot = service._connection_supervisor.snapshot() # noqa: SLF001
|
||||||
|
assert snapshot.authority.control_allowed is socket_open
|
||||||
|
assert snapshot.authority.acquisition_start_allowed is socket_open
|
||||||
|
if socket_open:
|
||||||
|
service._reconcile_connection_supervisor( # noqa: SLF001
|
||||||
|
{**control, "state": "failed"}, {"source_mode": "idle"},
|
||||||
|
)
|
||||||
|
assert not service._connection_supervisor.snapshot().authority.control_allowed # noqa: SLF001
|
||||||
|
|||||||
@@ -367,6 +367,17 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
|||||||
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
||||||
assert response_evidence[-1]["operation_key"] == "modeling:stop"
|
assert response_evidence[-1]["operation_key"] == "modeling:stop"
|
||||||
assert all("payload" not in item for item in response_evidence)
|
assert all("payload" not in item for item in response_evidence)
|
||||||
|
before_batches = list(transport.batches)
|
||||||
|
standby_pumps = len(transport.maintain_calls)
|
||||||
|
transport.pre_start_ready = lambda _binding: True
|
||||||
|
executor.maintain_standby_until_next_acquisition(
|
||||||
|
lambda: len(transport.maintain_calls) >= standby_pumps + 3,
|
||||||
|
)
|
||||||
|
assert transport.batches == before_batches
|
||||||
|
assert len(transport.maintain_calls) == standby_pumps + 3
|
||||||
|
# A next dialogue must use a fresh transport, not reset one-shot guards.
|
||||||
|
with pytest.raises(ApplicationAcceptanceError):
|
||||||
|
executor.run_connection_stage(orchestrator)
|
||||||
assert APPLICATION_KEY not in str(executor.snapshot())
|
assert APPLICATION_KEY not in str(executor.snapshot())
|
||||||
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
|
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
|
||||||
TypeAdapter(JsonValue).validate_python(executor.snapshot())
|
TypeAdapter(JsonValue).validate_python(executor.snapshot())
|
||||||
|
|||||||
@@ -372,6 +372,14 @@ class FakeExecutor:
|
|||||||
def maintain_post_stop_until_standby(self) -> None:
|
def maintain_post_stop_until_standby(self) -> None:
|
||||||
self.records.append("wait:device-standby")
|
self.records.append("wait:device-standby")
|
||||||
|
|
||||||
|
def maintain_standby_until_next_acquisition(self, requested: Callable[[], bool]) -> None:
|
||||||
|
while not requested():
|
||||||
|
if self.transport.state == "closed":
|
||||||
|
raise RuntimeError("test transport closed")
|
||||||
|
if not self.transport.proof_fresh:
|
||||||
|
raise ApplicationControlProofStale("test control proof expired")
|
||||||
|
threading.Event().wait(0.005)
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
def snapshot(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"records": list(self.records),
|
"records": list(self.records),
|
||||||
@@ -2160,9 +2168,11 @@ def test_new_explicit_session_waits_for_old_worker_transport_retirement(
|
|||||||
_wait_phase(session, "scanning")
|
_wait_phase(session, "scanning")
|
||||||
session.request_stop(confirmation=_confirmation())
|
session.request_stop(confirmation=_confirmation())
|
||||||
_wait_phase(session, "completed")
|
_wait_phase(session, "completed")
|
||||||
|
assert new_thread.is_alive()
|
||||||
|
session.close()
|
||||||
new_thread.join(timeout=2.0)
|
new_thread.join(timeout=2.0)
|
||||||
assert not new_thread.is_alive()
|
assert not new_thread.is_alive()
|
||||||
assert transports[1].close_calls == 1
|
assert transports[1].close_calls == 2 # explicit close and idempotent worker cleanup
|
||||||
|
|
||||||
|
|
||||||
def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
|
def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
|
||||||
@@ -2856,3 +2866,66 @@ def test_pretransport_authority_failure_remains_safe_after_worker_retirement() -
|
|||||||
assert failure["diagnostic_snapshot_unavailable"] == []
|
assert failure["diagnostic_snapshot_unavailable"] == []
|
||||||
assert failure["diagnostic_evidence_unavailable"] == []
|
assert failure["diagnostic_evidence_unavailable"] == []
|
||||||
assert failure["safe_to_retry"] is True
|
assert failure["safe_to_retry"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def close_test_control_sessions(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
sessions = []
|
||||||
|
original = InteractiveApplicationControlSession.__init__
|
||||||
|
|
||||||
|
def tracked(self, *args, **kwargs):
|
||||||
|
original(self, *args, **kwargs)
|
||||||
|
sessions.append(self)
|
||||||
|
|
||||||
|
monkeypatch.setattr(InteractiveApplicationControlSession, "__init__", tracked)
|
||||||
|
yield
|
||||||
|
for session in sessions:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_named_scans_retain_idle_connection_and_require_new_dialogues(monkeypatch):
|
||||||
|
FakeExecutor.records = []
|
||||||
|
monkeypatch.setattr(session_module, "PhysicalAcceptanceDialogueExecutor", FakeExecutor)
|
||||||
|
transports = []
|
||||||
|
def create_transport(host):
|
||||||
|
transport = FakeTransport(host)
|
||||||
|
transports.append(transport)
|
||||||
|
return transport
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
FakeAuthorityLoader(), transport_factory=create_transport,
|
||||||
|
)
|
||||||
|
session.open(host="192.168.1.20", timezone_name="UTC", connection_binding=_connection_binding())
|
||||||
|
_wait_phase(session, "connection-ready")
|
||||||
|
first_checkpoint = None
|
||||||
|
for project in ("FIRST", "SECOND"):
|
||||||
|
current = session.snapshot()
|
||||||
|
if first_checkpoint is not None:
|
||||||
|
with pytest.raises(ApplicationAcceptanceError):
|
||||||
|
session.enter_workspace(expected_session_generation=first_checkpoint[0],
|
||||||
|
expected_state_revision=first_checkpoint[1])
|
||||||
|
first_checkpoint = (current["session_generation"], current["state_revision"])
|
||||||
|
session.enter_workspace(expected_session_generation=current["session_generation"],
|
||||||
|
expected_state_revision=current["state_revision"])
|
||||||
|
_wait_phase(session, "workspace-ready")
|
||||||
|
session.open_project_prompt()
|
||||||
|
_wait_phase(session, "project-ready")
|
||||||
|
session.request_start(project_name=project, confirmation=_confirmation())
|
||||||
|
_wait_phase(session, "scanning")
|
||||||
|
session.request_stop(confirmation=_confirmation())
|
||||||
|
finished = _wait_phase(session, "completed")
|
||||||
|
assert finished["control_socket_open"] is True
|
||||||
|
assert finished["can_enter_workspace"] is True
|
||||||
|
assert finished["verified_control"]["control_proof_fresh"] is True
|
||||||
|
before = list(FakeExecutor.records)
|
||||||
|
threading.Event().wait(0.025)
|
||||||
|
assert FakeExecutor.records == before # no timer-driven new acquisition
|
||||||
|
assert transports[-1].state == "ready"
|
||||||
|
assert len(transports) == (1 if project == "FIRST" else 2)
|
||||||
|
assert len(transports) == 2
|
||||||
|
assert transports[0].state == "closed"
|
||||||
|
assert FakeExecutor.records.count("start:11-14") == 2
|
||||||
|
assert FakeExecutor.records.count("stop") == 2
|
||||||
|
transports[-1].proof_fresh = False
|
||||||
|
_wait_phase(session, "failed")
|
||||||
|
assert session.snapshot()["control_socket_open"] is False
|
||||||
|
assert len(transports) == 2 # no reconnect or physical command replay on real loss
|
||||||
|
|||||||
Reference in New Issue
Block a user