history: memory.jsonl store + flock (Phase 4 commit #1)

Phase 4 commit #1 per docs/PHASE4.md §12. Two file changes bundled
because R-B1 (flock for race-free single-writer enforcement) cannot
be deferred — adding it retroactively means reopening the memory
handle.

ffi/libc.lua extensions:
  - cdef flock(int fd, int op), open(...), lseek(int, long, int)
  - constants LOCK_EX=2, LOCK_NB=4, LOCK_UN=8
  - M.flock(fd, op) wrapper returning (true) on success or
    (false, errmsg) — errmsg is the strerror text so callers can
    surface "Resource temporarily unavailable" cleanly to the user.

history.lua additions (Phase 4 section appended at end):
  - M.open_memory(path) -> handle | nil, err
    Opens the file via libc.open(2) (need integer fd for flock —
    io.open's FILE* doesn't expose it), takes flock(LOCK_EX | LOCK_NB).
    Returns "memory.jsonl held by another aish process" on lock-held.
    Scans existing content for max id; caches as handle.next_id.
    Writes meta header on first creation (no id, ignored at load).
  - handle:add(kind, content, tags?, source?) -> id
    Assigns next id; appends one JSONL item with auto-timestamp.
    kind ∈ {fact, pref, context} enforced via assert.
  - handle:forget(target_id)
    Appends a tombstone {id, ts, kind:"forget", target}.
  - handle:close()
    Releases fd (flock auto-released on close).
  - M.load_memory(path) -> items_table
    Reads all lines, builds forget-target set from kind=="forget"
    entries, returns active items as an array sorted by ts desc.
    Items without id (meta header) silently dropped. Tombstones with
    non-matching targets are no-ops (N3 invariant).

Round-trip test passes:
  - open empty file → next_id=1
  - add 3 items → ids 1, 2, 3
  - forget id 2 (appends tombstone)
  - reopen → next_id correctly advances past the tombstone (=5)
  - load_memory → 2 active items (id 1 + id 3); tombstone resolved
  - lock-held detection: second open while first held → fails with
    "memory.jsonl held by another aish process" message
  - close releases the lock; reopen after release succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 04:52:03 +00:00
parent ffead3986c
commit 199dd87eaa
2 changed files with 205 additions and 3 deletions
+20
View File
@@ -33,6 +33,11 @@ void cfmakeraw(struct termios *tio);
/* poll for stdin↔master multiplex in executor. */
struct pollfd { int fd; short events; short revents; };
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
/* Phase 4: advisory file locking on memory.jsonl. Single-writer
enforcement via LOCK_EX | LOCK_NB — fail-fast if another aish
process holds the lock. */
int flock(int fd, int operation);
]]
local C = ffi.C
@@ -154,4 +159,19 @@ function M.poll(fds_arr, nfds, timeout_ms)
return C.poll(fds_arr, nfds, timeout_ms or -1)
end
-- ---------------------------------------------------------------- flock
-- Advisory file locking. Phase 4 uses LOCK_EX | LOCK_NB so a second
-- aish process opening the same memory.jsonl fails fast rather than
-- blocking. Lock is released on fd close or process exit.
M.LOCK_EX = 2
M.LOCK_NB = 4
M.LOCK_UN = 8
-- Returns: true on success; false, errmsg on failure (e.g. EWOULDBLOCK
-- when LOCK_NB is set and another holder exists).
function M.flock(fd, op)
if C.flock(fd, op) == 0 then return true end
return false, ffi.string(C.strerror(C.__errno_location()[0]))
end
return M