diff --git a/docs/reference/protocols/file-lock-acquisition-v0.md b/docs/reference/protocols/file-lock-acquisition-v0.md index 634459b8c6..3e3eaf3fc3 100644 --- a/docs/reference/protocols/file-lock-acquisition-v0.md +++ b/docs/reference/protocols/file-lock-acquisition-v0.md @@ -39,6 +39,13 @@ releases wrote `*.lock.holder.json`. Readers still accept that name, preferring the most recently acquired record, and each new holder removes it while it owns the kernel lock. +Unreadable holder metadata, including invalid UTF-8 or malformed JSON, is +advisory failure, not evidence that the kernel lock is free. Readers retain +any valid alternate holder record; without one, liveness is `unreadable` and +a timed-out waiter still receives `lock_acquire_timeout` with an empty holder +record and the existing inspection guidance. Reading diagnostics never repairs +or removes the damaged record; a later successful holder overwrites it normally. + A timeout appends one `file_lock_incident_v0` row to the sibling `*.lock.incidents.jsonl` channel. That append uses `O_APPEND` directly and does not acquire the blocked lock. The row contains holder and waiter identities, diff --git a/loopx/file_lock.py b/loopx/file_lock.py index d19e688f33..09959b2b80 100644 --- a/loopx/file_lock.py +++ b/loopx/file_lock.py @@ -423,7 +423,7 @@ def _read_latest_holder_record(path: Path) -> tuple[dict[str, object], bool]: text = holder_path.read_text(encoding="utf-8") except FileNotFoundError: continue - except OSError: + except (OSError, UnicodeDecodeError): unreadable = True continue try: diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 5257787d1f..426d485acd 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `lock_holder_decode_hides_timeout` | Lock diagnostics or a contended acquisition raises `UnicodeDecodeError` instead of reporting unreadable metadata or the typed timeout. | Synthetic damaged holder bytes, liveness readback, and a real held-lock timeout followed by normal reacquisition. | Holder text decoding fails before the JSON/error guard, masking advisory failure and incident recording. | Treat invalid UTF-8 as unreadable at the shared holder reader; retain valid alternate records and kernel ownership. Cover the incident, inspection guidance and recovery after the actual holder exits; never delete the lock as repair. | | `scheduler_deadline_bucket_reset_gap` | A generated tighter deadline ACK fails with `progression_regression`, even after the host applies the requested RRULE. | Saved failed ACK, originating Turn, prior/current progression buckets and reset tokens, current host readback, and exact generated argv. | The deadline changes the effective progression while an override reset profile omits those buckets, so index zero still carries the old identity. | Bind effective buckets through the existing reset-profile digest; keep time within a bucket stable. Preserve native monotonic, host-readback, receipt-freshness, CAS and replay guards. Reproduce 30-minute/index-one to 15-minute/index-zero through disposable File/SQLite CLI and separately qualify the installed artifact; never edit active scheduler state or infer runtime adoption from source tests. | | `quiet_receipt_turn_start_context_loss` | A delegated request is delivered, but a quiet heartbeat reports no required inbox read and leaves it unassessed. | Original inbox delivery and assessment readback, initial hook dispatch, the captured final guard packet, and the automatic no-spend receipt reconstruction path. | The CLI observes pending context, then rebuilds the decision after stall writeback without forwarding that same Turn's hook dispatch. | Forward the original dispatch through the existing live-decision owner during reconstruction; preserve pending, empty and failed observations without re-running hooks or granting delivery/spend. Cover first and replayed stall receipts through final capture and CLI projection, including negative authority checks. Assess and link the original request, execute its admitted work, and report the actual result through its native return route; delivery or adoption alone is not completion. | | `benchmark_planning_clock_projection_gap` | A resumed task closes work using an earlier Host deadline even though a continuation was explicitly authorized; readable history and an old ready plan are mistaken for current executable work. | Original task constraints, Host phase receipts, saved model input, current authorized continuation, actual quota/frontier readback and terminal dispositions; keep private trajectories outside public artifacts. | Historical Host timing and closure context survived recovery without the new continuation intent; ordinary planning should not inject Host countdowns. | Keep Host deadlines, remaining time and execution reserves out of ordinary model input; retain external enforcement and task-owned time constraints. At the explicitly authorized archive-recovery boundary, preserve old history and closure records, identify earlier Host clocks as historical in the new continuation request, and reuse Goal/Todo/replan with a stable operation identity. Read back actionable work or review before calling continuation ready; an old plan or exit code zero is insufficient. Do not renew budgets on retry, reopen without authorization, clear a user stop, filter task deadlines or bypass evaluation cooldowns. Cover clock-free fresh/resumed input, idempotent recovery and legitimate stopping; qualify real model adoption and score gains separately. | diff --git a/tests/test_file_lock.py b/tests/test_file_lock.py index 3ed242eed3..31d575252c 100644 --- a/tests/test_file_lock.py +++ b/tests/test_file_lock.py @@ -599,3 +599,70 @@ def test_windows_holder_reader_prefers_the_latest_acquisition( state, record = file_lock.lock_holder_liveness(target) assert state == file_lock.LOCK_HOLDER_LIVE assert record["acquired_at"] == "2026-01-01T00:00:02Z" + + +@pytest.mark.parametrize("payload", [b"\xff\xfe", b'{"pid": "\xe2\x82']) +def test_holder_liveness_treats_invalid_utf8_as_unreadable( + tmp_path: Path, payload: bytes +) -> None: + target = tmp_path / "state.json" + holder = lock_holder_path(target) + holder.write_bytes(payload) + + assert file_lock.lock_holder_liveness(target) == ( + file_lock.LOCK_HOLDER_UNREADABLE, + {}, + ) + assert holder.read_bytes() == payload + + +@pytest.mark.parametrize("corrupt_current", [True, False]) +def test_windows_holder_reader_keeps_valid_record_when_other_name_is_invalid_utf8( + tmp_path: Path, windows_holder_layout: None, corrupt_current: bool +) -> None: + target = tmp_path / "state.json" + current, legacy = file_lock.lock_holder_paths(target) + corrupt, valid = (current, legacy) if corrupt_current else (legacy, current) + corrupt.write_bytes(b"\xff\xfe") + expected = _holder_fields(acquired_at="2026-01-01T00:00:00Z") + valid.write_text(json.dumps(expected), encoding="utf-8") + + assert file_lock.lock_holder_liveness(target) == ( + file_lock.LOCK_HOLDER_LIVE, + expected, + ) + assert corrupt.read_bytes() == b"\xff\xfe" + + +def test_invalid_utf8_holder_preserves_timeout_incident_and_lock_recovery( + tmp_path: Path, +) -> None: + target = tmp_path / "state.json" + target.write_text("{}", encoding="utf-8") + process, _, _ = _start_stalled_holder(target) + holder = lock_holder_path(target) + try: + # Only corrupt disposable advisory metadata, never a live user lock. + holder.write_bytes(b"\xff\xfe") + with pytest.raises(LockAcquireTimeoutError) as raised: + with exclusive_file_lock(target, timeout_seconds=0): + pytest.fail("corrupt metadata bypassed the held kernel lock") + + payload = raised.value.to_payload() + assert payload["error_code"] == LOCK_ACQUIRE_TIMEOUT_ERROR_CODE + assert payload["incident_recorded"] is True + assert payload["lock_timeout"]["holder"] == {} + assert payload["operator_action"]["action"] == "inspect_lock_holder" + incidents = lock_incident_path(target).read_text(encoding="utf-8").splitlines() + assert len(incidents) == 1 + assert json.loads(incidents[0]) == payload["lock_timeout"] + assert holder.read_bytes() == b"\xff\xfe" + assert process.poll() is None + assert target.read_text(encoding="utf-8") == "{}" + finally: + _stop(process) + + # Once the real holder exits, normal acquisition replaces corrupt metadata. + with exclusive_file_lock(target, timeout_seconds=0): + assert file_lock.lock_holder_liveness(target)[0] == file_lock.LOCK_HOLDER_LIVE + assert file_lock.lock_holder_liveness(target)[0] == file_lock.LOCK_HOLDER_RELEASED