From 1059798b533c6354693ebc8ae3c4869bfe20f0af Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 14 Jun 2026 21:28:07 +0800 Subject: [PATCH] =?UTF-8?q?test(reve):=20R3=20=E2=80=94=20soul=20read-modi?= =?UTF-8?q?fy-write=20loses=20no=20entry=20under=20concurrency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN_REVE R3 acceptance "两进程并发写 journal/emotions → 无丢条". lock.test.ts proves the lock PRIMITIVE is mutually exclusive; this is the end-to-end proof that the real soul RMW (applyEmotionDelta: read emotions → append event → persist, under withSoulLock) drops no append under concurrency. - 25 concurrent applyEmotionDelta → all 25 distinct events present (without the lock, the events-array RMW would clobber on last-writer-wins). - maxEvents cap holds under concurrency (bounded, no loss of the invariant). Test-only (LISA_HOME-tmp + dynamic import). Full suite green; build clean. Assessment of the rest of this batch (documented, not built): D3 cwd-lock already exists (activeAgentInCwd in dispatch_agent, with force override); D3 capability routing is partially served by the advisor; the "steer a RUNNING agent" command loop is architecturally limited (detached claude-code agents expose no mid-run control channel for approval injection); R4 data model is done+tested (desire-pursuit.test.ts) with the island clickable prompt the low-pri (0.11) remainder. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/soul/concurrency.test.ts | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/soul/concurrency.test.ts diff --git a/src/soul/concurrency.test.ts b/src/soul/concurrency.test.ts new file mode 100644 index 0000000..0f73390 --- /dev/null +++ b/src/soul/concurrency.test.ts @@ -0,0 +1,60 @@ +import { test, describe, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// R3 (PLAN_REVE) end-to-end: lock.test.ts proves the lock PRIMITIVE is mutually +// exclusive; this proves the real soul read-modify-write (applyEmotionDelta: +// read emotions → append an event → persist, under withSoulLock) loses NO append +// under concurrency. Without the lock, concurrent RMW would clobber the events +// array (last-writer-wins) and drop events. +// +// SOUL_DIR derives from LISA_HOME at import, so set a tmp home before importing +// (dynamic import); node --test isolates each file in its own process. +let store: typeof import("./store.js"); +let home: string; +before(async () => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-soul-conc-")); + process.env.LISA_HOME = home; + store = await import("./store.js"); + await store.ensureSoulDirs(); +}); +after(() => { + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe("soul lock — concurrent writes lose no entry (R3)", () => { + test("N concurrent applyEmotionDelta calls all land (no lost append)", async () => { + const N = 25; + await Promise.all( + Array.from({ length: N }, (_, i) => + store.applyEmotionDelta({ + emotion: "curiosity", + delta: 0.01, + trigger: `t${i}`, + maxEvents: 1000, + }), + ), + ); + const state = await store.readEmotions(); + assert.equal(state.events?.length, N, "every concurrent append survived the read-modify-write"); + // Distinct triggers ⇒ no event was overwritten by a racing writer. + const triggers = new Set((state.events ?? []).map((e) => e.trigger)); + assert.equal(triggers.size, N, "all N distinct events present"); + }); + + test("maxEvents cap still holds under concurrency (bounded, newest kept)", async () => { + // Reset to a clean emotions file for this case. + fs.rmSync(path.join(home, "soul", "emotions.json"), { force: true }); + const N = 30; + const CAP = 10; + await Promise.all( + Array.from({ length: N }, (_, i) => + store.applyEmotionDelta({ emotion: "focus", delta: 0.001, trigger: `c${i}`, maxEvents: CAP }), + ), + ); + const state = await store.readEmotions(); + assert.equal(state.events?.length, CAP, "events stay capped under concurrency (no unbounded growth, no loss of the cap invariant)"); + }); +});