fix(k1): settle same-process standby checkpoint
This commit is contained in:
@@ -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.
|
||||||
@@ -2627,6 +2627,7 @@ def _require_gap_cessation_evidence_session(
|
|||||||
failed_binding: ActiveAcquisitionRecoveryTransportBinding,
|
failed_binding: ActiveAcquisitionRecoveryTransportBinding,
|
||||||
failed_evidence_session_id: str,
|
failed_evidence_session_id: str,
|
||||||
status_proof: ActiveAcquisitionRecoveryStatusProof,
|
status_proof: ActiveAcquisitionRecoveryStatusProof,
|
||||||
|
allow_same_runtime_successor_evidence: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
crosses_runtime_instance = (
|
crosses_runtime_instance = (
|
||||||
status_proof.binding.runtime_instance_id
|
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 "
|
"terminal recovery across a runtime instance requires a successor "
|
||||||
"evidence session"
|
"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(
|
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
||||||
"same-runtime terminal recovery must preserve the exact evidence session"
|
"same-runtime terminal recovery must preserve the exact evidence session"
|
||||||
)
|
)
|
||||||
@@ -3316,16 +3321,26 @@ def _require_active_reconciled_standby_shape(
|
|||||||
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
||||||
"restart standby physical proof must advance the active lineage"
|
"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 (
|
if (
|
||||||
cessation_status_proof.binding.runtime_instance_id
|
recovery_binding.control_session_id == failed_binding.control_session_id
|
||||||
== failed_binding.runtime_instance_id
|
|
||||||
or cessation_status_proof.binding.control_session_id
|
|
||||||
== failed_binding.control_session_id
|
|
||||||
or cessation_status_proof.evidence_session_id
|
or cessation_status_proof.evidence_session_id
|
||||||
== failed_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(
|
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(
|
gap_started = _validated_timestamp(
|
||||||
@@ -4982,6 +4997,9 @@ def _validate_checkpoint_semantics(
|
|||||||
prior_active_status.evidence_session_id
|
prior_active_status.evidence_session_id
|
||||||
),
|
),
|
||||||
status_proof=status,
|
status_proof=status,
|
||||||
|
allow_same_runtime_successor_evidence=(
|
||||||
|
receipts[-1].kind == "cease-active-reconciled-standby"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if 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
|
gap_started_at_utc = checkpoint.last_gap_started_at_utc
|
||||||
|
|||||||
@@ -455,11 +455,17 @@ def test_repeated_reset_reopen_fresh_ready_ceases_old_active_checkpoint(
|
|||||||
assert service._active_acquisition_checkpoint_trust == "trusted" # noqa: SLF001
|
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,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
same_process: bool,
|
||||||
) -> None:
|
) -> 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)
|
service = _service(tmp_path, monkeypatch)
|
||||||
original = _connection(
|
original = _connection(
|
||||||
@@ -509,6 +515,7 @@ def test_restart_ready_after_dispatched_stop_ceases_active_checkpoint(
|
|||||||
observed_at_utc="2026-08-13T12:01:01.000Z",
|
observed_at_utc="2026-08-13T12:01:01.000Z",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if not same_process:
|
||||||
service._snapshot_runtime_id = ( # noqa: SLF001
|
service._snapshot_runtime_id = ( # noqa: SLF001
|
||||||
"snapshot-runtime-checkpoint-dispatched-stop-successor"
|
"snapshot-runtime-checkpoint-dispatched-stop-successor"
|
||||||
)
|
)
|
||||||
@@ -561,6 +568,7 @@ def test_restart_ready_after_dispatched_stop_ceases_active_checkpoint(
|
|||||||
lambda: None,
|
lambda: None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
settle_checkpoint = service._settle_restart_checkpoint_after_verified_standby # noqa: SLF001
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
service,
|
service,
|
||||||
"_settle_restart_checkpoint_after_verified_standby",
|
"_settle_restart_checkpoint_after_verified_standby",
|
||||||
@@ -583,8 +591,21 @@ def test_restart_ready_after_dispatched_stop_ceases_active_checkpoint(
|
|||||||
assert checkpoint is not None
|
assert checkpoint is not None
|
||||||
assert checkpoint.state == "active"
|
assert checkpoint.state == "active"
|
||||||
|
|
||||||
# Simulate the exact live process boundary: the physical ledger fsync is
|
# The physical ledger fsync is durable while checkpoint cessation did not
|
||||||
# durable, while checkpoint cessation did not commit in the prior process.
|
# 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)
|
restarted_service = _service(tmp_path, monkeypatch)
|
||||||
assert restarted_service._active_acquisition_checkpoint_reason is None # noqa: SLF001
|
assert restarted_service._active_acquisition_checkpoint_reason is None # noqa: SLF001
|
||||||
checkpoint = ActiveAcquisitionRecoveryCheckpointStore(tmp_path).snapshot().checkpoint
|
checkpoint = ActiveAcquisitionRecoveryCheckpointStore(tmp_path).snapshot().checkpoint
|
||||||
|
|||||||
@@ -1312,6 +1312,7 @@ def test_cease_active_reconciled_standby_rejects_inexact_restart_proofs(
|
|||||||
replace(
|
replace(
|
||||||
status.binding,
|
status.binding,
|
||||||
runtime_instance_id=binding.runtime_instance_id,
|
runtime_instance_id=binding.runtime_instance_id,
|
||||||
|
host_path_epoch=binding.host_path_epoch,
|
||||||
),
|
),
|
||||||
replace(
|
replace(
|
||||||
status.binding,
|
status.binding,
|
||||||
|
|||||||
Reference in New Issue
Block a user