From 321cde70e8b411591b730ae780361abf2940e62d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 14 Aug 2026 21:30:03 +0300 Subject: [PATCH] fix(k1): settle same-process standby checkpoint --- ...K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md | 79 +++++++++++++++++++ .../active_acquisition_recovery_checkpoint.py | 30 +++++-- ...tive_acquisition_checkpoint_integration.py | 39 ++++++--- ..._active_acquisition_recovery_checkpoint.py | 1 + 4 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 docs/runbooks/K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md diff --git a/docs/runbooks/K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md b/docs/runbooks/K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md new file mode 100644 index 0000000..686beb5 --- /dev/null +++ b/docs/runbooks/K1_OPERATOR_FLOW_INCREMENTAL_ACCEPTANCE.md @@ -0,0 +1,79 @@ +# K1 operator flow: incremental acceptance + +Status: working acceptance ledger, 2026-08-14. + +This is the short regression anchor for changes to the existing K1 operator +flow. The authoritative connection and safety model remains +[`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md). +Every K1 fix must name one row below, add a reducer test, and preserve all +previously accepted rows. No change in this ledger authorizes a new K1 command. + +## Non-negotiable device boundary + +- START and STOP keep the captured vendor payload, topic, QoS and one-shot + dispatch contract. No automatic replay, substitute command or inferred ACK. +- Reconnect and Verify are read-only. Scenario reset changes local Mission Core + state only; it sends no BLE, Wi-Fi, START or STOP operation. +- `READY` / `SCAN_OVER` from a fresh, identity-bound, non-retained status is + physical standby truth. Local recovery state must converge to that fact + before a new START can be admitted. +- An unknown command outcome is never presented as success. A later exact + read-only reconciliation may settle it, but may not rewrite its audit history. + +## Operator-state contract + +| State | Stable operator surface | Required feedback | Accepted exit | Forbidden | +| --- | --- | --- | --- | --- | +| Saved connection needs checking | Saved K1 card plus `Переподключиться` and `Подключить новый K1` | None before action | One explicit action | Hidden Scan, Verify or reconnect | +| Reconnect in progress | The same saved K1 card and same button geometry | Disabled action with canonical `ActivityIndicator`; neutral copy `Переподключение` | Connected or an actionable terminal result | Replacing the whole panel, blank wait state, first-person copy | +| Bluetooth search | The same connection step | Visible activity and search phase | Results or terminal no-result state | Empty disabled panel with no progress | +| Device selected | Selected-device card and network fields | None while editing | One explicit Apply or choose another device | Background provisioning or hidden second discovery | +| Apply in progress | The same selected-device/network form | Disabled Apply with canonical `ActivityIndicator` and named phase | `network_applied`, or a truthful terminal failure | Container-size jumps, screen substitution, automatic Apply replay | +| Network applied, control checking | Connection card remains visible | Passive `Подтверждение управления` activity | Control ready or explicit recovery choice | Calling the provisioning result a network failure | +| Ready to start | Project form | No activity | One explicit START | Green readiness without exact control authority | +| START in progress | The same project form | Disabled START with named phase | Active acquisition or truthful terminal state | Second START, unexplained switch to viewer | +| Active acquisition | Spatial viewer and one stable STOP action | Stream/visualizer status separately | One explicit STOP | STOP enabled/disabled oscillation before click | +| STOP in progress | Same viewer and same STOP position | Disabled STOP with named phase | Physical standby or explicit outcome-unknown recovery | Second STOP or button disappearance | +| Physical standby | Clean local idle/new-session state | None | Reconnect or new acquisition | Active recovery checkpoint blocking a proven `READY` | + +## Presentation invariants + +- Any operator-owned operation lasting longer than one rendered frame has an + `ActivityIndicator`, visible neutral process noun, `aria-busy`, and a stable + action/container position. +- Pending copy describes the process, not the application or operator: + `Переподключение`, `Поиск устройства`, `Подтверждение управления`, + `Подготовка приёма`, `Остановка записи`. +- One action never flashes an unrelated complete screen between its pending and + terminal states. +- Wi-Fi mutation, control bootstrap, physical command and visualization are + separate facts. Failure of a later fact must not relabel an earlier success. +- If decoded PCL frames exist but the browser has not admitted the exact Rerun + store, the viewer remains `Подключение визуального источника` and must become + an actionable visualization error at its deadline. It must not show + `Визуализатор готов` over an empty scene. + +## Increment gate + +For each patch, record four facts in the handoff: + +1. **Fix** — one named broken transition. +2. **Reducer** — the smallest offline sequence that failed before the patch. +3. **Visual acceptance** — exactly what the operator should see. +4. **Not accepted yet** — adjacent known violations that remain out of scope. + +Current violations observed on 2026-08-14: + +- `K1-P0-CHECKPOINT`: fresh standby reconciliation left an ACTIVE recovery + checkpoint and blocked the next connection. Code fix and same-process/restart + reducers are green. Live startup convergence accepted: checkpoint revision 16 + is `ceased`, physical head remains `physical-standby-observed`, and the public + policy again admits Scan plus read-only configured-device observation. +- `K1-P0-RERUN-ADMISSION`: backend decoded and published PCL, but the browser + never admitted the active Rerun store; camera/counters were visible over an + empty point scene. Not fixed in the checkpoint increment. +- `K1-P1-STOP-STABILITY`: STOP authority visibly oscillated before the operator + clicked. Not fixed in the checkpoint increment. +- `K1-P1-PENDING-FEEDBACK`: reconnect/search/select/provision transitions replace + panels or show disabled controls without an activity indicator. Not fixed in + the checkpoint increment. diff --git a/src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py b/src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py index 4d7325a..2eb6a4a 100644 --- a/src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py +++ b/src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py @@ -2627,6 +2627,7 @@ def _require_gap_cessation_evidence_session( failed_binding: ActiveAcquisitionRecoveryTransportBinding, failed_evidence_session_id: str, status_proof: ActiveAcquisitionRecoveryStatusProof, + allow_same_runtime_successor_evidence: bool = False, ) -> None: crosses_runtime_instance = ( status_proof.binding.runtime_instance_id @@ -2640,7 +2641,11 @@ def _require_gap_cessation_evidence_session( "terminal recovery across a runtime instance requires a successor " "evidence session" ) - if not crosses_runtime_instance and not preserves_evidence_session: + if ( + not crosses_runtime_instance + and not preserves_evidence_session + and not allow_same_runtime_successor_evidence + ): raise ActiveAcquisitionRecoveryCheckpointTransitionError( "same-runtime terminal recovery must preserve the exact evidence session" ) @@ -3316,16 +3321,26 @@ def _require_active_reconciled_standby_shape( raise ActiveAcquisitionRecoveryCheckpointTransitionError( "restart standby physical proof must advance the active lineage" ) + recovery_binding = cessation_status_proof.binding + same_runtime = ( + recovery_binding.runtime_instance_id == failed_binding.runtime_instance_id + ) if ( - cessation_status_proof.binding.runtime_instance_id - == failed_binding.runtime_instance_id - or cessation_status_proof.binding.control_session_id - == failed_binding.control_session_id + recovery_binding.control_session_id == failed_binding.control_session_id or cessation_status_proof.evidence_session_id == failed_evidence_session_id + or ( + same_runtime + and ( + recovery_binding.host_path_epoch == failed_binding.host_path_epoch + or recovery_binding.producer_generation + == failed_binding.producer_generation + ) + ) ): raise ActiveAcquisitionRecoveryCheckpointTransitionError( - "restart standby settlement requires new runtime, control and evidence sessions" + "standby settlement requires a new control/evidence boundary; " + "same-runtime recovery also requires a new host path and producer generation" ) gap_started = _validated_timestamp( @@ -4982,6 +4997,9 @@ def _validate_checkpoint_semantics( prior_active_status.evidence_session_id ), status_proof=status, + allow_same_runtime_successor_evidence=( + receipts[-1].kind == "cease-active-reconciled-standby" + ), ) if receipts[-1].kind == "cease-active-reconciled-standby": gap_started_at_utc = checkpoint.last_gap_started_at_utc diff --git a/tests/test_xgrids_active_acquisition_checkpoint_integration.py b/tests/test_xgrids_active_acquisition_checkpoint_integration.py index 87944d2..c4b4f3f 100644 --- a/tests/test_xgrids_active_acquisition_checkpoint_integration.py +++ b/tests/test_xgrids_active_acquisition_checkpoint_integration.py @@ -455,11 +455,17 @@ def test_repeated_reset_reopen_fresh_ready_ceases_old_active_checkpoint( assert service._active_acquisition_checkpoint_trust == "trusted" # noqa: SLF001 -def test_restart_ready_after_dispatched_stop_ceases_active_checkpoint( +@pytest.mark.parametrize( + "same_process", + (False, True), + ids=("process-restart", "same-process-new-control-epoch"), +) +def test_ready_after_dispatched_stop_ceases_active_checkpoint( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + same_process: bool, ) -> None: - """Fresh READY settles a dispatched STOP whose outcome crossed restart.""" + """Fresh READY settles a dispatched STOP across either recovery boundary.""" service = _service(tmp_path, monkeypatch) original = _connection( @@ -509,9 +515,10 @@ def test_restart_ready_after_dispatched_stop_ceases_active_checkpoint( observed_at_utc="2026-08-13T12:01:01.000Z", ), ) - service._snapshot_runtime_id = ( # noqa: SLF001 - "snapshot-runtime-checkpoint-dispatched-stop-successor" - ) + if not same_process: + service._snapshot_runtime_id = ( # noqa: SLF001 + "snapshot-runtime-checkpoint-dispatched-stop-successor" + ) restarted = _connection( control_session_id="checkpoint-dispatched-stop-restarted-control", host_path_epoch=2, @@ -561,6 +568,7 @@ def test_restart_ready_after_dispatched_stop_ceases_active_checkpoint( lambda: None, ) + settle_checkpoint = service._settle_restart_checkpoint_after_verified_standby # noqa: SLF001 monkeypatch.setattr( service, "_settle_restart_checkpoint_after_verified_standby", @@ -583,10 +591,23 @@ def test_restart_ready_after_dispatched_stop_ceases_active_checkpoint( assert checkpoint is not None assert checkpoint.state == "active" - # Simulate the exact live process boundary: the physical ledger fsync is - # durable, while checkpoint cessation did not commit in the prior process. - restarted_service = _service(tmp_path, monkeypatch) - assert restarted_service._active_acquisition_checkpoint_reason is None # noqa: SLF001 + # The physical ledger fsync is durable while checkpoint cessation did not + # commit. Recovery may happen after a process restart or, as observed in + # production, later in the same process under a fresh control/path epoch. + if same_process: + monkeypatch.setattr( + service, + "_settle_restart_checkpoint_after_verified_standby", + settle_checkpoint, + ) + token = service._validate_active_acquisition_checkpoint_lineage() # noqa: SLF001 + assert token is not None + assert service._settle_durable_prepared_stop_standby_checkpoint( # noqa: SLF001 + token + ), service._active_acquisition_checkpoint_reason # noqa: SLF001 + else: + restarted_service = _service(tmp_path, monkeypatch) + assert restarted_service._active_acquisition_checkpoint_reason is None # noqa: SLF001 checkpoint = ActiveAcquisitionRecoveryCheckpointStore(tmp_path).snapshot().checkpoint assert checkpoint is not None assert checkpoint.state == "ceased" diff --git a/tests/test_xgrids_active_acquisition_recovery_checkpoint.py b/tests/test_xgrids_active_acquisition_recovery_checkpoint.py index daa255c..2248b73 100644 --- a/tests/test_xgrids_active_acquisition_recovery_checkpoint.py +++ b/tests/test_xgrids_active_acquisition_recovery_checkpoint.py @@ -1312,6 +1312,7 @@ def test_cease_active_reconciled_standby_rejects_inexact_restart_proofs( replace( status.binding, runtime_instance_id=binding.runtime_instance_id, + host_path_epoch=binding.host_path_epoch, ), replace( status.binding,