Skip to content

fix(memory): recover stale write locks instead of jamming a note forever - #1646

Open
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:fix/memory-lock-staleness
Open

fix(memory): recover stale write locks instead of jamming a note forever#1646
elhoim wants to merge 1 commit into
danielmiessler:mainfrom
elhoim:fix/memory-lock-staleness

Conversation

@elhoim

@elhoim elhoim commented Jul 26, 2026

Copy link
Copy Markdown

The problem

The per-note write lock in MemorySystem.ts was openSync(lockPath, "wx") with removal in a finally, and no staleness check, TTL, or liveness test.

Memory writes run inside hook-spawned subprocesses. If the harness times one out, or the machine loses power, after the lock file is created but before the finally runs, the .lock survives on disk. Every later write to that note then returns:

{ ok: false, code: "EWRITE_FAILED", message: "Lock held: <path>.lock" }

Forever. There is no self-heal, and nothing surfaces that error, so the outcome is silent permanent memory loss for that note. The operator is never told their memory system stopped accepting writes for that entry.

Two files in this tree already solve exactly this — DerivedSync.ts with a LOCK_STALE_MS constant and rmSync recovery, and PromptProcessing.hook.ts with a LOCK_STALE window. This site simply did not use the pattern.

The fix

Liveness first, age as fallback. The lock file now records the holder's pid, host and timestamp. On contention:

  1. Holder ran on this host and kill(pid, 0) says it is gone → recover immediately, no waiting out a TTL.
  2. Holder is provably alive → respect the lock.
  3. Holder cannot be verified (different host, unreadable or corrupt lock file) → fall back to age, and recover only past LOCK_STALE_MS.

Every unknown resolves toward "assume alive", so the failure mode is a delayed write rather than two writers corrupting one note. LOCK_STALE_MS matches DerivedSync.ts's constant rather than introducing a third number.

Failures stop being silent. Every lock failure and every recovery is written to MEMORY/OBSERVABILITY/memory-locks.jsonl and to stderr, with a reason code: holder-process-gone, holder-alive, unverifiable-holder-within-ttl, expired-unverifiable-holder. An operator can now distinguish "recovered from a crash" from "nothing ever went wrong" from "still jammed".

Tests

test/tools/MemorySystem.test.ts, 8 cases:

8 pass
0 fail
28 expect() calls

Separately verified with an independent probe. The dangerous direction here is not failing to recover a dead lock — it is breaking a live one, which would put two writers on the same note — so that case got equal weight:

✓ dead holder on this host → recovered      (holder-process-gone)
✓ live holder, fresh → refused              (holder-alive)
✓ corrupt lock, fresh → refused             (unverifiable-holder-within-ttl, fails safe)
✓ corrupt lock, aged past window → recovered (expired-unverifiable-holder)
✓ other-host holder, fresh → refused        (unverifiable)
✓ no lock → acquired
✓ release removes the lock file

Note for review

A holder that is alive but has held the lock longer than LOCK_STALE_MS is still broken, on the reasoning that a five-minute append is itself a failure. That is a deliberate trade-off rather than an oversight, and it is the one case where this can interrupt a genuinely running writer. Worth a second opinion on the window.

MemorySystem.appendToTierBFile took a `<note>.lock` with O_EXCL and removed
it in a `finally`, with no staleness check. Memory writes run in hook-spawned
subprocesses; one killed after the lockfile was created but before the finally
ran left the lock on disk permanently, so every later write to that note
returned EWRITE_FAILED "Lock held" forever. Nothing reported it, so the note
silently stopped accepting memory.

Add MemoryLock.ts, a crash-safe lock that decides staleness on evidence:
the holder stamps pid + host into the lockfile, and a contender that finds
the lock held probes the holder with kill(pid, 0). A holder the kernel says
is gone is stale immediately, no TTL wait. When liveness cannot be
established -- empty or unparseable stamp, or a holder recorded on another
host -- it falls back to the same LOCK_STALE_MS age rule DerivedSync.ts
already uses. Every ambiguous probe resolves toward "alive", so a live
holder is respected rather than trampled by a second concurrent writer.
Recovery breaks the lock by renaming it aside and re-creating with O_EXCL,
re-checking the inode it judged stale, so concurrent recoverers cannot both
end up holding it.

Recovery and refusal are both appended to
MEMORY/OBSERVABILITY/memory-locks.jsonl and echoed to stderr, so a jammed
note is now visible and distinguishable from a clean run.

Tests: test/tools/MemorySystem.test.ts -- 6 of the 8 fail against the
previous implementation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant