Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/reference/protocols/file-lock-acquisition-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion loopx/file_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions skills/loopx-self-repair/references/repair-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
67 changes: 67 additions & 0 deletions tests/test_file_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading