diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index d9e39d5cb2..5b85e346c5 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -6,6 +6,7 @@ Read the context documents relevant to the code or decision under review. Do not | --- | --- | --- | | Session Runtime and Client Contract | [`CONTEXT.md`](CONTEXT.md) | `packages/opencode/src/session`, `packages/opencode/src/system-context`, `packages/protocol`, `packages/client`, `packages/sdk` | | Workflow Orchestration | [`packages/opencode/src/dag/CONTEXT.md`](packages/opencode/src/dag/CONTEXT.md) | `packages/opencode/src/dag`, workflow tool, DAG template validation and packaging | +| Project Memory | [`packages/opencode/src/memory/CONTEXT.md`](packages/opencode/src/memory/CONTEXT.md) | `packages/opencode/src/memory`, Memory-owned worktree lifecycle integration | ## Contexts created lazily diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md new file mode 100644 index 0000000000..aa5fc070b6 --- /dev/null +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -0,0 +1,275 @@ +# Memory Authority Redo Plan — from `d7b011738` + +Date: 2026-08-12. Worktree: `/private/tmp/oc-dag-wt-lifecycle` (branch `chore/worktree-lifecycle-audit`). +Status: ADR-0004 **Rejected**; Occam path (§10) **adopted and implemented** (#1 done; #2 deferred by user; #4 closed as non-gap; **#3 was later reopened by the MEM-PR01 review and fixed** — the "ABBA unreachable" claim was falsified, see the #3 row). The two-round MEM-PR01 review then landed fixes/pins #5–#18 below. Remaining items are user decisions (#2 typed-error cascade, source-Home retention/GC). + +## 0. Why this plan exists + +The prior ProjectMemoryAuthority redesign (~20 untracked files: `authority-*.ts`, `destruction-guard.ts`, `project/identity.ts`, `project/reference-adapter.ts`, ADR-0004, CONTEXT update, ~4 authority test files, crash-harness fixtures) plus the 1A keystone/harness fixes lived **only as uncommitted working-tree state** in a `/private/tmp` worktree. `/private/tmp` was cleaned; `git fsck` found no dangling objects and the branch was never pushed, so that work is **gone**. Recoverable: the committed baseline `d7b011738` ("fix(opencode): make project memory process safe") — the Iteration-1 process-safe memory foundation. This plan reconstructs the lost redesign **faithfully** (from the approved ADR-0004 / CONTEXT / redesign-decision spec, retained in design memory) on top of that baseline, phased so each stage is independently committable. + +## 1. Baseline at `d7b011738` (surveyed — what exists, all tests GREEN) + +**Modules** (`packages/opencode/src/memory/`): +- `home.ts` MemoryHome — paths only: `directory/topics/manifest/generations` + shared `locks` dir. No policy/retirements/aliases paths yet. +- `store.ts` MemoryStore — generation+manifest persistence. **Topics are versioned** (revision int, named generation, atomic temp→rename→manifest). **Policy is NOT in the Home** — it lives in worktree/global `.opencode/memory.jsonc|json` via MemoryConfig, no generation/revision/CAS. `commit(expectedRevision)` CAS exists but is **unused in src/**; `updateTopics` (hides revision bump) is the live writer. Strict `inspectTopics` vs lenient `readTopics`. +- `lock.ts` MemoryLock — **in-process `KeyedMutex` only**, surface is just `withProject(projectID)`. NO canonical/Held/FenceClosed. (Controller-only; 4 sites in `memory.ts`.) +- `admission.ts` MemoryAdmission — the legacy-input seam: `ensure`/`invalidate`, caches only conflict-free results, nests `memory-admission:` → `memory-project:` flock. +- `identity-migration.ts` MemoryIdentityMigration — **only `migrateHome(oldID,newID)`** (no prepareHome/migrateIdentity). Fast-path `fs.rename(source,target)`; merge-path merge-then-`fs.remove(source)`. **Both DELETE source.** Typed `ConflictError`/`InvalidHomeError` exist. +- `config.ts` MemoryConfig, `paths.ts`, `file.ts` (atomicWrite), `schema.ts`, `model.ts`, `prompts.ts`. + +**Two lock systems (non-overlapping):** MemoryLock (in-process KeyedMutex) vs `EffectFlock` (`core/util/effect-flock.ts`, cross-process mkdir-dir locks, `STALE_MS=60s`, heartbeat ~20s, breaker stale-takeover, witness = Scope lifetime). + +**Project identity upgrade** (`project/project.ts:217-314` `fromDirectory`; `migrateProjectId` `:148-197`): resolve → `identityMigration.migrate(old,new)` (FIRST durable; **`.orDie` collapses typed errors to defects**) → DB txn (copy Project row; `delete ProjectDirectory`; repoint `Session`+`Workspace` FK; `delete ProjectTable old`) → upsert new Project row → Session global→new → saveProjectDirectory → `emitUpdated` (in-memory) → `projectV2.commit` (writes `/opencode` cache, LAST durable). + +**5 Project-owned FK tables** (`ON DELETE CASCADE`): `session`✅repointed, `workspace`✅repointed, `project_directory`(deleted+reinserted), `workflow`❌**cascade-lost**, `permission`❌**cascade-lost**. ⇒ every root→remote upgrade today silently destroys all DAG workflows + saved permissions. + +**Worktree** reset/remove call only `memoryAdmission.invalidate`→`ensure` (gated by serviceOption), pure-FS `hasUnresolvedLegacyMemory` fallback; errors stringified into `Remove/ResetFailedError`. + +**3 tests encode "source Home deleted"** (`memory-persistence:166,194`; `project.test:325`) — backed by the single `fs.remove(source)` at `identity-migration.ts` tail. 4 tests encode "preserve on failure" (must stay green). `MemoryLock.withProject` is untested. + +## 2. The gap (what the redo must build) — Gap IDs + +| Gap | Baseline failure | Redo delivers | +|---|---|---| +| `MEM-ID-01` | ID change migrates Memory + only 3/5 FK; old ID can re-fork; source Home destroyed | One `retireIdentity` migrates Memory + **all 5** FK atomically; source Home **preserved** (non-authoritative); old ID routes to successor | +| `MEM-LOCK-02` | In-process lock only; migration `old→new` flock nesting not proven vs reverse; no canonical recheck | Cross-process sorted flock order (no ABBA); routine = one canonical project flock + recheck | +| `MEM-CRASH-06` | Migration crash (rename/remove mid-flight) unrecovered; no journal | Forward-only journal `Requested→TargetPrepared→IdentityPublished→ReferencesRetired→CleanupPending`; crash = forward recovery from durable evidence | +| `MEM-REF-07` | `workflow`+`permission` cascade-lost | `ProjectReferenceAdapter` migrates **all** FK in one immediate txn; new FK ⇒ contract test fails | +| `MEM-BOOT-09` | (mostly closed) Memory admission needs durable Project row | fail-closed `ProjectUnavailable` when no durable row | +| `MEM-ATOMIC-10` | Topics versioned, Policy not — half-commit window | Topics+Policy share **one generation + one manifest + one opaque revision** | +| `MEM-ID-AUTO-11` (1C) | `fromDirectory` uses legacy `.orDie` migrateHome bypass | `fromDirectory` → `authority.retireIdentity`, typed errors, retirement before successor upsert/cache commit/return | +| `MEM-ADMIT-03`/`RET-04` | Worktree reset/remove trusts process-local admission cache | `ProjectMemoryDestructionGuard` sealed intent; no-cache rescan of primary+all worktrees | + +## 3. Reconstructed design (the authority spec — faithful to ADR-0004) + +**Public seam** (application callers see ONLY this): +```ts +interface ProjectMemoryAuthority { + readMemory(projectID): Effect + changeMemory(revision, changes: NonEmpty): Effect + retireIdentity(request: IdentityRetirement): Effect +} +``` +- `Revision` opaque, one-shot, caller-unforgeable, binds canonical identity + Topics revision + Policy fingerprint + topology + admission fingerprint. +- `readMemory` performs runtime admission internally (no `admit→read` composition). +- `changeMemory` accepts data `Change`s (`replace_topics|mark_matched|set_policy`), not Effect callbacks. +- `retireIdentity` is the **only** identity-migration entry. + +**Atomic Topics+Policy**: extend `MemoryStore` with `readAuthoritySnapshotInFence`/`commitAuthorityInFence`/`writeAuthoritySnapshotInFence` — `writeSnapshot` writes `policy.jsonc` **into the same generation dir** as topic YAML; manifest rename is the single publish point; strict topic read tolerates the co-tenant `policy.jsonc`. + +**ProjectIdentity** (`project/identity.ts`): `canonical(id)` (resolve alias chain, cycle→error), `revision`, `recordAlias(old,new)` (immutable tombstone, rejects retarget/retired-successor/cycle). Alias file is a DB-external durable ledger. + +**IdentityLedgerAdapter** (`authority-journal.ts` + `authority-journal-store.ts`): journal keyed by `request_id`, unique `source_id` per in-flight; `save` rejects rebind + regression; phase enum `Requested→TargetPrepared→IdentityPublished→ReferencesRetired→CleanupPending→Completed`. + +**Retirement merge rules** (`authority-retirement-rules.ts`): empty/empty→empty gen; non-empty/empty→copy source; empty/non-empty→keep successor; both→deterministic union (Topics by id, Policy unique, `revision=max+1`); same-id-differing-content / Policy-differ / corrupt → `RetirementBlocked` zero-change. + +**ProjectMemoryAuthorityLock** (`authority-lock.ts`): wraps EffectFlock. `canonical(id,use)`: resolve→lock one `memory-project:`→recheck→retry-on-change. `retirement(source,successor,use)`: `sorted({source,successor})` project flocks. No dynamic extension; rolling-upgrade-compatible key order. + +**ProjectReferenceAdapter** (`project/reference-adapter.ts`): dynamically enumerate all `project_id` FK tables; migrate source→successor in ONE `immediate` txn; contract test fails if a new FK table appears. + +**ProjectMemoryDestructionGuard** (`destruction-guard.ts`): sealed durable intent `{request_id,requested_project,identity_revision,normalized_target,action,topology_fingerprint,candidate_fingerprint}`; execute/reconcile always join/recover retirement → re-resolve → no-cache rescan primary+all worktrees → publish valid candidates → one fixed action adapter; ambiguous postcondition = fail-closed. + +**Lock order**: retirement reads ledger unlocked → `sorted(source,successor)` project flocks → identity-ledger flock → revalidate. Routine: join/recover touched retirement → resolve → one project flock → resolve → retry. (Recovery before routine project lock; routine never project→ledger.) + +**Crash semantics**: commit point = immutable tombstone. Pre-tombstone: source authoritative. Post-tombstone: successor authoritative, old revision invalid. Each public command first joins/recovers touched journals. Source Home preserved; cleanup is retryable, `CleanupPending` allowed. + +**Layer wiring (BOTH systems)**: `defaultLayer` self-provides all sub-services (mirror `memory/memory.ts:581-603`); `.node` re-lists them; register in `app-runtime.ts` AppLayer **and** `server/routes/instance/httpapi/server.ts:210-287` app group (else HTTP path silently no-ops). + +## 4. Phased redo — each phase is one commit on the branch + +Order is dependency-driven; each phase has a Green proof + a mutation gate. + +- **P1 — Foundation: ProjectIdentity + atomic Topics+Policy store API.** + Files: `project/identity.ts`; extend `memory/store.ts` (authority snapshot read/commit/write, `policy.jsonc` co-tenant strict-read), `memory/home.ts` (add `policy`/`retirements`/`aliases` paths). + Green: new unit tests for identity canonical/alias + atomic Topics+Policy commit/read (crash-injection Red: no topics-new/policy-old). Mutation: revert `policy.jsonc` co-tenant allow ⇒ provenance Red. + +- **P2 — Authority skeleton + Lock + Repository.** + Files: `memory/authority.ts` (seam + typed errors + Revision), `authority-lock.ts`, `authority-repository.ts` (inspect/inspectIfDurable/inspectHome via authority store API), `authority-live.ts` (readMemory/changeMemory). + Green: repository+lock unit tests; CAS revision invalidation on Topics/Policy change. Mutation: changeMemory without atomic commit ⇒ half-commit Red. + +- **P3 — Retirement journal + rules + process (state machine).** + Files: `authority-journal.ts`, `authority-journal-store.ts`, `authority-retirement-rules.ts`, `authority-retirement.ts` (retireLocked: observe→prepare→publish→references→cleanup). + Green: monotonic phase transition; merge-rule table; idempotent same-request; same-source→other-successor = conflict; reverse/retarget/independent-project rejected zero-change. + +- **P4 — Reference adapter (all 5 FK).** + Files: `project/reference-adapter.ts`. + Green: migrate all 5 FK in one txn; source=0/target-no-dup post-migrate; **add a 6th temp FK in a test ⇒ contract test fails** (MEM-REF-07 mutation). + +- **P5 — Wire authority into both Layer systems.** + Files: `authority-live.ts` aggregator defaultLayer+node; `app-runtime.ts`; `server.ts` app group; add `.node` to consumers that need it. + Green: integration test that authority reaches the HTTP path (not just that layers build). Mutation: drop from server app group ⇒ HTTP no-op Red. + +- **P6 — `Project.fromDirectory` cutover + typed-error boundaries (this is "1C", `MEM-ID-AUTO-11`).** + Files: `project/project.ts` (replace `migrateProjectId`→`authority.retireIdentity`, retirement BEFORE successor upsert + cache commit + return; stable internal request identity); delete legacy `project/identity-migration.ts` application seam; `project/instance-store.ts` (thread retirement typed errors through Deferred); `server/routes/instance/httpapi/handlers/project.ts` (map `Failure|AdmissionConflict|RetirementConflict` at HTTP boundary); flip the 3 "source Home deleted" assertions → preserved. + Green: real `fromDirectory` root→remote produces durable journal ≥ CleanupPending; cache not switched before authority success; metadata conflict ⇒ stable typed error, zero side-write; retry = same request identity monotonic; defaultLayer + LayerNode both use authority; source Home exists but non-authoritative. Mutation gates (5): drop the call / move cache-commit early / randomize request identity / `orDie` the typed error / split fixture instances. + +- **P7 — Crash harness + forward-recovery + reverse-retirement (this is "1A", `MEM-CRASH-06`/`MEM-LOCK-02`).** + Files: `test/fixture/project-memory-authority-{launcher,worker,bunfig}.ts`, `test/memory/project-memory-authority.test.ts`, `memory-authority-journal/rules.test.ts`. + Green: harness contract (launcher-ready→go→worker-ready→phase-stopped→SIGKILL, file-per-state, self-stop inside worker, reclaim stale 60s flock after kill); per-phase crash→new-process recovery; reverse retirement no-ABBA (both exit ≤10s, exactly one success/one structured failure, full stdout/stderr captured). + +- **P8 — Destruction guard + worktree migration + remove parallel authorities (1B/1D scope).** + Files: `memory/destruction-guard.ts`; `worktree/index.ts` reset/remove → guard (drop direct `MemoryAdmission.ensure/invalidate`); downgrade `MemoryLock` public Service + `MemoryAdmission`/`MemoryIdentityMigration` to internal adapters; final `rg` bypass audit + call graph. + +## 5. Verification & discipline (every phase) +- From package dir only: `cd packages/opencode && bun test …`; `bun typecheck`; `packages/core && bun typecheck`; repo-root `git diff --check`. +- Real SQLite, real tmpdirs, real git worktrees, real subprocesses; no fixed-sleep timing; no deleted assertions / no `.skip`/`.todo` to go green. +- Each phase = one conventional commit (`feat(memory): …`) on the branch (mitigates /tmp loss). +- Introduced P1/P2 per phase must close before the phase commits. + +## 6. Open items for the user (decide before loop) +1. Confirm the 8-phase structure + that P6 = "1C" and P7 = "1A" (the original iteration labels). +2. Loop cadence/scope: drive P1→P8 in order (one phase per fire), commit each, pause after P6 (1C) for review as the original 1C task required — or different? +3. The 3 "source Home deleted" assertion flips (P6) and the source-preserve semantics are a product decision restated in ADR-0004 — confirm acceptable to re-apply. +4. Should P1 also recreate ADR-0004 + the CONTEXT authority-glossary update (lost) as the design-of-record before code? + +--- + +## 7. Plan-review findings (ultracode adversarial workflow, 5 critics, 2026-08-12) — REVISES §3–§6 + +The adversarial review surfaced **blocking issues**. Per the task rule "spec-gap 必须暂停并记录所需产品决策", P1 does NOT start until §7.A is resolved + P0 is approved. + +### 7.A. BLOCKING product decisions (need user sign-off — these are NEW surface, not verifiable reconstruction) +The lost ADR-0004 is **unrecoverable** (repo's only ADR-0004 is an unrelated DAG lock-timeout ADR; `git fsck` empty; nothing pushed). "Reconstruct from design memory" is indistinguishable from "invent." The following core decisions are genuinely the user's: + +- **D1 — Policy source-of-truth (P1).** Moving Policy into the per-project Home generation as versioned/CAS'd **reverses ADR-0001's live decision** ("Project configuration is resolved from the Project's primary directory so it remains user-editable without creating sandbox-specific policy"). Decide: (a) Policy-in-Home + supersede ADR-0001 (controller-owned, atomic, not user-editable in place), or (b) keep Policy in `.opencode/memory.jsonc` (user-editable, NOT versioned/CAS'd) and P1 collapses to Topics-only atomicity. **Also**: how is GLOBAL Policy represented (it spans projects; no per-project Home)? +- **D2 — Source-Home preserve + retention (P3/P6/P7).** "Source Home preserved, non-authoritative" introduces a NEW class of non-authoritative artifact; ADR-0001/0002 require a "separate Project Memory retention policy" BEFORE any such artifact may exist. Decide: (a) define the retention/GC policy for retired-identity Homes in the recreated ADR-0004, or (b) revert to baseline migrate-then-remove (source deleted) — then the 3 "source Home deleted" assertions stay and P6/P7 preserve-assertions drop. +- **D3 — Retirement-as-merge / alias-lineage (P3).** "Old ID routes to successor" + immutable `recordAlias` tombstone + `canonical()` alias-chain + deterministic-union merge of two Projects' Memory+identity+FK is, in substance, the two **explicitly-forbidden** decisions (Project Merge, ProjectLineageID) relabeled. Decide: (a) approve a lineage/merge system with the exact merge rules + alias permanence, or (b) collapse to migrate-and-retire with conflict-fail-closed (closer to ADR-0001). +- **D4 — New public surface to confirm** (not in any recoverable spec): (i) the 6-phase forward-only journal machine `Requested→TargetPrepared→IdentityPublished→ReferencesRetired→CleanupPending→Completed` (ADR-0002 only states a single ordering invariant); (ii) the opaque Revision fingerprint composition ("canonical identity + Topics revision + Policy fingerprint + topology fingerprint + admission fingerprint" — topology/admission fingerprints are undefined); (iii) the `changeMemory` data-Change algebra (`replace_topics|mark_matched|set_policy`) replacing the baseline callback `updateTopics`. + +**⇒ NEW PHASE P0 (mandatory, before P1):** Recreate `packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md` + the CONTEXT authority-glossary update **as a written, committed design-of-record** that resolves D1–D4 explicitly. P0 Green = the user reviews + approves the recreated ADR-0004 line-by-line. No subsequent phase may claim a "faithful" Green until P0 is approved. (This demotes old open-item #4 from optional to blocking precondition.) + +### 7.B. Technical revisions (from completeness/phase-ordering/mutation/baseline critics) +- **Reorder: P4 before P3** (or inject `migrateReferences`/`cleanup` as Effect seams in P3, wired in P6). retireLocked's ReferencesRetired→CleanupPending transitions cannot call a ProjectReferenceAdapter that doesn't exist yet. +- **P4 per-table FK migration rules (MEM-REF-07):** `session`,`workspace` → `UPDATE project_id`; `permission` → has `uniqueIndex(project_id,action,resource)` (permission/sql.ts:19) ⇒ DELETE source rows whose `(action,resource)` already exists on successor, then UPDATE the rest (else SQLITE_CONSTRAINT_UNIQUE); `project_directory` → composite `primaryKey(project_id,directory)` ⇒ delete+reinsert preserving `type` (and `strategy`), with a test where successor already has an overlapping directory; `workflow` → `UPDATE project_id`. Add a P4 contract test: a 6th temp `project_id` FK table ⇒ test fails (MEM-REF-07 mutation). +- **P6 file scope:** add `test/project/project.test.ts` (imports `ProjectIdentityMigration` at :30; layer helpers at :87,:101,:119) and `test/memory/memory-persistence.test.ts` to scope, else P6 won't compile (module deleted) / won't be coherent. **P6 flips only `project.test:325`** (the fromDirectory path); the two direct-`migrateHome` assertions (`memory-persistence:166,:194`) are reachable only via `memory/identity-migration.ts` (downgraded in P8) — either leave them asserting `deleted` until P8, or rewrite those two cases to drive `authority.retireIdentity`. +- **Mutation gates (fix mismatches + gaps):** + - P1 needs TWO: read-side (`revert policy.jsonc co-tenant allow ⇒ strict-read Red`) AND write-side (`publish policy via a separate rename outside the manifest ⇒ crash-injection topics-new/policy-old Red`). + - P2: relabel to `changeMemory that doesn't bump revision on set_policy ⇒ stale-revision Red`, AND add a **changeMemory-level** crash-injection test (store-API atomicity alone doesn't prove the caller uses it atomically). + - P6 `orDie` gate only works if the conflict test asserts the error **type** (`Effect.catchTag("RetirementConflict")` / `Cause._tag==="Fail"` + schema `_tag`), NOT `Exit._tag==="Failure"` (baseline project.test:375-377 uses the weak form — copying it = a tautology gate). + - P3 mutation: `allow phase regression ⇒ monotonic-transition Red; rebind source_id ⇒ same-source-conflict Red`. + - P7 mutation: `drop sorted() lock order ⇒ reverse-retirement ABBA (both >10s) Red; skip joinRecovery on cold start ⇒ crash-recovery Red`. + - P6 `split fixture instances` is ambiguous — replace with `fromDirectory resolves MemoryHome/ledger from Global.Path.data instead of the wired Service ⇒ durable-journal-preserved Red`. +- **P8 add Green+Mutation:** `worktree reset/remove with a sibling's new legacy-memory input fails closed via guard (no source Home touched); ambiguous topology ⇒ fail-closed; no-cache rescan observes input added after invalidate. Mutation: re-trust process-local admission cache ⇒ wrong-destroy Red.` +- **inspectHome allow-list** (`identity-migration.ts:43-54`, invoked at :69-70): only accepts `topics/generations/manifest.json`. Once Homes carry `policy.jsonc` (+ ledger paths), any migrateHome MERGE over a modern Home fail-closes with `InvalidHomeError`. P1 must either extend the allow-list or mark inspectHome dead post-P6. +- **Pin ledger locations (global, not per-project):** journal at `home.retirements/.json`, aliases at `home.aliases` (= `/memory/project-aliases.json`), destructions at `home.destructions/...` — all GLOBAL under `/memory/`, reachable from any (retired) id. Add a test that a fresh process finds the journal/alias after source Home is non-authoritative. +- **Phase enum canonical = 6 phases** (add `Completed` terminal); reconcile §2 Gap table (5) with §3 (6) — use 6 everywhere. Clarify `CleanupPending` is a retryable-resting state; `Completed` reached only after cleanup (not required this redo since source-Home cleanup is deferred/excluded). +- **Path precision:** AppLayer is at `packages/opencode/src/effect/app-runtime.ts` (alias `@/effect/app-runtime`), `Memory.defaultLayer` at :87 — insert the Authority aggregator's defaultLayer there alongside it. +- **MEM-BOOT-09:** assign to P2 — `authority-live.readMemory` yields a typed `Failure` (ProjectUnavailable) when no durable ProjectV2 row; + test injecting a missing row. (Or cite exact baseline file:line that already fails closed.) + +### 7.C. Revised phase order +**P0** (spec, user-approved) → **P1** (identity + atomic store API) → **P2** (authority skeleton + lock + repository; MEM-BOOT-09) → **P4** (reference adapter, all 5 FK) → **P3** (retirement journal + rules + state machine, using P4's adapter or injected seams) → **P5** (dual Layer wiring) → **P6** (fromDirectory cutover = 1C) → **P7** (crash harness = 1A) → **P8** (destruction guard + worktree + remove parallel authorities). + +## 8. Resume protocol for a fresh session (read FIRST) +1. `cd /private/tmp/oc-dag-wt-lifecycle` (branch `chore/worktree-lifecycle-audit`). If missing, `git worktree add` it from the branch (it lives in /tmp and may be cleaned — each phase commits, so history is safe). +2. `git log --oneline -8` to see which phases are committed; read this plan doc fully (esp. §7). +3. If **P0 not approved yet**: recreate ADR-0004 + CONTEXT resolving §7.A D1–D4, present to user, **PAUSE**. Do not start P1. +4. Else advance the next un-committed phase (§7.C order). Per phase: re-read exact baseline → implement → `cd packages/opencode && bun typecheck` → targeted tests (package dir only) → mutation gate → `git commit` → update this doc's phase status. +5. Exclusions: no Goal/DAG/DAG-config/CI, no push/PR/dev→main, no source-Home GC. Tests never from repo root. + +## 9. Phase status (living tracker) + +| Phase | Status | Commit | Notes | +|---|---|---|---| +| P0 — recreate ADR-0004 + CONTEXT | **Proposed (awaiting user approval)** | (this commit) | ADR-0004 + CONTEXT.md written; resolves D1–D4; encodes user principles (shared/no-fork/imperceptible). User must approve before P1. | +| P1 — identity + atomic store API | pending | — | blocked on P0 approval | +| P2 — authority skeleton + lock + repository (MEM-BOOT-09) | pending | — | | +| P4 — reference adapter (all 5 FK) | pending | — | reorder before P3 | +| P3 — retirement journal + rules + state machine | pending | — | uses P4 adapter (or injected seams) | +| P5 — dual Layer wiring | pending | — | effect/app-runtime.ts + server.ts app group | +| P6 — fromDirectory cutover (1C, MEM-ID-AUTO-11) | pending | — | + project.test.ts/memory-persistence.test.ts scope; Spec/Standards review + pause | +| P7 — crash harness (1A, MEM-CRASH-06/LOCK-02) | pending | — | | +| P8 — destruction guard + worktree + remove parallel authorities | pending | — | + Green/mutation per §7.B | + +> **§4/§7.C/§9 (the elaborate 8-phase redesign) are SUPERSEDED by §10 below.** Kept for history. + +## 10. Occam minimal path (ADOPTED 2026-08-12 — the actual work) + +After the survey + ultracode adversarial review, the user applied Occam's Razor ("一切从简"): the elaborate ProjectMemoryAuthority / retirement journal / alias tombstone / opaque Revision / destruction guard / 8-phase plan is over-engineered for the real needs. Confirmed user principles: **one shared Memory per Project (worktrees share it, hold none of their own); Memory never forks; identity upgrade is imperceptible; no data loss.** Shared + no-fork are already satisfied by the baseline d7b011738 (Home follows identity). So the work collapses to small in-place fixes on the existing seams. **ADR-0004 is Rejected.** + +| Fix | Gap | Status | Commit | +|---|---|---|---| +| **#1** Repoint `workflow`+`permission` FK on identity upgrade (was `ON DELETE CASCADE` silent data loss) | MEM-REF-07 | ✅ done (mutation-proven) | `ec6972b22` | +| **#2** Remove `.orDie` on the migration seam (`memory/identity-migration.ts` via `project/identity-migration.ts:19`); propagate `ConflictError`/`InvalidHomeError` as typed errors to the instance-store Deferred + HTTP project handler boundary | typed-error invariant (#5) | ⏸ **deferred** — full typed-propagation is a multi-file cascade (seam→migrateProjectId→fromDirectory Interface→instance-store load/reload/Deferred→HTTP) for a marginal gain (HTTP 409 vs 500 on a rare migration conflict; `.orDie` already preserves `ConflictError` in the Die cause, so it stays diagnosable). Awaits user decision: Occam cut vs invariant #5. | — | +| **#3** `migrateHome` deadlock-freedom for opposite-direction migrations | MEM-LOCK-02 | ✅ **fixed (MEM-PR01-R1-24, P2; Red = 20 s deadlock timeout, Green = ms)**: the review falsified the one-way-retirement claim — a changed origin URL yields `previous=remote(A), current=remote(B)` (resolve: `remote ?? previous`), so opposite-direction pairs are reachable. Sorted pre-acquisition is impossible (the flock is non-reentrant; `updateTopics` re-locks the target inside). Fix by construction: a dedicated sorted **pair lock** serializes the two directions, and the merge is restructured into three phases that never hold more than one `memory-project:*` lock at a time (snapshot source → merge via target-locked `updateTopics` → verify-and-remove source; if the source changed meanwhile, fail closed with retryable `SourceChangedError`, nothing removed). Crash-retry convergence pinned by MEM-PR01-R1-13. | this slice | +| **#4** Destructive admission (worktree reset/remove) **force-rescans**, never trusts the process-local admission cache | MEM-ADMIT-03 / RET-04 | ✅ **closed — already handled** | — | +| **#5** Memory is **fail-closed inert under `ProjectV2.ID.global`**: `configuration()` returns undefined while the project has no identity of its own | MEM-PR01-00 (P1, two-round review 2026-08-12) | ✅ done (Red→Green→mutation) | `d6abdf466` | +| **#6** `inspectHome` tolerates the store's own `atomicWrite` residue (`manifest.json...tmp`); foreign files still fail closed | MEM-PR01-R1-12 (P2) | ✅ done (Red→Green→mutation) | this slice | +| **#7** Identity-merge conflict check compares **content only** — controller metadata drift (`last_matched_at`/`match_count`/`revision`/`updated_at` from `markMatched`) is not a conflict; real content differences still are | MEM-PR01-R1-15 (P2) | ✅ done (Red→Green→mutation) | this slice | +| **#8** Permission FK repoint is **uniqueness-collision-safe**: on `(project_id, action, resource)` collision the successor row wins and the duplicate old row is dropped; disjoint rows still repoint. The previous bulk UPDATE violated the unique index and wedged the whole upgrade transaction | MEM-PR01-R1-11 (P2) | ✅ done (Red→Green→mutation) | this slice | + +**#5 rationale (product decision, Occam route):** every commit-less repository resolves to the SAME shared `global` identity (`core/project.ts` resolve: `id = remote ?? previous ?? root`, and `global` is never cached because `project.ts` skips the identity commit for it). With Home keyed by project ID, an active Memory under `global` would (a) share one Home across all commit-less repositories on the machine (cross-repo topic leakage) and (b) be permanently orphaned at the first commit — identity moves global→root/remote but `migrateProjectId` never migrates away from global (explicit guard; `previous` can never be global). The migration option is structurally infeasible (topics in the shared bucket carry no per-repository provenance), so the minimal correct behavior is **inertness**: memory activates once the repository gains a real identity. One guard at the single activation seam (`Memory.configuration`, which active/prepare/search/checkpoint/setEnabled all funnel through); no new authority, no new machinery. Pre-fix global-bucket contents remain orphans — recovery belongs to the deferred retention/GC decision. Note: this decision constrains the spec — the `lightweight-project-memory` spec has no identity-tier requirement today (review finding MEM-PR01-R1-14); when openspec changes land, add "memory is inert until the project resolves a non-global identity". + +**#3 rationale — FALSIFIED (kept for the record):** the original claim was that `migrateHome` is called only via `migrateProjectId(previous=oldID, current=newID)` and retirement is one-way (root→remote), so no reverse caller exists and ABBA is unreachable. The MEM-PR01 review disproved this: a changed origin URL yields `previous=remote(A), current=remote(B)` (resolve: `remote ?? previous`), so opposite-direction pairs ARE reachable. See the #3 row above for the fix (sorted pair lock + three-phase merge + `SourceChangedError` verify-before-delete). + +**#4 rationale:** `worktree/index.ts reconcileLegacyMemory` already runs `memoryAdmission.invalidate(projectID)` **before** `ensure(...)`; invalidation clears the cache entry, so the destructive `ensure` always rescans fresh. The "no stale-cache trust" invariant already holds; no code change warranted. + +**Occam path outcome (2026-08-12):** the only *real* gap was **#1** (silent `workflow`+`permission` cascade-loss on identity upgrade) — fixed, tested, mutation-proven, no regressions (project 38, memory-persistence 16, memory 36, worktree 26 — all 0 fail; opencode+core typecheck clean; `git diff --check` 0). #4 verified as non-gap; #3 was initially closed as a non-gap but the MEM-PR01 review reopened and fixed it (see the #3 row); #2 deferred as a cascade awaiting the user's Occam-vs-invariant-#5 call. Subsequent fixes/pins #5–#18 are recorded in the tables below. + +**Explicitly cut by Occam** (do NOT build): MEM-ATOMIC-10 (Policy stays in `.opencode/memory.jsonc`; memory is topic content); the authority facade, 6-phase journal, alias tombstone, opaque Revision, destruction guard, crash harness; MEM-CRASH-06 as a forward-journal state machine (POSIX `rename` + the store's generation/manifest atomicity cover content; `migrateHome` can be made idempotent if a crash-retry need is shown). + +**Resume protocol (replaces §8 steps 3–4):** ~~do the next pending Fix in order (#2 → #3 → #4)~~ — SUPERSEDED: #1 done, #3/#4 closed (then #3 reopened by the MEM-PR01 review and fixed by construction), #5–#18 done/pinned by the MEM-PR01 slices. There is **no pending autonomous fix**. The only open items are user decisions: #2 (typed-error cascade — approved deferred as MEM-TYPED-02) and source-Home retention/GC. Per-slice discipline (kept for future work): re-read exact baseline → implement → `cd packages/opencode && bun typecheck` AND `cd packages/core && bun typecheck` → targeted test (package dir ONLY) → mutation gate (temp-revert ⇒ a real test flips Red, restore) → `git commit` (conventional) → update this plan. Exclusions unchanged: no Goal/DAG-config/CI, no source-Home GC, no dev→main/release. + +### M-C additions (two-round review findings, 2026-08-12) + +| Fix | Finding | Status | +|---|---|---| +| **#9** Memory is inert when the identity row is gone: `configuration()` no longer falls back to the stale instance context (`?? ctx.project` removed). A process holding a retired identity can no longer fork a Home under it. | MEM-PR01-R1-03 (P2) | ✅ done (Red→Green→mutation) | +| **#10** Worktree remove/reset reconcile against the **complete** directory snapshot (primary + every registered sandbox), never a single directory: a lone sandbox config can no longer be promoted past disagreeing siblings. | MEM-PR01-R1-06 (P2, blocking) | ✅ done (Red→Green; Red captured on the legacy single-directory behavior) | +| **#11** Migration is gated on `time.initialized` (the memory path's own eligibility rule): uninitialized projects stay inert on worktree remove/reset; residue still fails closed. Existing migration tests stamp initialized accordingly. | MEM-PR01-R1-08 (P3) | ✅ done (Red→Green) | +| **#12** Legacy topic/config files are **re-read and compared immediately before deletion**; content that changed after the scan (older-version writer, hand edit) is preserved and surfaced as a conflict instead of destroyed. Deterministic TOCTOU test holds the store flock to pin the scan→delete window. | MEM-PR01-R1-04 (P2) | ✅ done (Red→Green→mutation) | +| **#13** Admission's explicit-config choice follows `MemoryConfig.load` precedence (memory.jsonc before memory.json); a jsonc/json fork inside the project directory is diagnosed as `config.conflict` instead of silently picking a side, and legacy configs equal only to the non-effective side are no longer deleted as duplicates. | MEM-PR01-R1-10 (P3) | ✅ done (Red→Green→mutation) | +| pin | `/memory on|off` creates/updates the config in the **project worktree** even when the instance context lives in another worktree (sandbox). | MEM-PR01-R1-07 (P2 test-gap) | ✅ pinned | +| pin | Runtime admission snapshot covers **every registered sandbox**: a legacy topic living only in a sandbox is imported on activation. | MEM-PR01-R1-23 (P3 test-gap) | ✅ pinned | + +### M-D additions (worktree lifecycle findings, 2026-08-12) + +| Fix | Finding | Status | +|---|---|---| +| **#14** `list()` is a pure observation path: no more unconditional `git worktree prune` + deregistration on merely-prunable entries (git also marks inaccessible directories and broken gitdir links prunable while the directory still exists). Prunable entries stay hidden from the listing but otherwise untouched. | MEM-PR01-R1-16 (P2, blocking) | ✅ done (Red→Green→mutation) | +| **#15** Destructive cleanup moved to the action path: `remove()` gains a prunable branch (prune admin data + remove directory if present + branch cleanup + drop registrations) and a git-unknown recovery branch (registered but no git record: reconcile fail-closed, drop the stale registration, never delete the directory). Registration cleanup drops ALL canonically-equal entries (symlinked /var vs /private/var duplicates). | MEM-PR01-R1-18 (P3) + serialization regression | ✅ done (Red→Green→mutation) | +| pin | reset fails closed over invalid legacy memory and preserves it. | MEM-PR01-R1-17 (P2 test-gap) | ✅ pinned (mutation-proven) | +| pin | reset/remove invalidate the admission cache before the rescan (deterministic TOCTOU via reset-primed cache). | MEM-PR01-R1-19 (P2 test-gap, blocking) | ✅ pinned (mutation-proven) | + +### M-E additions (store resilience pins, 2026-08-12) + +| Fix | Finding | Status | +|---|---|---| +| **#16** Corrupt-manifest fail-closed reads are now Red-tested: an invalid manifest and a manifest referencing a missing generation both fail `readSnapshot`, and `migrateHome` fails closed on the merge path without deleting the unread source Home. Both fail-closed guards proven load-bearing by mutation (fail-open revert → the test Red). | MEM-PR01-R1-02 (P2 test-gap) | ✅ pinned (mutation-proven) | +| pin | `decodeTopic` rejects Topics whose `item_count` disagrees with `items.length`; the store refuses to publish such a generation. | MEM-PR01-R1-20 (P3 test-gap) | ✅ pinned (mutation-proven) | +| pin | An orphaned staging generation (crash mid-`writeSnapshot`, manifest never published) never shadows the committed generation; the store still commits cleanly afterwards. | MEM-PR01-R1-21 (P3 test-gap) | ✅ pinned (mutation-proven) | + +### M-F additions (config concurrency findings, 2026-08-12) + +| Fix | Finding | Status | +|---|---|---| +| **#17** All writers of a MEMORY config file now serialize on a per-file cross-process flock (`memory-config:`): `writeProject`, `writeGlobal`, and the normalization rewrite in `readConfig`. atomicWrite's byte-atomicity is no longer undermined by whole-document last-writer-wins between `/memory on|off`, admission promotion, and normalization rewrites across worktrees/processes. Pinned by a blocking-observation test (mutation-proven: dropping the lock lets the concurrent writer complete during the hold). Residual (documented, not fixed — Occam): decision-level read-modify-write across processes is not CAS-protected; only the write primitives are serialized. | MEM-PR01-R2-02 (P3, newly-exposed) | ✅ done (Red→Green→mutation) | +| **#18** Cross-process commit protocol now has a real second-process test: a spawned worker commits with a stale expectedRevision and must observe `MemoryStore.CommitConflictError` (ADR-0002's explicit-conflict guarantee), deterministic — unlike the timing-probabilistic updateTopics race test. | MEM-PR01-R2-03 (P3 test-gap) | ✅ pinned | + +### M-G additions (documentation alignment, 2026-08-12) + +| Item | Finding | Resolution | +|---|---|---| +| **#19** `packages/opencode/src/memory/CONTEXT.md` rewritten to the shipped Occam design: rejected-design glossary/invariants removed (Identity Alias, Canonical Project ID, tombstone retirement, opaque Revision, destruction guard); source Home described as migrate-then-remove (retention deferred); Project Configuration described as the unversioned `.opencode/memory.jsonc` (not Home-versioned); read-leniency split stated (runtime read projects empty; strict reads/migration fail closed); ADR-0001 policy clause restored as live; ADR-0004 marked Rejected; M-A…M-F behaviors reflected (global inertness, content-only conflicts, non-destructive list, fail-closed reset/remove, per-file config lock). | MEM-PR01-R1-01 (P3, blocking) | ✅ done | +| **#20** Redo-plan internal consistency: header status no longer says PLANNING; the §10 resume protocol is marked superseded (no pending autonomous fix; only user decisions remain). | MEM-PR01-R1-25 (P3) | ✅ done | +| **#21 (decision)** Git-exclusion narrowing is intentional and documented here: `ensureProjectExclude` installs only the two config candidates, not `.opencode/memory/`. Legacy topic files preserved fail-closed (topic.invalid/topic.conflict) are therefore visible in `git status` and committable. Trade-off accepted: surfacing repair-pending files beats silently git-excluding user data; the delta spec drops the old scenario and the test pins the narrowed behavior. | MEM-PR01-R1-09 (P3 spec-gap) | ✅ decision recorded | +| **#22 (requirement)** Identity-upgrade requirement recorded (the openspec workspace is untracked, so this plan carries it): **Identity upgrade preserves Project Memory and Project-owned references.** Scenarios: (a) root→first-remote migrates the Memory Home before the old Project row is deleted and repoints session/workspace/workflow/permission references; (b) a successor permission colliding on (project_id,action,resource) wins without wedging; (c) merge (not fork) when the successor already has Memory, content-conflicts fail closed; (d) crash mid-migration retries to convergence; (e) global identity is inert. Pinned by the M-A/M-B/M-C/M-E tests. | MEM-PR01-R1-14 (P3 spec-gap) | ✅ requirement recorded | + +### M-H additions (Round 3/4 confirmed P2 fixes + pins + doc alignment, 2026-08-12) + +Round 3/4 re-review (post M-A…M-G) confirmed five new code P2s introduced by the earlier slices, plus test-gap pins and doc drift. All addressed here. + +| Item | Finding | Resolution | +|---|---|---| +| **#23** In-flight old-identity writer could recreate a retired Home after the rename/merge (R3-P2-a). | P2 introduced | Fixed by a lock protocol: writers (prepare/search/checkpoint) hold a cross-process `memory-identity:` flock around their whole read-modify-write and re-check identity liveness inside it; `migrateHome` takes the same identity lock (inside the sorted pair lock), so it waits for in-flight writers and moves their writes with the Home. Global lock order admission→migrate(pair)→identity→project is cycle-free. | +| **#24** `git worktree prune` in remove's prunable branch is repo-global and destroyed sibling worktrees' admin data (R3-P2-d). | P2 introduced | Scoped: prune runs only when the removed entry is the SOLE prunable one; otherwise the stale admin data is left for explicit later cleanup. | +| **#25** Remove's fail-closed memory proof was taken BEFORE the WorktreeRemove hook window; legacy memory written by the hook was destroyed un-migrated (R3-P2-e). | P2 introduced | Reordered: the WorktreeRemove hook fires before the reconcile proof on all remove paths (normal, prunable, git-unknown), so the proof observes everything the hook produced. | +| **#26** remove/reset fell back to the stale instance identity (`?? ctx.project`), letting them reconcile under a retired identity (R3-P2-f). | P2 introduced | Both now fail closed when the identity row is gone (no fallback). | +| **#27** `cleanupLegacyDirectory` removed legacy dirs on a stale empty listing without revalidation (R3-P2-b). | P2 introduced | Re-checks the listing immediately before removing each dir. | +| pin | SourceChanged verify-before-delete guard had no Red-capable test (R3-P2-c). | P2 test-gap | Pinned: a deterministic test holds the target store lock to block migrateHome in phase 2, bumps the source revision mid-merge, and asserts SourceChangedError + source survives. Mutation-proven. | +| pin | Store write paths fail closed on a corrupt manifest but had no Red-capable test (R4-P2-a). | P2 test-gap | Pinned: updateTopics on a corrupt manifest fails and leaves it untouched. Mutation-proven. | +| pin | "Unresolved admission results are never cached" (ADR-0003) had no Red-capable test (R4-P2-b). | P2 test-gap | Pinned: after repairing an invalid legacy file, a same-key ensure rescans fresh (unresolved 0) instead of returning a cached unresolved. | +| #28 | ADR-0002 still documented the superseded "hold the old lock while merging" mechanism. | P3 introduced | Updated to the three-phase merge + identity-lock protocol. | +| #29 | Rejected ADR-0004's header still claimed it "Supersedes" live ADR-0001/0002 clauses. | P3 introduced | Corrected: a Rejected ADR supersedes nothing; those clauses stay live. | +| #30 | Redo plan kept the falsified "#3 ABBA unreachable / non-gap" narrative in three places. | P3 introduced | Header status, #3 rationale, and Occam-outcome lines corrected to record the falsification + fix. | + +Verification: memory+project suites 188 pass / 0 fail; opencode+core typecheck clean; every code fix mutation-proven where a guard was added. diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index e323fcc243..3bd5fd30dc 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -83,7 +83,7 @@ export const layer = Layer.effect( const dir = yield* InstanceState.directory const result = yield* appProcess .run( - ChildProcess.make(replaced[0]!, replaced.slice(1), { + ChildProcess.make(replaced[0], replaced.slice(1), { cwd: dir, env: item.environment, extendEnv: true, diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md new file mode 100644 index 0000000000..cc95f18b7b --- /dev/null +++ b/packages/opencode/src/memory/CONTEXT.md @@ -0,0 +1,67 @@ +# Project Memory Context + +Project Memory preserves user-confirmed, durable human context for one Project. It is not a code index, task tracker, instruction source, or general model-writable store. + +## User principles (confirmed 2026-08-12) + +- **One shared Memory per Project.** Worktrees hold no Memory of their own; they all share the Project's single Memory. +- **Memory never forks.** Memory is core, topic-typed content; worktrees (small PRs) must not branch it into per-worktree copies. +- **An identity upgrade is imperceptible.** When a repo gains its first remote (root → first-remote identity), the user's Memory endures seamlessly — nothing the user notices is lost, moved, or forked. + +## Authority structure (Occam path, adopted 2026-08-12) + +The domain runs on the existing seams; the elaborate `ProjectMemoryAuthority` redesign (ADR-0004) was **Rejected**. The authoritative pieces are: + +- **MemoryStore** (`store.ts`) — generation+manifest persistence for Topics. Strict reads (`readSnapshot`/`inspectTopics`) fail closed on a corrupt or missing generation; the runtime read (`readTopics`) is lenient and projects empty. +- **MemoryConfig** (`config.ts`) — the unversioned `.opencode/memory.jsonc` policy. Writes serialize on a per-file cross-process flock (`memory-config:`). +- **MemoryAdmission** (`admission.ts`) — the single legacy-input seam: scans one Project snapshot, reconciles once, caches only conflict-free results. +- **MemoryIdentityMigration** (`identity-migration.ts`) — `migrateHome(oldID, newID)`: rename when the target is absent, merge-then-remove otherwise; fails closed on conflict or an unread source. +- **Worktree guard** (`worktree/index.ts`) — `list()` is a pure observation path; `remove`/`reset` reconcile legacy memory fail-closed against the full directory snapshot and always invalidate the admission cache first. +- **Project identity migration** (`project/project.ts` `migrateProjectId`) — memory first, then the DB transaction that repoints session/workspace/workflow/permission references before deleting the old row. + +## Glossary + +| Term | Meaning | +| --- | --- | +| Project Memory | The authoritative durable Topic set owned by one Project identity and shared by all of that Project's worktrees. | +| Memory Home | The Project-scoped persistence boundary for Project Memory, keyed by Project identity (`memory/projects/`). | +| Topic | A bounded structured collection of confirmed preferences, decisions, or terms with controller-owned metadata. | +| Legacy Worktree Memory | Memory files stored inside a checkout by an older runtime. They are migration inputs, never a second authoritative store. | +| Memory Conflict | A case where legacy and Project Memory claim the same logical identity with different **content**, or where legacy configuration differs from the effective Project configuration. Controller metadata drift is not a conflict. | +| Project Configuration | The MEMORY policy owned by the Project's primary directory (`.opencode/memory.jsonc`). It is unversioned; writes are serialized per file, not atomic with Topics. | +| Memory Admission | The single legacy input seam that scans one Project snapshot, reconciles it once, and caches only conflict-free results. | +| Identity upgrade | The one-way transition when a repo gains a durable identity (root → first-remote, or a changed remote). Memory is migrated before the old Project row is deleted; nothing is forked. | +| Global identity | The shared fallback identity of commit-less repositories. Memory is fail-closed **inert** under it: one Project = one Memory, and a shared bucket would leak across repos and orphan at the first commit. | + +## Invariants + +- One Project identity has one authoritative Project Memory. +- Two worktrees of the same Project cannot form independent Memory namespaces; Memory never forks per worktree. +- Current user input and higher-priority instructions always override retrieved Memory. +- The controller owns persistence, metadata, migration, limits, and atomicity; models only propose bounded semantic actions. +- Migration writes a durable authoritative copy before treating a legacy copy as consumed. +- A Memory Conflict is explicit and fail-closed; no component silently chooses or overwrites conflicting durable context. Content equality ignores controller-owned metadata (`last_matched_at`, `match_count`, `revision`, `updated_at`). +- Removing or resetting a worktree cannot imply deleting Project Memory, and never deletes the user's worktree directory as a side effect of registration cleanup. +- Removing Project Memory requires a separate Project retention decision. +- Runtime reads never perform ad-hoc legacy migration; they consume a Project snapshot admitted by Memory Admission. +- Memory is inert under the global identity and for uninitialized projects; activation requires a real, initialized identity. +- Identity upgrade migrates Memory first, repoints every Project-owned reference (session, workspace, workflow, permission), and only then retires the old row. A successor permission that collides on `(project_id, action, resource)` wins; the duplicate is dropped, never wedged. +- A missing Memory Home is empty. A corrupt or dangling Home fails closed on strict reads and migration (the source is never deleted unread); the lenient runtime read projects it as empty rather than erroring. +- Worktree `list()` observes and never mutates: it does not prune git admin data or drop registrations for merely-prunable entries. Destructive cleanup belongs to `remove`/`reset`, which prove each case first. +- Worktree `remove`/`reset` reconcile legacy memory fail-closed against the **complete** directory snapshot (primary + every registered sandbox) and always invalidate the admission cache before rescanning; they never trust a cached clean result. +- Legacy files are re-read and compared immediately before deletion; content that changed after the scan is preserved and surfaced as a conflict. +- Every writer of a MEMORY config file serializes on the file's cross-process lock; byte-atomicity is not undermined by whole-document last-writer-wins. + +## Boundaries + +- Worktree lifecycle assembles the directory snapshot and invalidates the admission cache before reconciling; it does not own Topic persistence or Project retention. +- Session runtime may retrieve and attach bounded Memory context, but it does not own Topic persistence. +- Codebase discovery belongs to codebase-memory facilities and is rejected from Project Memory. +- Source-Home retention/GC after migration is a deferred product decision; the current behavior is migrate-then-remove. + +## Decisions + +- [ADR-0001: Project identity owns Memory](docs/adr/0001-project-owned-memory.md) +- [ADR-0002: Project Memory commits are versioned and process-safe](docs/adr/0002-project-memory-commit-protocol.md) +- [ADR-0003: Legacy Memory enters through Project admission](docs/adr/0003-memory-admission.md) +- [ADR-0004: Project Memory authority owns identity and commits](docs/adr/0004-project-memory-authority.md) — **Rejected (2026-08-12)** in favor of the Occam path recorded in `docs/memory-authority-redo-plan-2026-08-12.md` §10. diff --git a/packages/opencode/src/memory/admission.ts b/packages/opencode/src/memory/admission.ts new file mode 100644 index 0000000000..1dabe4ea8d --- /dev/null +++ b/packages/opencode/src/memory/admission.ts @@ -0,0 +1,486 @@ +export * as MemoryAdmission from "./admission" + +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProjectV2 } from "@opencode-ai/core/project" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Context, Effect, Layer, Option, Schema } from "effect" +import { basename, join } from "node:path" +import { parse } from "yaml" +import { MemoryConfig } from "./config" +import { MemoryHome } from "./home" +import { MemoryIdentityFence } from "./identity-fence" +import { MemoryPaths } from "./paths" +import { MemoryStore } from "./store" + +const Code = Schema.Literals([ + "topic.imported", + "topic.duplicate", + "topic.invalid", + "topic.conflict", + "config.promoted", + "config.duplicate", + "config.invalid", + "config.conflict", +]) +const Count = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)) + +export class Diagnostic extends Schema.Class("MemoryAdmission.Diagnostic")({ + code: Code, + path: Schema.String, + topic_id: Schema.optional(Schema.String), + message: Schema.String, +}) {} + +export class Result extends Schema.Class("MemoryAdmission.Result")({ + diagnostics: Schema.Array(Diagnostic), + imported: Count, + duplicates: Count, + unresolved: Count, +}) {} + +export class ProjectSnapshot extends Schema.Class("MemoryAdmission.ProjectSnapshot")({ + projectID: ProjectV2.ID, + projectDirectory: Schema.String, + directories: Schema.Array(Schema.String), + updated: Schema.Number, +}) {} + +/** + * The identity was retired between the caller's snapshot and fence + * acquisition. The import is abandoned: writing would re-create the retired + * Home and destroy the only remaining copy of the legacy content. + */ +export class IdentityRetiredError extends Schema.TaggedErrorClass()( + "MemoryAdmission.IdentityRetired", + { project_id: Schema.String }, +) {} + +export interface Interface { + readonly ensure: ( + snapshot: ProjectSnapshot, + ) => Effect.Effect + readonly invalidate: (projectID: ProjectV2.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/MemoryAdmission") {} + +type TopicCandidate = { + readonly file: string + readonly id: string + readonly topic?: MemoryStore.Snapshot["topics"][number] +} + +type ConfigCandidate = { + readonly file: string + readonly config: ReturnType | undefined +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service + const fence = yield* MemoryIdentityFence.Service + const config = yield* MemoryConfig.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + const cache = new Map() + + const readTopicCandidates = Effect.fnUntraced(function* (directories: ReadonlyArray) { + return yield* Effect.forEach( + directories, + (directory) => + Effect.gen(function* () { + const legacy = MemoryPaths.legacyTopics(directory) + if (!(yield* fs.existsSafe(legacy))) return [] + const files = (yield* fs.readDirectoryEntries(legacy)) + .filter((entry) => entry.type === "file" && entry.name.endsWith(".yaml")) + .map((entry) => join(legacy, entry.name)) + .sort() + return yield* Effect.forEach( + files, + (file) => + Effect.gen(function* () { + const id = basename(file, ".yaml") + const text = yield* fs.readFileString(file) + const parsed = yield* Effect.try({ + try: () => parse(text), + catch: () => new MemoryStore.StoreError({ message: "Legacy MEMORY topic YAML is invalid" }), + }).pipe(Effect.option) + return { + file, + id, + topic: Option.isSome(parsed) ? MemoryStore.decodeTopic(parsed.value, id) : undefined, + } satisfies TopicCandidate + }), + { concurrency: 8 }, + ) + }), + { concurrency: 4 }, + ).pipe(Effect.map((items) => items.flat().sort((left, right) => left.file.localeCompare(right.file)))) + }) + + // A legacy file may change between the scan and its removal (an older-version + // runtime still writing .opencode/memory, or a hand edit). Re-read each file + // right before deleting it; if the content no longer matches what was scanned, + // preserve the file and surface a conflict instead of destroying the new content. + const revalidateTopicFile = Effect.fnUntraced(function* (candidate: TopicCandidate) { + const text = yield* fs.readFileStringSafe(candidate.file) + if (text === undefined) return true + const parsed = yield* Effect.try({ + try: () => parse(text), + catch: () => undefined, + }).pipe(Effect.option) + if (Option.isNone(parsed) || parsed.value === undefined) return false + const decoded = MemoryStore.decodeTopic(parsed.value, candidate.id) + return decoded !== undefined && same(decoded, candidate.topic) + }) + + const reconcileTopics = Effect.fnUntraced(function* (snapshot: ProjectSnapshot, candidates: TopicCandidate[]) { + const updated = yield* store.updateTopics(snapshot.projectID, (topics) => { + const next = [...topics] + const byID = new Map(next.map((topic) => [topic.id, topic])) + const changed: string[] = [] + const removable: TopicCandidate[] = [] + const diagnostics = candidates.map((candidate) => { + if (!candidate.topic) + return new Diagnostic({ + code: "topic.invalid", + path: candidate.file, + topic_id: candidate.id, + message: `Legacy MEMORY topic ${candidate.id} is invalid and was preserved`, + }) + const existing = byID.get(candidate.id) + if (!existing) { + next.push(candidate.topic) + byID.set(candidate.id, candidate.topic) + changed.push(candidate.id) + removable.push(candidate) + return new Diagnostic({ + code: "topic.imported", + path: candidate.file, + topic_id: candidate.id, + message: `Legacy MEMORY topic ${candidate.id} was imported into Project Memory`, + }) + } + if (same(existing, candidate.topic)) { + removable.push(candidate) + return new Diagnostic({ + code: "topic.duplicate", + path: candidate.file, + topic_id: candidate.id, + message: `Legacy MEMORY topic ${candidate.id} already exists in Project Memory`, + }) + } + return new Diagnostic({ + code: "topic.conflict", + path: candidate.file, + topic_id: candidate.id, + message: `Legacy MEMORY topic ${candidate.id} differs from Project Memory and was preserved`, + }) + }) + return { + applied: { topics: next, changed, deleted: [] }, + result: { diagnostics, removable }, + } + }) + const preserved = new Set() + for (const candidate of updated.result.removable) { + if (!(yield* revalidateTopicFile(candidate))) preserved.add(candidate.file) + } + yield* Effect.forEach( + updated.result.removable.filter((candidate) => !preserved.has(candidate.file)), + (candidate) => fs.remove(candidate.file, { force: true }), + { + concurrency: 1, + discard: true, + }, + ) + return updated.result.diagnostics.map((diagnostic) => + preserved.has(diagnostic.path) + ? new Diagnostic({ + code: "topic.conflict", + path: diagnostic.path, + topic_id: diagnostic.topic_id, + message: `Legacy MEMORY topic ${diagnostic.topic_id} changed during migration and was preserved`, + }) + : diagnostic, + ) + }) + + const readConfigCandidates = Effect.fnUntraced(function* (directories: ReadonlyArray) { + const files = directories.flatMap((directory) => + MemoryPaths.PROJECT_CONFIG_PATHS.map((relative) => join(directory, relative)), + ) + // Keep the flatMap order (directory-major, and within one directory + // memory.jsonc BEFORE memory.json — exactly MemoryConfig.load's + // precedence). A localeCompare sort would flip jsonc/json and make + // admission disagree with the runtime loader about which file is + // authoritative. + const order = new Map(files.map((file, index) => [file, index])) + return yield* Effect.forEach( + files, + (file) => + Effect.gen(function* () { + const text = yield* fs.readFileStringSafe(file) + if (text === undefined) return undefined + const decoded = MemoryConfig.decodeConfig(text) + return { + file, + config: Option.isSome(decoded) ? MemoryConfig.normalizeConfig(decoded.value) : undefined, + } satisfies ConfigCandidate + }), + { concurrency: 4 }, + ).pipe( + Effect.map((items) => + items + .filter((item): item is ConfigCandidate => item !== undefined) + .sort((left, right) => (order.get(left.file) ?? 0) - (order.get(right.file) ?? 0)), + ), + ) + }) + + // Same stale-scan protection as topics: a config file may change between the + // scan and its removal. Re-read and compare before deleting. + const revalidateConfigFile = Effect.fnUntraced(function* (candidate: ConfigCandidate) { + const text = yield* fs.readFileStringSafe(candidate.file) + if (text === undefined) return true + const decoded = MemoryConfig.decodeConfig(text) + if (Option.isNone(decoded)) return false + return same(MemoryConfig.normalizeConfig(decoded.value), candidate.config) + }) + + const removeValidated = Effect.fnUntraced(function* (candidates: ReadonlyArray) { + const preserved = new Set() + for (const candidate of candidates) { + if (!(yield* revalidateConfigFile(candidate))) preserved.add(candidate.file) + } + yield* Effect.forEach( + candidates.filter((candidate) => !preserved.has(candidate.file)), + (candidate) => fs.remove(candidate.file, { force: true }), + { concurrency: 1, discard: true }, + ) + return preserved + }) + + const reconcileConfigs = Effect.fnUntraced(function* (snapshot: ProjectSnapshot) { + const project = yield* readConfigCandidates([snapshot.projectDirectory]) + const legacy = yield* readConfigCandidates( + snapshot.directories.filter((directory) => directory !== snapshot.projectDirectory), + ) + const explicit = project[0] + if (explicit) { + const diagnostics: Diagnostic[] = [] + if (!explicit.config) + diagnostics.push( + new Diagnostic({ + code: "config.invalid", + path: explicit.file, + message: "Project MEMORY config is invalid and was preserved", + }), + ) + // A project directory holding BOTH memory.jsonc and memory.json is a + // fork of the durable configuration: diagnose it explicitly instead of + // silently following one side. Equal copies collapse to a duplicate. + for (const extra of project.slice(1)) { + if (!extra.config || !explicit.config) { + diagnostics.push( + new Diagnostic({ + code: "config.invalid", + path: extra.file, + message: "Project MEMORY config is invalid and was preserved", + }), + ) + } else if (same(extra.config, explicit.config)) { + const preserved = yield* removeValidated([extra]) + diagnostics.push( + preserved.has(extra.file) + ? new Diagnostic({ + code: "config.conflict", + path: extra.file, + message: "Project MEMORY config changed during migration and was preserved", + }) + : new Diagnostic({ + code: "config.duplicate", + path: extra.file, + message: "Project MEMORY config duplicates the authoritative config and was removed", + }), + ) + } else { + diagnostics.push( + new Diagnostic({ + code: "config.conflict", + path: extra.file, + message: "Project MEMORY config fork (jsonc/json) disagrees with the authoritative config and was preserved", + }), + ) + } + } + const duplicates = legacy.filter( + (candidate) => candidate.config && explicit.config && same(candidate.config, explicit.config), + ) + const preserved = yield* removeValidated(duplicates) + for (const candidate of legacy) { + if (duplicates.some((duplicate) => duplicate.file === candidate.file)) { + diagnostics.push( + preserved.has(candidate.file) + ? new Diagnostic({ + code: "config.conflict", + path: candidate.file, + message: "Legacy sandbox MEMORY config changed during migration and was preserved", + }) + : new Diagnostic({ + code: "config.duplicate", + path: candidate.file, + message: "Legacy sandbox MEMORY config duplicates the Project config", + }), + ) + } else { + diagnostics.push( + new Diagnostic({ + code: candidate.config ? "config.conflict" : "config.invalid", + path: candidate.file, + message: candidate.config + ? "Legacy sandbox MEMORY config differs from the Project config and was preserved" + : "Legacy sandbox MEMORY config is invalid and was preserved", + }), + ) + } + } + return diagnostics + } + + const valid = legacy.filter( + (candidate): candidate is ConfigCandidate & { config: NonNullable } => + candidate.config !== undefined, + ) + const values = new Map(valid.map((candidate) => [JSON.stringify(candidate.config), candidate.config])) + if (values.size !== 1) + return legacy.map( + (candidate) => + new Diagnostic({ + code: candidate.config ? "config.conflict" : "config.invalid", + path: candidate.file, + message: candidate.config + ? "Legacy sandbox MEMORY configs disagree and were preserved" + : "Legacy sandbox MEMORY config is invalid and was preserved", + }), + ) + + const promoted = valid[0] + yield* config.writeProject(snapshot.projectDirectory, promoted.config) + const preserved = yield* removeValidated(valid) + return legacy.map( + (candidate) => + new Diagnostic({ + code: !candidate.config + ? "config.invalid" + : preserved.has(candidate.file) + ? "config.conflict" + : candidate.file === promoted.file + ? "config.promoted" + : "config.duplicate", + path: candidate.file, + message: !candidate.config + ? "Legacy sandbox MEMORY config is invalid and was preserved" + : preserved.has(candidate.file) + ? "Legacy sandbox MEMORY config changed during migration and was preserved" + : candidate.file === promoted.file + ? "Legacy sandbox MEMORY config was promoted to the Project config" + : "Legacy sandbox MEMORY config duplicates the promoted Project config", + }), + ) + }) + + const cleanupLegacyDirectory = Effect.fnUntraced(function* (directory: string) { + const topics = MemoryPaths.legacyTopics(directory) + if ((yield* fs.existsSafe(topics)) && (yield* fs.readDirectoryEntries(topics)).length === 0) { + // Re-check immediately before removing: an older-version writer that + // does not take our locks may have created a file after the first + // listing. Removing on a stale empty listing would destroy it. + if ((yield* fs.readDirectoryEntries(topics)).length === 0) yield* fs.remove(topics, { recursive: true }) + } + const legacy = join(directory, ".opencode", "memory") + if ((yield* fs.existsSafe(legacy)) && (yield* fs.readDirectoryEntries(legacy)).length === 0) { + if ((yield* fs.readDirectoryEntries(legacy)).length === 0) yield* fs.remove(legacy, { recursive: true }) + } + }) + + const ensureUnsafe = Effect.fnUntraced(function* (snapshot: ProjectSnapshot, key: string) { + const cached = cache.get(snapshot.projectID) + if (cached?.key === key) return cached.result + const candidates = yield* readTopicCandidates(snapshot.directories) + const diagnostics = [ + ...(yield* reconcileTopics(snapshot, candidates)), + ...(yield* reconcileConfigs(snapshot)), + ] + yield* Effect.forEach(snapshot.directories, cleanupLegacyDirectory, { concurrency: 1, discard: true }) + const result = new Result({ + diagnostics, + imported: diagnostics.filter((item) => item.code === "topic.imported").length, + duplicates: diagnostics.filter((item) => item.code.endsWith(".duplicate")).length, + unresolved: diagnostics.filter((item) => item.code.endsWith(".invalid") || item.code.endsWith(".conflict")).length, + }) + if (result.unresolved === 0) cache.set(snapshot.projectID, { key, result }) + return result + }) + + const ensure = Effect.fn("MemoryAdmission.ensure")(function* (snapshot: ProjectSnapshot) { + const directories = Array.from(new Set([snapshot.projectDirectory, ...snapshot.directories])).sort() + const normalized = new ProjectSnapshot({ + projectID: snapshot.projectID, + projectDirectory: snapshot.projectDirectory, + directories, + updated: snapshot.updated, + }) + const key = JSON.stringify([snapshot.projectID, directories, snapshot.updated]) + // Lock order (outermost→innermost): memory-admission → memory-identity → + // memory-project (inside updateTopics). The identity fence is owned by + // MemoryIdentityFence: it re-checks identity liveness inside the fence, + // so the import can never re-create a retired Home or delete the legacy + // source files after a concurrent retirement. + return yield* flock.withLock( + Effect.gen(function* () { + const imported = yield* fence.withLiveIdentity(snapshot.projectID, ensureUnsafe(normalized, key)) + if (Option.isNone(imported)) { + return yield* new IdentityRetiredError({ project_id: snapshot.projectID }) + } + return imported.value + }), + `memory-admission:${snapshot.projectID}`, + home.locks, + ) + }) + + const invalidate = Effect.fn("MemoryAdmission.invalidate")((projectID: ProjectV2.ID) => + Effect.sync(() => { + cache.delete(projectID) + }), + ) + + return Service.of({ ensure, invalidate }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(MemoryConfig.defaultLayer), + Layer.provide(MemoryHome.defaultLayer), + Layer.provide(MemoryStore.defaultLayer), + Layer.provide(MemoryIdentityFence.defaultLayer), +) + +export const node = LayerNode.make(layer, [ + FSUtil.node, + EffectFlock.node, + MemoryConfig.node, + MemoryHome.node, + MemoryStore.node, + MemoryIdentityFence.node, +]) + +function same(left: unknown, right: unknown) { + return JSON.stringify(left) === JSON.stringify(right) +} diff --git a/packages/opencode/src/memory/config.ts b/packages/opencode/src/memory/config.ts index 4d46d8af56..4d1b641edf 100644 --- a/packages/opencode/src/memory/config.ts +++ b/packages/opencode/src/memory/config.ts @@ -1,13 +1,17 @@ export * as MemoryConfig from "./config" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Flag } from "@opencode-ai/core/flag/flag" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" +import { Git } from "@/git" import { Context, Effect, Layer, Option, Schema } from "effect" -import { dirname, join } from "node:path" +import { dirname, isAbsolute, join, resolve } from "node:path" import { parse, type ParseError } from "jsonc-parser" import { MemoryFile } from "./file" +import { MemoryPaths } from "./paths" import { MemorySchema } from "./schema" export type Loaded = { @@ -17,14 +21,17 @@ export type Loaded = { } export interface Interface { - readonly load: (projectDir: string) => Effect.Effect - readonly loadGlobal: () => Effect.Effect + readonly load: (projectDir: string) => Effect.Effect + readonly loadGlobal: () => Effect.Effect readonly writeProject: ( projectDir: string, config: MemorySchema.Config, existingPath?: string, - ) => Effect.Effect - readonly writeGlobal: (config: MemorySchema.Config, existingPath?: string) => Effect.Effect + ) => Effect.Effect + readonly writeGlobal: ( + config: MemorySchema.Config, + existingPath?: string, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/MemoryConfig") {} @@ -33,6 +40,22 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service + const git = yield* Git.Service + const flock = yield* EffectFlock.Service + + const ensureProjectExclude = Effect.fnUntraced(function* (projectDir: string) { + const result = yield* git.run(["rev-parse", "--git-path", "info/exclude"], { cwd: projectDir }) + if (result.exitCode !== 0) return + const raw = result.text().trim() + if (!raw) return + const file = isAbsolute(raw) ? raw : resolve(projectDir, raw) + const current = (yield* fs.readFileStringSafe(file)) ?? "" + const lines = new Set(current.split(/\r?\n/).map((line) => line.trim())) + const missing = MemoryPaths.PROJECT_CONFIG_PATHS.filter((rule) => !lines.has(rule)) + if (missing.length === 0) return + const prefix = current.length === 0 || current.endsWith("\n") ? current : current + "\n" + yield* MemoryFile.atomicWrite(fs, file, prefix + missing.join("\n") + "\n") + }) const readFirst = Effect.fnUntraced(function* (paths: string[]) { for (const path of paths) { @@ -43,14 +66,14 @@ export const layer = Layer.effect( }) const readConfig = Effect.fnUntraced(function* (found: { path: string; text: string }) { - const decoded = decode(found.text) + const decoded = decodeConfig(found.text) if (Option.isNone(decoded)) { yield* Effect.logWarning("memory config is invalid — ignoring", { path: found.path }) return undefined } if (decoded.value.topic_limit === decoded.value.topic_limit_floor) return decoded.value - const config = MemorySchema.updateConfig(decoded.value, { topic_limit_floor: decoded.value.topic_limit }) - yield* MemoryFile.atomicWrite(fs, found.path, serialize(config)) + const config = normalizeConfig(decoded.value) + yield* flock.withLock(MemoryFile.atomicWrite(fs, found.path, serialize(config)), writeLockKey(found.path)) return config }) @@ -78,7 +101,14 @@ export const layer = Layer.effect( config: MemorySchema.Config, existingPath?: string, ) { - yield* MemoryFile.atomicWrite(fs, existingPath ?? projectPath(projectDir), serialize(config)) + yield* ensureProjectExclude(projectDir) + // One Project = one shared policy file, written by several paths + // (/memory on|off, admission promotion, normalization rewrites) from + // multiple worktrees and processes. Serialize the writes on the target + // file so atomicWrite's byte-atomicity is not undermined by + // whole-document last-writer-wins. + const target = existingPath ?? projectPath(projectDir) + yield* flock.withLock(MemoryFile.atomicWrite(fs, target, serialize(config)), writeLockKey(target)) }) const writeGlobal = Effect.fn("MemoryConfig.writeGlobal")(function* ( @@ -86,14 +116,14 @@ export const layer = Layer.effect( existingPath?: string, ) { if (existingPath && globalCandidates().includes(existingPath)) { - yield* MemoryFile.atomicWrite(fs, existingPath, serialize(config)) + yield* flock.withLock(MemoryFile.atomicWrite(fs, existingPath, serialize(config)), writeLockKey(existingPath)) return true } const file = join(globalConfigDir(), "memory.jsonc") const found = yield* readFirst(globalCandidates()) if (found) { if (yield* readConfig(found)) return false - yield* MemoryFile.atomicWrite(fs, found.path, serialize(config)) + yield* flock.withLock(MemoryFile.atomicWrite(fs, found.path, serialize(config)), writeLockKey(found.path)) return true } yield* fs.makeDirectory(dirname(file), { recursive: true }) @@ -107,9 +137,18 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(Git.defaultLayer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))), +) + +export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node, Git.node]) -export const node = LayerNode.make(layer, [FSUtil.node]) +/** Cross-process serialization key for writes to one MEMORY config file. */ +export function writeLockKey(file: string) { + return `memory-config:${file}` +} export function projectPath(projectDir: string) { return join(projectDir, ".opencode", "memory.jsonc") @@ -123,7 +162,7 @@ export function globalConfigDir() { return Flag.OPENCODE_CONFIG_DIR ?? Global.Path.config } -function projectCandidates(projectDir: string) { +export function projectCandidates(projectDir: string) { return [join(projectDir, ".opencode", "memory.jsonc"), join(projectDir, ".opencode", "memory.json")] } @@ -135,7 +174,7 @@ function serialize(config: MemorySchema.Config) { return JSON.stringify(config, null, 2) + "\n" } -function decode(text: string) { +export function decodeConfig(text: string) { const errors: ParseError[] = [] const value = parse(text, errors, { allowTrailingComma: true }) if (errors.length > 0) return Option.none() @@ -144,3 +183,8 @@ function decode(text: string) { return Option.none() return decoded } + +export function normalizeConfig(config: MemorySchema.Config) { + if (config.topic_limit === config.topic_limit_floor) return config + return MemorySchema.updateConfig(config, { topic_limit_floor: config.topic_limit }) +} diff --git a/packages/opencode/src/memory/docs/adr/0001-project-owned-memory.md b/packages/opencode/src/memory/docs/adr/0001-project-owned-memory.md new file mode 100644 index 0000000000..6b732a195c --- /dev/null +++ b/packages/opencode/src/memory/docs/adr/0001-project-owned-memory.md @@ -0,0 +1,32 @@ +# ADR-0001: Project identity owns Memory + +- Status: Accepted +- Date: 2026-08-11 + +## Context + +Durable Memory was stored beneath the active worktree. This made identical Projects acquire divergent Topic sets and allowed checkout reset/removal to destroy information whose intended lifetime exceeded that checkout. + +Project identity is stable across registered worktrees. Worktree paths are locations with shorter, independent lifecycles. + +## Decision + +Project Memory is owned and located by Project identity. All worktrees of that Project share one authoritative Topic set outside checkout directories. + +Project configuration is resolved from the Project's primary directory so it remains user-editable without creating sandbox-specific policy. Worktree-local Memory is compatibility input only. Valid non-conflicting data migrates to Project Memory; conflicting or invalid data remains in place and blocks destructive worktree removal. + +Deleting a worktree never deletes Project Memory. Retention or garbage collection of Project Memory requires a separate Project-level policy. + +## Consequences + +- Worktrees share durable preferences, decisions, and terms immediately. +- Reset and remove no longer own the lifetime of authoritative Topic data. +- Migration and conflict diagnostics become part of the persistence boundary. +- Central data can outlive the last checkout until a separate retention policy exists. +- Cross-process write serialization is defined by [ADR-0002](0002-project-memory-commit-protocol.md). + +## Alternatives Considered + +- Use the primary worktree as the shared store: rejected because moving, resetting, or deleting that checkout still controls Project Memory lifetime. +- Keep per-worktree stores and merge during retrieval: rejected because it creates multiple authorities and makes conflicts part of every read. +- Resolve conflicts by revision number: rejected because revision alone cannot prove which durable user-confirmed content should win. diff --git a/packages/opencode/src/memory/docs/adr/0002-project-memory-commit-protocol.md b/packages/opencode/src/memory/docs/adr/0002-project-memory-commit-protocol.md new file mode 100644 index 0000000000..958efd3fe6 --- /dev/null +++ b/packages/opencode/src/memory/docs/adr/0002-project-memory-commit-protocol.md @@ -0,0 +1,29 @@ +# ADR-0002: Project Memory commits are versioned and process-safe + +- Status: Accepted +- Date: 2026-08-11 + +## Context + +Project Memory is shared by every worktree of one Project. Separate OpenCode processes can therefore read the same Topic revision and attempt conflicting updates. Per-process mutexes and per-file atomic writes do not prevent the last writer from silently replacing another process's confirmed content, nor do they make a multi-Topic update crash-atomic. + +## Decision + +Memory Store is the commit authority. Its public mutation surface is limited to: + +- `commit(projectID, expectedRevision, applied)`, which rejects a stale revision; +- `updateTopics(projectID, update)`, which acquires the existing cross-process `EffectFlock`, reads the latest snapshot inside the lock, applies one synchronous update, and commits it. + +Each successful mutation writes a complete Topic generation into a temporary directory, renames that directory into place, and atomically publishes a manifest containing the new revision and generation. Readers follow only the manifest. A crash before manifest publication leaves the previous generation authoritative; a crash after publication leaves the complete new generation authoritative. + +Legacy `topics/` data is revision zero and is promoted on the first commit. Previous and orphaned generations remain non-authoritative. Their garbage collection requires the separate Project Memory retention policy. + +Project identity migration serializes on a `memory-migrate:` flock and a `memory-identity:` flock, then runs a three-phase merge: snapshot the source under the source lock, merge into the target (the target update takes the target lock), and remove the source only after re-reading it and confirming its revision has not changed since the snapshot (`SourceChangedError` otherwise). In-flight writers still producing under the old identity hold `memory-identity:` for their whole read-modify-write, so the migration waits for them and moves their writes along with the Home. The Project database retires the old identity only after Memory migration succeeds. + +## Consequences + +- Concurrent worktrees cannot silently lose same-Topic updates when they use the Store mutation API. +- Stale callers receive an explicit revision conflict. +- Restart observes either the complete old generation or the complete new generation, never a partial batch. +- Store writes use more disk space until retention policy defines safe generation cleanup. +- Callers cannot persist an already-computed stale Topic set through an unversioned write API. diff --git a/packages/opencode/src/memory/docs/adr/0003-memory-admission.md b/packages/opencode/src/memory/docs/adr/0003-memory-admission.md new file mode 100644 index 0000000000..40c96ec6e9 --- /dev/null +++ b/packages/opencode/src/memory/docs/adr/0003-memory-admission.md @@ -0,0 +1,34 @@ +# ADR-0003: Legacy Memory enters through Project admission + +- Status: Accepted +- Date: 2026-08-11 + +## Context + +Memory configuration reads previously scanned every registered worktree and could import Topics or delete duplicate files. `prepare`, `context`, and `checkpoint` therefore hid cross-directory writes behind a read-shaped function. Each legacy Topic also reopened and rewrote the authoritative Topic set independently. When no explicit Project configuration existed, one consistent sandbox configuration was treated as an unresolvable conflict instead of becoming the Project configuration. + +## Decision + +`MemoryAdmission.ensure(projectSnapshot)` is the only legacy input seam. A snapshot contains the Project identity, primary directory, complete sorted directory set, and Project update revision. Admission holds a Project-scoped cross-process lock, reads all legacy candidates, applies all Topic imports in one Store update, resolves configuration, removes only committed imports or exact duplicates, and returns stable diagnostics. + +Successful conflict-free results are cached by Project identity, sorted directories, and Project update revision. Unresolved results are not cached so manual repair can be observed. Worktree reset and removal invalidate the Project before rerunning admission. + +Configuration resolution follows these rules: + +- An explicit valid Project configuration is authoritative; equal sandbox files are duplicates and differing files are conflicts. +- Without an explicit Project configuration, one normalized value across all valid sandbox files is promoted to the Project. +- Multiple normalized values conflict. Invalid files remain in place and are diagnosed. + +## Consequences + +- Memory reads no longer rescan or mutate every worktree on each call. +- Topic migration publishes at most one authoritative revision per admitted Project snapshot. +- A consistent sandbox policy can become the Project policy without manual copying. +- Worktree lifecycle owns cache invalidation, not migration rules. +- Conflict and invalid-file repair remains fail-closed and observable. + +## Alternatives Considered + +- Cache `Memory.configuration()`: rejected because migration rules and filesystem mutation would remain hidden in a read-shaped module. +- Keep one reconcile call per legacy file: rejected because it multiplies authoritative reads and commits and makes cross-process ordering harder to reason about. +- Treat the global fallback as an explicit Project configuration: rejected because global policy is not Project-owned and must not prevent promotion of a consistent Project-specific legacy value. diff --git a/packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md b/packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md new file mode 100644 index 0000000000..8fa9a0c94c --- /dev/null +++ b/packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md @@ -0,0 +1,70 @@ +# ADR-0004: Project Memory authority owns identity and commits + +- Status: **Rejected** (2026-08-12) — superseded by the Occam minimal path (redo plan §10). After survey + adversarial review the user applied Occam's Razor: this elaborate redesign (authority facade, 6-phase retirement journal, alias tombstone, opaque Revision, destruction guard, 8 phases) is over-engineered for the actual needs — one shared memory per project and no fork are already in the baseline; an imperceptible identity upgrade and no data loss are achievable with small in-place fixes. Kept as a record of the considered-and-rejected direction. +- Date: 2026-08-12 +- Supersedes: **nothing** — this ADR was Rejected before adoption, so it supersedes no live clause. The Policy-source clause of [ADR-0001](./0001-project-owned-memory.md) and the lock/commit framing of [ADR-0002](./0002-project-memory-commit-protocol.md) remain live and authoritative. Reconstructs the lost authority redesign (uncommitted WIP, /tmp-cleaned) from design memory, now written down so the considered-and-rejected direction stays auditable. + +## Context + +Project identity resolution, cross-process locking, Memory Home selection, Project configuration, legacy admission, and Project ID retirement were composed by several public services (Store, Config, Admission, the controller, Migration). Three complete reviews found the same failure class in different call orders: a caller could resolve before locking, invalidate the wrong identity, move a Home before an alias preflight, or carry inherited "lock held" state beyond the real flock lifetime. Pushing more canonical IDs / paths / callbacks / lock state between those services would keep the persistence protocol in application callers. + +User product principles confirmed 2026-08-12 (governs this ADR): +1. **One shared Memory per Project.** Worktrees have no Memory of their own; they all share the Project's single Memory. +2. **Memory never forks.** Memory is core, topic-typed content; worktrees are small PRs and must not branch Memory into per-worktree copies. +3. **An identity upgrade is imperceptible.** When a repo gains its first remote (root-commit identity → first-remote identity), the user's Memory endures seamlessly — nothing the user notices is lost, moved, or forked. + +These reaffirm the baseline direction (ADR-0001/0002: Memory is the Project-owned, identity-keyed, worktree-external shared store) and raise the bar: the upgrade must be correct and seamless, not just "eventually consistent." + +## Decision + +One `ProjectMemoryAuthority` owns the application seam. Callers express three domain operations: read Memory, change Memory via an opaque revision, and retire a Project identity. Runtime admission is part of `readMemory` (no compose-your-own `admit → read`). Callers never receive canonical IDs, Home paths, lock capabilities, cache invalidation, or migration callbacks. + +```ts +interface ProjectMemoryAuthority { + readMemory(projectID): Effect + changeMemory(revision, changes: NonEmpty): Effect + retireIdentity(request: IdentityRetirement): Effect +} +``` + +- `Revision` is opaque, one-shot, caller-unforgeable, binding canonical identity + Topics revision + Policy fingerprint + Project topology fingerprint + admission-input fingerprint. **D4.** +- `Change = replace_topics | mark_matched | set_policy` — data, not Effect callbacks. **D4.** +- `retireIdentity` is the **only** identity-migration entry point. + +### Routine operations +Resolve the requested Project ID → acquire **one** canonical Project commit right → resolve again → retry if retirement changed the identity. Model work happens outside the commit right; a later change uses revision comparison rather than holding a lock across provider execution. A revision issued before Identity Retirement is rejected after the identity commit point. + +### Identity Retirement (forward-only; "merge into one", not a fork, not a Project Merge) +Validate source + successor before mutation → prepare a complete successor while **retaining** the source → publish **one** immutable identity tombstone (the commit point) → migrate every Project-owned database reference → treat source cleanup as retryable completion. State machine: `Requested → TargetPrepared → IdentityPublished → ReferencesRetired → CleanupPending → Completed`. **D4.** It rejects a successor that is itself retired or belongs to an independent Project. **It is Identity Retirement, not the explicitly-deferred Project Merge**: exactly one logical Project's old identity converges into its new identity, preserving one Memory (no fork). Distinct from combining two independently-owned Projects. + +### Internal transaction witness (not exported) +Runtime lifetime fence (`open → closing → closed`); never exposes persistence paths. Even an escaped fiber cannot use it after close; an operation that has entered is completed before the OS flock releases. Effect Context is **not** proof that an OS lock remains held. + +### Locking +- Routine: `join/recover touched retirement → resolve requested ID → one canonical Project flock → resolve again → retry on change`. Never extends a held set; never reaches the identity-ledger lock. +- Retirement: read ledger unlocked to derive expected keys → acquire the **complete sorted** `({source,successor})` Project flock set → acquire the identity-ledger flock → revalidate. Order preserves the rolling-upgrade key order used by supported older processes (no ABBA). Recovery completes before any routine Project lock; routine never goes Project → ledger. + +### Atomic Topics + Policy + revision (**D1**) +Project Memory Topics, Project configuration (Policy), topology, and admission inputs contribute to **one** opaque `Revision`. **Policy lives in the Memory Home** generation (`policy.jsonc` co-tenant with topic YAML); the Home generation is the single atomic publish point (temp-dir → rename → manifest). Worktree `.opencode/memory.jsonc|.json` and the global config are **admission candidates only**, not authorities. *(Supersedes ADR-0001's "Policy resolved from the primary directory so it stays user-editable": under this ADR the controller owns Policy, atomically versioned with Topics. Global config remains a fallback candidate admitted when no Home Policy exists.)* Reads are pure; normalization never writes. + +### Worktree lifecycle (integration via an internal guard) +Worktree reset/remove remain the Worktree authority's operations, integrated through an internal, non-exported `ProjectMemoryDestructionGuard` whose sealed durable intent binds `{request_id, requested_project, identity_revision, normalized_target, action, topology_fingerprint, candidate_fingerprint}`. Every execution/recovery first joins Identity Retirement and re-resolves the requested Project; an identity-revision change rebases the intent and rescans before publication. The guard publishes valid candidates into the authoritative generation before invoking the one fixed action adapter, so action failure leaves only safe legacy duplicates. Destructive admission **never** trusts a process-local success cache — it rescans the Project primary + every registered worktree each time. + +### Automatic Identity Retirement +Limited to **verifiable first identity convergence**: source = the observed repository's root commit; the repo-local cache names it `previous`; current resolution selects the successor from the remote identity; every existing successor directory re-resolves to that successor (different physical stores allowed — one remote Project may have several clones). A remote X→Y change, contradictory observation, or unavailable evidence **fails closed** and does not become an implicit Project Merge. + +### Crash semantics +Commit point = the immutable tombstone. Pre-tombstone: source authoritative. Post-tombstone: successor authoritative, old revisions invalid; recovery rebuilds target from the latest two-sided state if either side changed after `TargetPrepared`. Each public command first joins/recovers touched journals. Source Home is preserved (non-authoritative) and is never read as an authority after the tombstone. + +## Decisions D1–D4 (resolved) +- **D1 — Policy in Home generation**, atomic with Topics, one Revision; worktree/global config = admission candidates. *(Supersedes ADR-0001's primary-directory Policy.)* +- **D2 — Source Home preserved**, non-authoritative; old ID routes to successor via alias. GC/retention is **excluded** this round; retired Homes remain as backups indefinitely. *(ADR-0001/0002's retention precondition is satisfied by "retain indefinitely; GC deferred" — no non-authoritative artifact is ever silently collected.)* +- **D3 — Identity Retirement approved**: old ID retires into successor; immutable alias tombstone + canonical chain + deterministic-union into ONE Memory (no fork). Distinct from the forbidden Project Merge. +- **D4 — Surface confirmed**: 6-phase forward-only journal; opaque Revision fingerprint (identity + Topics revision + Policy + topology + admission); `changeMemory` Change algebra. + +## Consequences +- `MemoryLock` (public), alias mutation, manual admission invalidation, project-directory configuration writes, and callback-shaped identity migration are **removed from application callers**; they survive only as authority-private adapters. +- Crashes across filesystem and SQLite are recovered by **advancing** the recorded retirement state; no cross-store rollback is promised. +- The source Home may temporarily remain after identity publication; it is non-authoritative and cannot be recreated through the retired ID. +- An immutable `ProjectLineageID` could remove identity movement entirely but needs a new product identity, schema backfill, rolling-upgrade protocol, and explicit Project Merge semantics — **deferred** to a separate proposal. +- Per AGENTS §7 review: the legacy `inspectHome` allow-list (`memory/identity-migration.ts:43-54`) must either be extended to the new Home contents (policy.jsonc) or the legacy path is retired when the P6 cutover lands — it must not fail-closed on a modern Home. diff --git a/packages/opencode/src/memory/home.ts b/packages/opencode/src/memory/home.ts new file mode 100644 index 0000000000..14e10fcc74 --- /dev/null +++ b/packages/opencode/src/memory/home.ts @@ -0,0 +1,36 @@ +export * as MemoryHome from "./home" + +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Global } from "@opencode-ai/core/global" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Hash } from "@opencode-ai/core/util/hash" +import { Context, Layer } from "effect" +import { join } from "node:path" + +export interface Interface { + readonly directory: (projectID: ProjectV2.ID) => string + readonly topics: (projectID: ProjectV2.ID) => string + readonly manifest: (projectID: ProjectV2.ID) => string + readonly generations: (projectID: ProjectV2.ID) => string + readonly locks: string +} + +export class Service extends Context.Service()("@opencode/MemoryHome") {} + +export function make(dataRoot: string): Interface { + const directory = (projectID: ProjectV2.ID) => + join(dataRoot, "memory", "projects", Hash.sha256(`memory-project:${projectID}`)) + return Service.of({ + directory, + topics: (projectID) => join(directory(projectID), "topics"), + manifest: (projectID) => join(directory(projectID), "manifest.json"), + generations: (projectID) => join(directory(projectID), "generations"), + locks: join(dataRoot, "memory", "locks"), + }) +} + +export const layer = Layer.succeed(Service, make(Global.Path.data)) + +export const defaultLayer = layer + +export const node = LayerNode.make(layer, []) diff --git a/packages/opencode/src/memory/identity-fence.ts b/packages/opencode/src/memory/identity-fence.ts new file mode 100644 index 0000000000..fdcdb643b8 --- /dev/null +++ b/packages/opencode/src/memory/identity-fence.ts @@ -0,0 +1,75 @@ +export * as MemoryIdentityFence from "./identity-fence" + +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectV2 } from "@opencode-ai/core/project" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Context, Effect, Layer, Option } from "effect" +import { eq } from "drizzle-orm" +import { MemoryHome } from "./home" + +/** + * Single authority for the `memory-identity:` fence protocol. + * + * Every reader/writer that serializes against identity retirement goes through + * `withLiveIdentity`, which owns the whole protocol: the lock key, the lock + * directory, AND the in-fence identity-liveness recheck. Before this module + * the protocol was hand-duplicated at four sites across three files, which let + * the admission path diverge from the writer discipline (a retired identity + * could re-create its Home). With the protocol here, no new path can forget + * the recheck. + * + * The retirement seam (ProjectIdentityMigration.migrate) is the only raw + * holder: it deletes the identity row inside the fence, so it cannot recheck + * liveness. It builds its key from `MemoryIdentityFence.key` so the key + * convention still has exactly one source. + */ +export interface Interface { + /** + * Run `body` inside the cross-process `memory-identity:` fence, and only + * if the identity row still exists. Returns `Option.none()` when the row was + * retired between the caller's earlier check and fence acquisition — the + * caller must then fail closed instead of writing under a retired identity. + */ + readonly withLiveIdentity: ( + id: ProjectV2.ID, + body: Effect.Effect, + ) => Effect.Effect, E | EffectFlock.LockError, R> +} + +export const key = (id: ProjectV2.ID) => `memory-identity:${id}` + +export class Service extends Context.Service()("@opencode/MemoryIdentityFence") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const home = yield* MemoryHome.Service + const { db } = yield* Database.Service + return Service.of({ + withLiveIdentity: (id, body) => + flock.withLock( + Effect.gen(function* () { + // Same fail-closed stance as Project.get: a query error here means + // the storage layer is unusable — die loudly rather than silently + // importing into a possibly-retired Home. + const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie) + if (!row) return Option.none() + return Option.some(yield* body) + }), + key(id), + home.locks, + ), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(MemoryHome.defaultLayer), + Layer.provide(Database.defaultLayer), +) + +export const node = LayerNode.make(layer, [EffectFlock.node, MemoryHome.node, Database.node]) diff --git a/packages/opencode/src/memory/identity-migration.ts b/packages/opencode/src/memory/identity-migration.ts new file mode 100644 index 0000000000..d63d1a0e4c --- /dev/null +++ b/packages/opencode/src/memory/identity-migration.ts @@ -0,0 +1,211 @@ +export * as MemoryIdentityMigration from "./identity-migration" + +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProjectV2 } from "@opencode-ai/core/project" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Context, Effect, Exit, Layer, Schema } from "effect" +import { dirname, join } from "node:path" +import { MemoryHome } from "./home" +import { MemorySchema } from "./schema" +import { MemoryStore } from "./store" + +export interface Interface { + readonly migrateHome: ( + oldID: ProjectV2.ID, + newID: ProjectV2.ID, + ) => Effect.Effect< + void, + FSUtil.Error | EffectFlock.LockError | MemoryStore.StoreError | ConflictError | InvalidHomeError | SourceChangedError + > +} + +export class Service extends Context.Service()("@opencode/MemoryIdentityMigration") {} + +export class ConflictError extends Schema.TaggedErrorClass()("MemoryIdentityMigration.Conflict", { + topic_ids: Schema.Array(Schema.String), +}) {} + +export class InvalidHomeError extends Schema.TaggedErrorClass()( + "MemoryIdentityMigration.InvalidHome", + { + paths: Schema.Array(Schema.String), + }, +) {} + +/** + * The source Home changed while the migration was merging it into the target + * (a process still running under the old identity committed). Nothing was + * removed; the migration is safe to retry and converges. + */ +export class SourceChangedError extends Schema.TaggedErrorClass()( + "MemoryIdentityMigration.SourceChanged", + { + project_id: Schema.String, + }, +) {} + +// Content identity for the migration merge: everything except the metadata fields +// the match controller mutates on live topics (MemoryStore.markMatched bumps +// last_matched_at / match_count / revision / updated_at without touching content). +// Two topics that differ only in those must not register as a user-visible conflict. +function sameContent(left: MemorySchema.Topic, right: MemorySchema.Topic): boolean { + const content = (topic: MemorySchema.Topic) => + JSON.stringify({ + schema_version: topic.schema_version, + id: topic.id, + name: topic.name, + summary: topic.summary, + metadata: { + categories: topic.metadata.categories, + status: topic.metadata.status, + importance: topic.metadata.importance, + keywords: topic.metadata.keywords, + related_topics: topic.metadata.related_topics, + created_at: topic.metadata.created_at, + item_count: topic.metadata.item_count, + }, + items: topic.items, + }) + return content(left) === content(right) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + + const inspectHome = Effect.fnUntraced(function* (directory: string) { + const unexpected = (yield* fs.readDirectoryEntries(directory)).filter( + (entry) => + !( + (entry.name === "topics" && entry.type === "directory") || + (entry.name === "generations" && entry.type === "directory") || + (entry.name === "manifest.json" && entry.type === "file") || + // The store's own atomicWrite residue (`manifest.json...tmp`) + // is left behind if a process dies between the temp write and the rename. + // It is harmless garbage, not foreign state — rejecting it would wedge + // every identity upgrade after such a crash. + (entry.type === "file" && entry.name.startsWith("manifest.json.") && entry.name.endsWith(".tmp")) + ), + ) + if (unexpected.length === 0) return + yield* new InvalidHomeError({ paths: unexpected.map((entry) => join(directory, entry.name)) }) + }) + + // Three-phase merge. Locking rules that make opposite-direction migrations + // (remote→remote identity changes) deadlock-free: + // - a dedicated pair lock serializes the two directions of the same pair; + // - at most ONE `memory-project:*` lock is held at any moment (phases 1 and + // 3 hold the source lock, phase 2 holds none — the store locks the target + // itself inside updateTopics), so no hold-and-wait cycle can form between + // concurrent migrations or with writers on either identity. + const migrateHomeUnsafe = Effect.fnUntraced(function* ( + oldID: ProjectV2.ID, + newID: ProjectV2.ID, + ) { + const source = home.directory(oldID) + const target = home.directory(newID) + + // Phase 1 — snapshot the source under the source lock. If the target does + // not exist yet the whole migration is a rename under the same lock. + const snapshot = yield* flock.withLock( + Effect.gen(function* () { + if (!(yield* fs.existsSafe(source))) return undefined + yield* fs.makeDirectory(dirname(target), { recursive: true }) + if (!(yield* fs.existsSafe(target))) { + const renamed = yield* fs.rename(source, target).pipe(Effect.exit) + if (Exit.isSuccess(renamed)) return undefined + // A writer under newID created the target between existsSafe and + // rename (ENOTEMPTY/EEXIST race). Nothing was removed — fall + // through to the snapshot-merge path below, which converges. + if (!(yield* fs.existsSafe(target))) return yield* renamed + } + yield* inspectHome(source) + return yield* store.readSnapshot(oldID) + }), + `memory-project:${oldID}`, + home.locks, + ) + if (!snapshot) return + + // Phase 2 — merge into the target. updateTopics takes the target lock. + yield* inspectHome(target) + const targetTopics = yield* store.inspectTopics(newID) + const targetByID = new Map(targetTopics.map((topic) => [topic.id, topic])) + const conflicts = snapshot.topics + .filter((topic) => { + const current = targetByID.get(topic.id) + return current && !sameContent(current, topic) + }) + .map((topic) => topic.id) + if (conflicts.length > 0) yield* new ConflictError({ topic_ids: conflicts }) + + const imported = snapshot.topics.filter((topic) => !targetByID.has(topic.id)) + if (imported.length > 0) { + yield* store.updateTopics(newID, (topics) => { + const current = new Map(topics.map((topic) => [topic.id, topic])) + const conflicts = imported.filter((topic) => { + const existing = current.get(topic.id) + return existing && !sameContent(existing, topic) + }) + if (conflicts.length > 0) + throw new MemoryStore.StoreError({ + message: `Memory identity migration conflicted for Topics: ${conflicts.map((topic) => topic.id).join(", ")}`, + }) + const changed = imported.filter((topic) => !current.has(topic.id)) + changed.forEach((topic) => current.set(topic.id, topic)) + return { + applied: { + topics: Array.from(current.values()).sort((left, right) => left.id.localeCompare(right.id)), + changed: changed.map((topic) => topic.id), + deleted: [], + }, + result: undefined, + } + }) + } + + // Phase 3 — remove the source only if it has not changed since the + // snapshot; otherwise leave everything in place for a converging retry. + yield* flock.withLock( + Effect.gen(function* () { + if (!(yield* fs.existsSafe(source))) return + const current = yield* store.readSnapshot(oldID) + if (current.revision !== snapshot.revision) yield* new SourceChangedError({ project_id: oldID }) + yield* fs.remove(source, { recursive: true }) + }), + `memory-project:${oldID}`, + home.locks, + ) + }) + + const migrateHome: Interface["migrateHome"] = (oldID, newID) => { + if (oldID === newID) return Effect.void + const pair = [oldID, newID].sort().join("|") + // The memory-identity: fence is held by the retirement seam + // (ProjectIdentityMigration.migrate), which wraps this call together with + // the reference/row retirement so the fence covers the whole retirement. + // Here we only serialize opposite-direction migrations via the pair lock. + // Lock order (outermost→innermost): memory-identity (held by caller) → + // memory-migrate (pair) → memory-project (inside migrateHomeUnsafe). + return flock + .withLock(migrateHomeUnsafe(oldID, newID), `memory-migrate:${pair}`, home.locks) + .pipe(Effect.asVoid, Effect.withSpan("MemoryIdentityMigration.migrateHome")) + } + + return Service.of({ migrateHome }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(MemoryHome.defaultLayer), + Layer.provide(MemoryStore.defaultLayer), +) + +export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node, MemoryHome.node, MemoryStore.node]) diff --git a/packages/opencode/src/memory/lock.ts b/packages/opencode/src/memory/lock.ts new file mode 100644 index 0000000000..e51cb31595 --- /dev/null +++ b/packages/opencode/src/memory/lock.ts @@ -0,0 +1,21 @@ +export * as MemoryLock from "./lock" + +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Context, Effect, Layer } from "effect" + +export interface Interface { + readonly withProject: (projectID: ProjectV2.ID) => (effect: Effect.Effect) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/MemoryLock") {} + +export const layer = Layer.sync(Service, () => { + const locks = KeyedMutex.makeUnsafe() + return Service.of({ withProject: (projectID) => locks.withLock(projectID) }) +}) + +export const defaultLayer = layer + +export const node = LayerNode.make(layer, []) diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index d5402dac62..a9a3fe0b3f 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -1,7 +1,7 @@ export * as Memory from "./memory" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" +import { ProjectV2 } from "@opencode-ai/core/project" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Context, Duration, Effect, Layer, Option, Ref, Schema, Semaphore } from "effect" import { stringify } from "yaml" @@ -11,7 +11,10 @@ import { Project } from "@/project/project" import { InstanceState } from "@/effect/instance-state" import { MessageID, SessionID } from "@/session/schema" import { Token } from "@/util/token" +import { MemoryAdmission } from "./admission" import { MemoryConfig } from "./config" +import { MemoryIdentityFence } from "./identity-fence" +import { MemoryLock } from "./lock" import { MemoryModel } from "./model" import { MemoryPrompts } from "./prompts" import { MemorySchema } from "./schema" @@ -62,19 +65,29 @@ export class ControllerError extends Schema.TaggedErrorClass()( export const layer: Layer.Layer< Service, never, - Config.Service | Provider.Service | Project.Service | MemoryConfig.Service | MemoryModel.Service | MemoryStore.Service + | Config.Service + | Provider.Service + | Project.Service + | MemoryAdmission.Service + | MemoryConfig.Service + | MemoryIdentityFence.Service + | MemoryLock.Service + | MemoryModel.Service + | MemoryStore.Service > = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service const provider = yield* Provider.Service const project = yield* Project.Service + const fence = yield* MemoryIdentityFence.Service + const admission = yield* MemoryAdmission.Service const configStore = yield* MemoryConfig.Service + const lock = yield* MemoryLock.Service const modelCalls = yield* MemoryModel.Service const store = yield* MemoryStore.Service const globalStarted = yield* Ref.make(false) const initializationLock = Semaphore.makeUnsafe(1) - const locks = KeyedMutex.makeUnsafe() const state = yield* InstanceState.make(() => Effect.succeed({ sessions: new Map() })) const availableModels = Effect.fn("Memory.availableModels")(function* () { @@ -164,9 +177,40 @@ export const layer: Layer.Layer< const configuration = Effect.fn("Memory.configuration")(function* () { const ctx = yield* InstanceState.context - const current = (yield* project.get(ctx.project.id)) ?? ctx.project + // No fallback to the instance context: a missing row means the identity + // was retired by a concurrent upgrade (or never registered). Resurrecting + // the stale context identity would fork a Home under a retired Project — + // fail closed instead and stay inert. + const current = yield* project.get(ctx.project.id) + if (!current) return undefined + // Fail-closed inertness for the shared global identity: every commit-less + // repository resolves to the same ProjectV2.ID.global, so an active Memory + // would share one Home across unrelated repositories and be orphaned by the + // first commit (migrateProjectId never migrates away from global). Memory + // activates once the repository gains a real identity. + if (current.id === ProjectV2.ID.global) return undefined if (current.vcs !== "git" || !current.time.initialized) return undefined - return { ctx, loaded: yield* configStore.load(ctx.worktree) } + const migration = yield* admission + .ensure({ + projectID: current.id, + projectDirectory: current.worktree, + directories: Array.from(new Set([current.worktree, ...current.sandboxes, ctx.worktree])), + updated: current.time.updated, + }) + .pipe(Effect.catchTag("MemoryAdmission.IdentityRetired", () => Effect.succeed(undefined))) + // The identity was retired between the row check above and the fence + // acquisition: fail closed and stay inert. + if (!migration) return undefined + if (migration.unresolved) { + yield* Effect.logWarning("Project MEMORY migration needs manual repair", { + projectID: current.id, + diagnostics: migration.diagnostics.filter( + (item) => item.code.endsWith(".invalid") || item.code.endsWith(".conflict"), + ), + }) + return undefined + } + return { ctx, project: current, loaded: yield* configStore.load(current.worktree) } }) const resolveModel = Effect.fn("Memory.resolveModel")(function* (config: MemorySchema.Config) { @@ -229,7 +273,7 @@ export const layer: Layer.Layer< config: MemorySchema.Config topics: MemorySchema.Topic[] messages: SessionV1.WithParts[] - worktree: string + projectID: Project.Info["id"] }) { const evidence = maintenanceEvidence(input.messages) if (!evidence) return input.topics @@ -259,22 +303,16 @@ export const layer: Layer.Layer< const decoded = Schema.decodeUnknownOption(MemorySchema.MaintenanceResponse)(output) if (Option.isNone(decoded)) return yield* new ControllerError({ message: "MEMORY maintenance returned invalid output" }) - const applied = yield* Effect.try({ - try: () => - MemoryStore.applyActions({ - topics: input.topics, + return yield* store + .updateTopics(input.projectID, (topics) => ({ + applied: MemoryStore.applyActions({ + topics, actions: decoded.value.actions, topicLimit: input.config.topic_limit, }), - catch: (cause) => - cause instanceof MemoryStore.StoreError - ? cause - : new MemoryStore.StoreError({ message: `MEMORY action validation failed: ${String(cause)}` }), - }) - if (applied.changed.length === 0 && applied.deleted.length === 0) return applied.topics - yield* store.ensureGitExclude(input.worktree) - yield* store.writeTopics(input.worktree, applied) - return applied.topics + result: undefined, + })) + .pipe(Effect.map((updated) => updated.topics)) }) const select = Effect.fn("Memory.select")(function* (input: { @@ -282,14 +320,13 @@ export const layer: Layer.Layer< config: MemorySchema.Config topics: MemorySchema.Topic[] text: string - worktree: string + projectID: Project.Info["id"] }) { const topicIDs = yield* match(input) - const matched = MemoryStore.markMatched(input.topics, topicIDs) - if (matched.changed.length > 0) { - yield* store.ensureGitExclude(input.worktree) - yield* store.writeTopics(input.worktree, matched) - } + const matched = yield* store.updateTopics(input.projectID, (topics) => ({ + applied: MemoryStore.markMatched(topics, topicIDs), + result: undefined, + })) const byID = new Map(matched.topics.map((topic) => [topic.id, topic])) const selected = topicIDs.flatMap((id) => { const topic = byID.get(id) @@ -329,39 +366,50 @@ export const layer: Layer.Layer< session.firstTurnAttempted = true if (!due && !shouldMatch) return - yield* locks.withLock(current.ctx.worktree)( + // Cross-process identity guard (see checkpointUnsafe): MemoryIdentityFence + // re-checks identity liveness under the identity lock before writing. + const live = yield* fence.withLiveIdentity( + current.project.id, Effect.gen(function* () { - const topics = yield* store.readTopics(current.ctx.worktree) - const maintained = due - ? yield* maintain({ - model: current.model, - config: current.loaded.config, - topics, - messages: input.messages, - worktree: current.ctx.worktree, - }).pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logWarning("periodic MEMORY maintenance failed", { cause }) - return topics - }), - ), - ) - : topics - const rendered = shouldMatch - ? (yield* select({ - model: current.model, - config: current.loaded.config, - topics: maintained, - text: user.text, - worktree: current.ctx.worktree, - })).rendered - : (data.sessions.get(input.sessionID)?.turn.rendered ?? []) - const entry = data.sessions.get(input.sessionID) - if (entry?.turn.messageID !== user.info.id) return - entry.turn = { ...entry.turn, completedTurns: turns, rendered } + yield* lock.withProject(current.project.id)( + Effect.gen(function* () { + const topics = yield* store.readTopics(current.project.id) + const maintained = due + ? yield* maintain({ + model: current.model, + config: current.loaded.config, + topics, + messages: input.messages, + projectID: current.project.id, + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("periodic MEMORY maintenance failed", { cause }) + return topics + }), + ), + ) + : topics + const rendered = shouldMatch + ? (yield* select({ + model: current.model, + config: current.loaded.config, + topics: maintained, + text: user.text, + projectID: current.project.id, + })).rendered + : (data.sessions.get(input.sessionID)?.turn.rendered ?? []) + const entry = data.sessions.get(input.sessionID) + if (entry?.turn.messageID !== user.info.id) return + entry.turn = { ...entry.turn, completedTurns: turns, rendered } + }), + ) }), ) + if (Option.isNone(live)) { + yield* clearSession(input.sessionID) + return + } }) const prepare: Interface["prepare"] = Effect.fn("Memory.prepare")((input) => @@ -423,36 +471,48 @@ export const layer: Layer.Layer< } const origin = user.info.id - return yield* locks.withLock(current.ctx.worktree)( + // Cross-process identity guard (see checkpointUnsafe): MemoryIdentityFence + // re-checks identity liveness under the identity lock before matching/writing. + const live = yield* fence.withLiveIdentity( + current.project.id, Effect.gen(function* () { - const activeTurn = data.sessions.get(input.sessionID)?.turn - if (activeTurn?.messageID !== origin) return { status: "stale" as const } - const repeated = activeTurn.queries.get(key) - if (repeated) { - activeTurn.rendered = repeated.rendered - return repeated.count > 0 - ? { status: "attached" as const, count: repeated.count, reused: true } - : { status: "empty" as const, reused: true } - } - if (activeTurn.queryCount >= 2) return { status: "limit" as const } - activeTurn.queryCount++ - const topics = yield* store.readTopics(current.ctx.worktree) - const selected = yield* select({ - model: current.model, - config: current.loaded.config, - topics, - text: query, - worktree: current.ctx.worktree, - }) - const latest = data.sessions.get(input.sessionID)?.turn - if (latest?.messageID !== origin) return { status: "stale" as const } - latest.queries.set(key, selected) - latest.rendered = selected.rendered - return selected.count > 0 - ? { status: "attached" as const, count: selected.count, reused: false } - : { status: "empty" as const, reused: false } + return yield* lock.withProject(current.project.id)( + Effect.gen(function* () { + const activeTurn = data.sessions.get(input.sessionID)?.turn + if (activeTurn?.messageID !== origin) return { status: "stale" as const } + const repeated = activeTurn.queries.get(key) + if (repeated) { + activeTurn.rendered = repeated.rendered + return repeated.count > 0 + ? { status: "attached" as const, count: repeated.count, reused: true } + : { status: "empty" as const, reused: true } + } + if (activeTurn.queryCount >= 2) return { status: "limit" as const } + activeTurn.queryCount++ + const topics = yield* store.readTopics(current.project.id) + const selected = yield* select({ + model: current.model, + config: current.loaded.config, + topics, + text: query, + projectID: current.project.id, + }) + const latest = data.sessions.get(input.sessionID)?.turn + if (latest?.messageID !== origin) return { status: "stale" as const } + latest.queries.set(key, selected) + latest.rendered = selected.rendered + return selected.count > 0 + ? { status: "attached" as const, count: selected.count, reused: false } + : { status: "empty" as const, reused: false } + }), + ) }), ) + if (Option.isNone(live)) { + yield* clearSession(input.sessionID) + return { status: "unavailable" as const } + } + return live.value }) const search: Interface["search"] = Effect.fn("Memory.search")((input) => @@ -477,33 +537,49 @@ export const layer: Layer.Layer< return [] } const user = latestRealUser(input.messages) - return yield* locks.withLock(current.ctx.worktree)( + // Cross-process identity guard: a concurrent upgrade may retire this + // identity (row deleted, Home renamed away) while this write is in + // flight. Serialize on the identity lock and re-check liveness inside it; + // writing after retirement would re-create the retired Home and orphan + // the new content permanently (the identity cache already points at the + // successor, so no migration would ever run for this pair again). + const live = yield* fence.withLiveIdentity( + current.project.id, Effect.gen(function* () { - const topics = yield* store.readTopics(current.ctx.worktree) - const maintained = yield* maintain({ - model: current.model, - config: current.loaded.config, - topics, - messages: input.messages, - worktree: current.ctx.worktree, - }).pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logWarning("pre-compaction MEMORY maintenance failed", { cause }) - return topics - }), - ), + return yield* lock.withProject(current.project.id)( + Effect.gen(function* () { + const topics = yield* store.readTopics(current.project.id) + const maintained = yield* maintain({ + model: current.model, + config: current.loaded.config, + topics, + messages: input.messages, + projectID: current.project.id, + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("pre-compaction MEMORY maintenance failed", { cause }) + return topics + }), + ), + ) + const rendered = (yield* select({ + model: current.model, + config: current.loaded.config, + topics: maintained, + text: user?.text ?? "", + projectID: current.project.id, + })).rendered + return rendered + }), ) - const rendered = (yield* select({ - model: current.model, - config: current.loaded.config, - topics: maintained, - text: user?.text ?? "", - worktree: current.ctx.worktree, - })).rendered - return rendered }), ) + if (Option.isNone(live)) { + yield* clearSession(input.sessionID) + return [] + } + return live.value }) const checkpoint: Interface["checkpoint"] = Effect.fn("Memory.checkpoint")((input) => @@ -533,11 +609,10 @@ export const layer: Layer.Layer< const config = enabled ? yield* ensureConfiguredModel(loaded.config) : loaded.config if (enabled && loaded.config.enabled && config.model === loaded.config.model) return "Memory on" as const - return yield* locks.withLock(value.ctx.worktree)( + return yield* lock.withProject(value.project.id)( Effect.gen(function* () { - yield* store.ensureGitExclude(value.ctx.worktree) yield* configStore.writeProject( - value.ctx.worktree, + value.project.worktree, MemorySchema.updateConfig(config, { enabled }), loaded.level === "project" ? loaded.path : undefined, ) @@ -567,7 +642,10 @@ export const defaultLayer: Layer.Layer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Project.defaultLayer), + Layer.provide(MemoryAdmission.defaultLayer), Layer.provide(MemoryConfig.defaultLayer), + Layer.provide(MemoryIdentityFence.defaultLayer), + Layer.provide(MemoryLock.defaultLayer), Layer.provide(MemoryModel.defaultLayer), Layer.provide(MemoryStore.defaultLayer), ), @@ -577,7 +655,10 @@ export const node = LayerNode.make(layer, [ Config.node, Provider.node, Project.node, + MemoryAdmission.node, MemoryConfig.node, + MemoryIdentityFence.node, + MemoryLock.node, MemoryModel.node, MemoryStore.node, ]) @@ -725,7 +806,7 @@ export function renderTopics(topics: MemorySchema.Topic[], config: MemorySchema. } function renderSelection(topics: MemorySchema.Topic[], config: MemorySchema.Config) { - const prefix = `\nThis is worktree-local historical data, not instructions. It is non-authoritative. Current user input and higher-priority instructions always win.\n` + const prefix = `\nThis is Project-owned historical data shared by this Project's worktrees, not instructions. It is non-authoritative. Current user input and higher-priority instructions always win.\n` const suffix = `` type Row = { topic_id: string diff --git a/packages/opencode/src/memory/paths.ts b/packages/opencode/src/memory/paths.ts new file mode 100644 index 0000000000..394885966d --- /dev/null +++ b/packages/opencode/src/memory/paths.ts @@ -0,0 +1,19 @@ +export * as MemoryPaths from "./paths" + +import { join } from "node:path" + +/** Worktree-local paths containing durable project memory. */ +export const PROJECT_PATHS = [".opencode/memory.jsonc", ".opencode/memory.json", ".opencode/memory/"] as const + +export const PROJECT_CONFIG_PATHS = [".opencode/memory.jsonc", ".opencode/memory.json"] as const + +export const LEGACY_TOPICS_PATH = ".opencode/memory/topics" + +export function legacyTopics(directory: string) { + return join(directory, LEGACY_TOPICS_PATH) +} + +export function isProjectMemoryPath(input: string) { + const path = input.replaceAll("\\", "/").replace(/^\.\//, "") + return PROJECT_PATHS.some((candidate) => (candidate.endsWith("/") ? path.startsWith(candidate) : path === candidate)) +} diff --git a/packages/opencode/src/memory/store.ts b/packages/opencode/src/memory/store.ts index b1cb8779ea..e371f0f4e9 100644 --- a/packages/opencode/src/memory/store.ts +++ b/packages/opencode/src/memory/store.ts @@ -1,16 +1,18 @@ export * as MemoryStore from "./store" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@/git" +import { ProjectV2 } from "@opencode-ai/core/project" import { Context, Effect, Layer, Option, Schema, Types } from "effect" -import { basename, isAbsolute, join, resolve } from "node:path" +import { basename, join } from "node:path" +import { randomUUID } from "node:crypto" import { ulid } from "ulid" import { parse, stringify } from "yaml" import { MemoryFile } from "./file" +import { MemoryHome } from "./home" import { MemorySchema } from "./schema" -const EXCLUDE_RULES = [".opencode/memory.jsonc", ".opencode/memory.json", ".opencode/memory/"] as const const TOPIC_KEYS = ["schema_version", "id", "name", "summary", "metadata", "items"] as const const METADATA_KEYS = [ "categories", @@ -92,37 +94,84 @@ export type Applied = { readonly deleted: string[] } +export type Update = { + readonly applied: Applied + readonly result: A +} + +export type Snapshot = { + readonly revision: number + readonly topics: MemorySchema.Topic[] +} + type MutableTopic = Types.DeepMutable export interface Interface { - readonly readTopics: (worktree: string) => Effect.Effect - readonly writeTopics: (worktree: string, applied: Applied) => Effect.Effect - readonly ensureGitExclude: (worktree: string) => Effect.Effect + readonly readTopics: (projectID: ProjectV2.ID) => Effect.Effect + readonly readSnapshot: ( + projectID: ProjectV2.ID, + ) => Effect.Effect + readonly commit: ( + projectID: ProjectV2.ID, + expectedRevision: number, + applied: Applied, + ) => Effect.Effect + readonly inspectTopics: ( + projectID: ProjectV2.ID, + ) => Effect.Effect + readonly updateTopics: ( + projectID: ProjectV2.ID, + update: (topics: MemorySchema.Topic[]) => Update, + ) => Effect.Effect< + Snapshot & { result: A }, + FSUtil.Error | StoreError | EffectFlock.LockError + > } export class StoreError extends Schema.TaggedErrorClass()("MemoryStore.Error", { message: Schema.String, }) {} +export class CommitConflictError extends Schema.TaggedErrorClass()("MemoryStore.CommitConflict", { + expected_revision: Schema.Number, + actual_revision: Schema.Number, +}) {} + export class Service extends Context.Service()("@opencode/MemoryStore") {} +const Manifest = Schema.Struct({ + schema_version: Schema.Literal(1), + revision: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)), + generation: Schema.String, +}) +const ManifestJson = Schema.fromJsonString(Manifest) +const decodeManifest = Schema.decodeUnknownOption(ManifestJson) + export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service - const git = yield* Git.Service + const flock = yield* EffectFlock.Service + const home = yield* MemoryHome.Service - const readTopics = Effect.fn("MemoryStore.readTopics")(function* (worktree: string) { - const directory = topicsDir(worktree) + const readDirectoryTopics = Effect.fnUntraced(function* (directory: string, strict = false) { if (!(yield* fs.existsSafe(directory))) return [] - const names = (yield* fs.readDirectoryEntries(directory)) + const entries = yield* fs.readDirectoryEntries(directory) + if (strict) { + const unexpected = entries.filter((entry) => entry.type !== "file" || !entry.name.endsWith(".yaml")) + if (unexpected.length > 0) + return yield* new StoreError({ + message: `Memory topics directory contains unexpected entries: ${unexpected.map((entry) => entry.name).join(", ")}`, + }) + } + const names = entries .filter((entry) => entry.type === "file" && entry.name.endsWith(".yaml")) .map((entry) => entry.name) .sort() const topics = yield* Effect.forEach( names, - (name) => - Effect.gen(function* () { + (name) => { + const read = Effect.gen(function* () { const file = join(directory, name) const text = yield* fs.readFileString(file) const value = yield* Effect.try({ @@ -131,62 +180,162 @@ export const layer = Layer.effect( }) const decoded = decodeTopic(value, basename(name, ".yaml")) if (decoded) return decoded + if (strict) return yield* new StoreError({ message: `Memory topic is invalid: ${file}` }) yield* Effect.logWarning("memory topic is invalid — ignoring", { path: file }) return undefined - }).pipe( + }) + if (strict) return read + return read.pipe( Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logWarning("memory topic read failed — ignoring", { path: name, cause }) return undefined }), ), - ), + ) + }, { concurrency: 8 }, ) return topics.filter((topic): topic is MemorySchema.Topic => topic !== undefined) }) - const writeTopics = Effect.fn("MemoryStore.writeTopics")(function* (worktree: string, applied: Applied) { - yield* fs.makeDirectory(topicsDir(worktree), { recursive: true }) + const writeDirectoryTopics = Effect.fnUntraced(function* (directory: string, applied: Applied) { + yield* fs.makeDirectory(directory, { recursive: true }) const byID = new Map(applied.topics.map((topic) => [topic.id, topic])) yield* Effect.forEach( applied.changed, (id) => { const topic = byID.get(id) if (!topic) return Effect.void - return MemoryFile.atomicWrite(fs, join(topicsDir(worktree), `${id}.yaml`), stringify(topic, { lineWidth: 0 })) + return MemoryFile.atomicWrite(fs, join(directory, `${id}.yaml`), stringify(topic, { lineWidth: 0 })) }, { concurrency: 1, discard: true }, ) - yield* Effect.forEach( - applied.deleted, - (id) => fs.remove(join(topicsDir(worktree), `${id}.yaml`), { force: true }), - { concurrency: 1, discard: true }, + yield* Effect.forEach(applied.deleted, (id) => fs.remove(join(directory, `${id}.yaml`), { force: true }), { + concurrency: 1, + discard: true, + }) + }) + + const readSnapshotUnsafe = Effect.fnUntraced(function* (projectID: ProjectV2.ID, strict: boolean) { + const text = yield* fs.readFileStringSafe(home.manifest(projectID)) + if (text === undefined) + return { + revision: 0, + topics: yield* readDirectoryTopics(home.topics(projectID), strict), + } satisfies Snapshot + const decoded = decodeManifest(text) + if (Option.isNone(decoded) || !/^[a-z0-9-]+$/.test(decoded.value.generation)) + return yield* new StoreError({ message: "Memory generation manifest is invalid" }) + const directory = join(home.generations(projectID), decoded.value.generation) + if (!(yield* fs.existsSafe(directory))) + return yield* new StoreError({ message: "Memory generation referenced by manifest is missing" }) + return { + revision: decoded.value.revision, + topics: yield* readDirectoryTopics(directory, strict), + } satisfies Snapshot + }) + + const writeSnapshot = Effect.fnUntraced(function* ( + projectID: ProjectV2.ID, + revision: number, + topics: MemorySchema.Topic[], + ) { + if ( + new Set(topics.map((topic) => topic.id)).size !== topics.length || + topics.some((topic) => !decodeTopic(topic, topic.id)) ) + yield* new StoreError({ message: "Memory update produced an invalid generation" }) + const generation = `${revision}-${randomUUID()}` + const generations = home.generations(projectID) + const staging = join(generations, `.${generation}.tmp`) + const directory = join(generations, generation) + yield* Effect.gen(function* () { + yield* fs.makeDirectory(generations, { recursive: true }) + yield* writeDirectoryTopics(staging, { + topics, + changed: topics.map((topic) => topic.id), + deleted: [], + }) + yield* fs.rename(staging, directory) + yield* MemoryFile.atomicWrite( + fs, + home.manifest(projectID), + JSON.stringify({ schema_version: 1, revision, generation }) + "\n", + ) + }).pipe(Effect.onError(() => fs.remove(staging, { force: true, recursive: true }).pipe(Effect.ignore))) + yield* fs.remove(home.topics(projectID), { force: true, recursive: true }).pipe(Effect.ignore) }) - const ensureGitExclude = Effect.fn("MemoryStore.ensureGitExclude")(function* (worktree: string) { - const result = yield* git.run(["rev-parse", "--git-path", "info/exclude"], { cwd: worktree }) - if (result.exitCode !== 0) return yield* new StoreError({ message: result.stderr.toString("utf8").trim() }) - const raw = result.text().trim() - if (!raw) return yield* new StoreError({ message: "Git did not resolve info/exclude" }) - const file = isAbsolute(raw) ? raw : resolve(worktree, raw) - const current = (yield* fs.readFileStringSafe(file)) ?? "" - const lines = new Set(current.split(/\r?\n/).map((line) => line.trim())) - const missing = EXCLUDE_RULES.filter((rule) => !lines.has(rule)) - if (missing.length === 0) return yield* Effect.void - const prefix = current.length === 0 || current.endsWith("\n") ? current : current + "\n" - yield* MemoryFile.atomicWrite(fs, file, prefix + missing.join("\n") + "\n") - return yield* Effect.logDebug("memory Git exclusions installed", { worktree, path: file }) + const readTopics = Effect.fn("MemoryStore.readTopics")((projectID: ProjectV2.ID) => + readSnapshotUnsafe(projectID, false).pipe( + Effect.map((snapshot) => snapshot.topics), + Effect.catchTag("MemoryStore.Error", () => Effect.succeed([])), + ), + ) + + const readSnapshot = Effect.fn("MemoryStore.readSnapshot")((projectID: ProjectV2.ID) => + readSnapshotUnsafe(projectID, true), + ) + + const inspectTopics = Effect.fn("MemoryStore.inspectTopics")((projectID: ProjectV2.ID) => + readSnapshot(projectID).pipe(Effect.map((snapshot) => snapshot.topics)), + ) + + const commitUnsafe = Effect.fnUntraced(function* ( + projectID: ProjectV2.ID, + expectedRevision: number, + applied: Applied, + ) { + const current = yield* readSnapshot(projectID) + if (current.revision !== expectedRevision) + return yield* new CommitConflictError({ + expected_revision: expectedRevision, + actual_revision: current.revision, + }) + if (applied.changed.length === 0 && applied.deleted.length === 0) return current + const revision = current.revision + 1 + yield* writeSnapshot(projectID, revision, applied.topics) + return { revision, topics: applied.topics } satisfies Snapshot }) - return Service.of({ readTopics, writeTopics, ensureGitExclude }) + const commit = Effect.fn("MemoryStore.commit")((projectID: ProjectV2.ID, expectedRevision: number, applied: Applied) => + flock.withLock(commitUnsafe(projectID, expectedRevision, applied), `memory-project:${projectID}`, home.locks), + ) + + const updateTopics: Interface["updateTopics"] = (projectID, update) => + flock.withLock( + Effect.gen(function* () { + const current = yield* readSnapshot(projectID) + const next = yield* Effect.try({ + try: () => update(current.topics), + catch: (cause) => + cause instanceof StoreError + ? cause + : new StoreError({ message: `Memory update failed: ${String(cause)}` }), + }) + const applied = next.applied + if (applied.changed.length === 0 && applied.deleted.length === 0) + return { revision: current.revision, topics: applied.topics, result: next.result } + const revision = current.revision + 1 + yield* writeSnapshot(projectID, revision, applied.topics) + return { revision, topics: applied.topics, result: next.result } + }), + `memory-project:${projectID}`, + home.locks, + ) + + return Service.of({ readTopics, readSnapshot, commit, inspectTopics, updateTopics }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(MemoryHome.defaultLayer), +) -export const node = LayerNode.make(layer, [FSUtil.node, Git.node]) +export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node, MemoryHome.node]) export function decodeTopic(value: unknown, expectedID?: string) { if (!hasExactKeys(value, TOPIC_KEYS)) return undefined @@ -376,10 +525,6 @@ export function indexes(topics: MemorySchema.Topic[]) { return topics.map(MemorySchema.topicIndex) } -export function topicsDir(worktree: string) { - return join(worktree, ".opencode", "memory", "topics") -} - function assertSemantic(...values: string[]) { if (values.some((value) => !isAllowedMemoryText(value))) throw new StoreError({ message: "Memory action contains prohibited content" }) diff --git a/packages/opencode/src/project/identity-migration.ts b/packages/opencode/src/project/identity-migration.ts new file mode 100644 index 0000000000..244c40c9f0 --- /dev/null +++ b/packages/opencode/src/project/identity-migration.ts @@ -0,0 +1,62 @@ +export * as ProjectIdentityMigration from "./identity-migration" + +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Context, Effect, Layer } from "effect" +import { MemoryHome } from "@/memory/home" +import { MemoryIdentityFence } from "@/memory/identity-fence" +import { MemoryIdentityMigration } from "@/memory/identity-migration" + +export interface Interface { + /** + * Retire `oldID` in favor of `newID` as ONE fenced retirement. Holds the + * cross-process `memory-identity:` fence for the whole retirement — + * the Memory Home migration AND the caller's reference/row retirement — so an + * in-flight writer still producing under oldID either completes before the + * retirement (its writes move with the Home) or sees the row gone on its + * in-fence liveness recheck and stops. Callers pass their reference/row + * retirement as `retireReferences` and do not touch the fence themselves. + */ + readonly migrate: ( + oldID: ProjectV2.ID, + newID: ProjectV2.ID, + retireReferences: () => Effect.Effect, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ProjectIdentityMigration") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const memory = yield* MemoryIdentityMigration.Service + const flock = yield* EffectFlock.Service + const home = yield* MemoryHome.Service + return Service.of({ + migrate: (oldID, newID, retireReferences) => + flock + .withLock( + Effect.gen(function* () { + yield* memory.migrateHome(oldID, newID) + yield* retireReferences() + }), + MemoryIdentityFence.key(oldID), + home.locks, + ) + .pipe(Effect.orDie, Effect.withSpan("ProjectIdentityMigration.migrate")), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(MemoryIdentityMigration.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(MemoryHome.defaultLayer), +) + +export const node = LayerNode.make(layer, [ + MemoryIdentityMigration.node, + EffectFlock.node, + MemoryHome.node, +]) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 82ae979ba3..e5ae665f94 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -5,6 +5,8 @@ import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/s import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { WorkflowTable } from "@opencode-ai/core/dag/sql" +import { PermissionTable } from "@opencode-ai/core/permission/sql" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus } from "@/bus/global" import { which } from "@opencode-ai/core/util/which" @@ -22,6 +24,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/schema/project" +import { ProjectIdentityMigration } from "./identity-migration" export const Info = Project.Info export type Info = Types.DeepMutable> @@ -112,6 +115,7 @@ export const layer = Layer.effect( const projectDirectories = yield* ProjectDirectories.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const identityMigration = yield* ProjectIdentityMigration.Service const { db } = yield* Database.Service const git = Effect.fnUntraced( @@ -151,45 +155,73 @@ export const layer = Layer.effect( if (oldID === ProjectV2.ID.global) return if (oldID === newID) return - yield* db - .transaction( - (d) => - Effect.gen(function* () { - const oldProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get() - const newProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get() - if (oldProject && !newProject) { + // The retirement seam holds the memory-identity: fence across BOTH + // the Home migration and this reference/row retirement, so an in-flight + // writer under oldID cannot slip in between the Home move and the row + // deletion. This callback runs inside that fence; it does not touch the + // fence itself. + yield* identityMigration.migrate(oldID, newID, () => + db + .transaction( + (d) => + Effect.gen(function* () { + const oldProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get() + const newProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get() + if (oldProject && !newProject) { + yield* d + .insert(ProjectTable) + .values({ + ...oldProject, + id: newID, + time_updated: Date.now(), + }) + .run() + } + + // Project directories may be shared across distinct + // checkouts which have diverged. Clear the directory + // list and rely on it being re-populated to ensure + // accuracy + yield* d.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, oldID)).run() + yield* d - .insert(ProjectTable) - .values({ - ...oldProject, - id: newID, - time_updated: Date.now(), - }) + .update(SessionTable) + .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) + .where(eq(SessionTable.project_id, oldID)) .run() - } - - // Project directories may be shared across distinct - // checkouts which have diverged. Clear the directory - // list and rely on it being re-populated to ensure - // accuracy - yield* d.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, oldID)).run() - - yield* d - .update(SessionTable) - .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) - .where(eq(SessionTable.project_id, oldID)) - .run() - yield* d - .update(WorkspaceTable) - .set({ project_id: newID }) - .where(eq(WorkspaceTable.project_id, oldID)) - .run() - - if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run() - }), - { behavior: "immediate" }, - ) - .pipe(Effect.orDie) + yield* d + .update(WorkspaceTable) + .set({ project_id: newID }) + .where(eq(WorkspaceTable.project_id, oldID)) + .run() + + // Repoint the Project-owned references that the old row's deletion would otherwise + // cascade-destroy. Both workflow and permission carry ON DELETE CASCADE on project_id, + // so without this repointing, gaining a first remote would silently delete every DAG + // workflow and every saved permission for the project. + yield* d.update(WorkflowTable).set({ project_id: newID }).where(eq(WorkflowTable.project_id, oldID)).run() + // (project_id, action, resource) is unique on permission. When the successor + // identity already holds a row with the same (action, resource), it already grants + // the identical permission: drop the old row instead of repointing it. A bulk + // UPDATE would violate the unique index and wedge the whole identity upgrade. + const successorPermissions = new Set( + (yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).all()).map( + (row) => JSON.stringify([row.action, row.resource]), + ), + ) + for (const row of yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).all()) { + if (successorPermissions.has(JSON.stringify([row.action, row.resource]))) { + yield* d.delete(PermissionTable).where(eq(PermissionTable.id, row.id)).run() + } else { + yield* d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.id, row.id)).run() + } + } + + if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run() + }), + { behavior: "immediate" }, + ).pipe(Effect.orDie), + ) }) const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: { @@ -472,6 +504,7 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(ProjectIdentityMigration.defaultLayer), ) export const use = serviceUse(Service) @@ -484,6 +517,7 @@ export const node = LayerNode.make(layer, [ ProjectDirectories.node, EventV2Bridge.node, RuntimeFlags.node, + ProjectIdentityMigration.node, Database.node, ]) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 370870935a..3a5ddc3ce7 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -14,7 +14,7 @@ function unquoteGitPath(input: string) { const bytes: number[] = [] for (let i = 0; i < body.length; i++) { - const char = body[i]! + const char = body[i] if (char !== "\\") { bytes.push(char.charCodeAt(0)) continue diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 255e5fce8d..e50b0361de 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -21,6 +21,8 @@ import { AppProcess } from "@opencode-ai/core/process" import { InstanceState } from "@/effect/instance-state" import { WorktreeEvent } from "@opencode-ai/schema/worktree-event" import { SettingsHook } from "@/hook/settings" +import { MemoryAdmission } from "@/memory/admission" +import { MemoryPaths } from "@/memory/paths" import * as Option from "effect/Option" export const Event = WorktreeEvent @@ -156,6 +158,7 @@ export const layer: Layer.Layer< const gitSvc = yield* Git.Service const project = yield* Project.Service const store = yield* InstanceStore.Service + const memoryAdmission = Option.getOrUndefined(yield* Effect.serviceOption(MemoryAdmission.Service)) const settingsHook = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) const git = Effect.fnUntraced( @@ -227,7 +230,7 @@ export const layer: Layer.Layer< { cwd: ctx.worktree }, ) if (created.code !== 0) { - return yield* new CreateFailedError({ + yield* new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree", }) } @@ -341,11 +344,30 @@ export const layer: Layer.Layer< return process.platform === "win32" ? normalized.toLowerCase() : normalized }) + const registeredSandbox = Effect.fnUntraced(function* (sandboxes: string[], directory: string) { + const key = yield* canonical(directory) + return (yield* Effect.forEach(sandboxes, (sandbox) => + canonical(sandbox).pipe(Effect.map((candidate) => ({ candidate, sandbox }))), + )).find((sandbox) => sandbox.candidate === key)?.sandbox + }) + + // All registrations canonically equal to `directory` — symlinked paths + // (/var vs /private/var) can register the same worktree twice; cleanup must + // drop every equivalent entry, not just the first match. + const registeredSandboxes = Effect.fnUntraced(function* (sandboxes: string[], directory: string) { + const key = yield* canonical(directory) + const matches: string[] = [] + for (const sandbox of sandboxes) { + if ((yield* canonical(sandbox)) === key) matches.push(sandbox) + } + return matches + }) + function parseWorktreeList(text: string) { return text .split("\n") .map((line) => line.trim()) - .reduce<{ path?: string; branch?: string }[]>((acc, line) => { + .reduce<{ path?: string; branch?: string; prunable?: boolean }[]>((acc, line) => { if (!line) return acc if (line.startsWith("worktree ")) { acc.push({ path: line.slice("worktree ".length).trim() }) @@ -356,12 +378,13 @@ export const layer: Layer.Layer< if (line.startsWith("branch ")) { current.branch = line.slice("branch ".length).trim() } + if (line.startsWith("prunable ")) current.prunable = true return acc }, []) } const locateWorktree = Effect.fnUntraced(function* ( - entries: { path?: string; branch?: string }[], + entries: { path?: string; branch?: string; prunable?: boolean }[], directory: string, ) { for (const item of entries) { @@ -383,11 +406,18 @@ export const layer: Layer.Layer< return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) } + const entries = parseWorktreeList(result.text) + // list() is an observation path: it must never prune or deregister. + // "prunable" does not prove a worktree is gone — git also marks merely + // inaccessible directories (unmounted volume, locked parent) and broken + // gitdir links whose directories still exist. Pruning there destroys git + // admin data and live registrations. Cleanup belongs to remove/reset, + // which can prove each case. const primary = yield* canonical(ctx.project.worktree) const primaryName = pathSvc.basename(primary).toLowerCase() - return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => + return yield* Effect.forEach(entries, (entry) => Effect.gen(function* () { - if (!entry.path) return undefined + if (!entry.path || entry.prunable) return undefined const directory = yield* canonical(entry.path) if (directory === primary) return undefined const name = pathSvc.basename(directory).toLowerCase() @@ -427,26 +457,82 @@ export const layer: Layer.Layer< }) } + const hasUnresolvedLegacyMemory = Effect.fnUntraced(function* (directory: string) { + const found = yield* Effect.forEach( + MemoryPaths.PROJECT_PATHS, + (relative) => fs.exists(pathSvc.join(directory, relative)).pipe(Effect.orDie), + { concurrency: "unbounded" }, + ) + return found.some(Boolean) + }) + + const reconcileLegacyMemory = Effect.fnUntraced(function* (input: { + projectID: ProjectV2.ID + projectDirectory: string + directory: string + directories: ReadonlyArray + initialized: boolean + updated: number + }) { + // Migration runs only for initialized projects (the memory path's own + // eligibility gate) and always against the COMPLETE directory snapshot: + // promoting a legacy config seen from a single directory could silently + // flip the project-wide effective config past disagreeing siblings. + if (memoryAdmission && input.initialized) { + yield* memoryAdmission.invalidate(input.projectID) + const memory = yield* memoryAdmission + .ensure({ + projectID: input.projectID, + projectDirectory: input.projectDirectory, + directories: input.directories, + updated: input.updated, + }) + .pipe(Effect.catchTag("MemoryAdmission.IdentityRetired", () => Effect.succeed(undefined))) + // The identity was retired concurrently. Legacy sources may never have + // been admitted anywhere, so a destructive step (worktree remove) must + // fail closed instead of destroying them; a retry under the successor + // identity imports them first. + if (!memory) + return "Project identity is being upgraded. Retry once the upgrade completes." + if (memory.unresolved > 0) + return `Cannot continue with unresolved legacy project memory: ${memory.diagnostics + .filter((item) => item.code.endsWith(".invalid") || item.code.endsWith(".conflict")) + .map((item) => `${item.code} ${item.path}`) + .join(", ")}` + } + if (!(yield* hasUnresolvedLegacyMemory(input.directory))) return undefined + return "Cannot continue while unresolved legacy project memory remains. Move or back up .opencode/memory* outside this worktree, then retry." + }) + const removeLocked = Effect.fnUntraced(function* (input: RemoveInput, directory: string) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) } - yield* FiberMap.remove(bootFibers, directory) - - if (settingsHook) { - const wrResult = yield* settingsHook - .trigger( - { event: "WorktreeRemove", path: directory, branch: pathSvc.basename(directory) }, - { sessionID: "", transcriptPath: "" }, - ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) - yield* SettingsHook.landSystemMessages(wrResult, { sessionID: "" }) + const primary = yield* canonical(ctx.project.worktree) + const current = yield* canonical(ctx.worktree) + if (directory === primary || directory === current) { + return yield* new RemoveFailedError({ message: "Cannot remove the primary or current worktree" }) } - // Preserve the loaded path casing for the store cache; `directory` is lowercased on Windows. - if (directory !== (yield* canonical(ctx.worktree))) yield* store.disposeDirectory(input.directory) + // Fail closed if the identity row is gone (retired by an upgrade): never + // reconcile, prune, or drop registrations under a stale instance identity. + const currentProject = yield* project.get(ctx.project.id) + if (!currentProject) { + return yield* new RemoveFailedError({ + message: "Project identity is no longer registered; reload the project before removing worktrees", + }) + } + const matches = yield* registeredSandboxes(currentProject.sandboxes, directory) + if (matches.length === 0) { + return yield* new RemoveFailedError({ message: "Worktree is not registered with this Project" }) + } + const dropRegistrations = Effect.forEach( + matches, + (match) => project.removeSandbox(ctx.project.id, match), + { concurrency: 1, discard: true }, + ) const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { @@ -455,13 +541,99 @@ export const layer: Layer.Layer< const entries = parseWorktreeList(list.text) const entry = yield* locateWorktree(entries, directory) - if (!entry?.path) { - const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie) - if (directoryExists) { - yield* stopFsmonitor(directory) - yield* cleanDirectory(directory) + // Registered, but git has no record of the worktree (admin data lost or + // the git side was already removed). Recover deterministically instead + // of failing with a false "not registered": legacy memory is reconciled + // fail-closed against the directory when it still exists, then the stale + // registration is dropped. The directory itself is never deleted here. + yield* FiberMap.remove(bootFibers, directory) + if (settingsHook) { + const wrResult = yield* settingsHook + .trigger( + { event: "WorktreeRemove", path: directory, branch: pathSvc.basename(directory) }, + { sessionID: "", transcriptPath: "" }, + ) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) + yield* SettingsHook.landSystemMessages(wrResult, { sessionID: "" }) } + const blocker = yield* reconcileLegacyMemory({ + projectID: ctx.project.id, + projectDirectory: ctx.project.worktree, + directory, + directories: currentProject.sandboxes, + initialized: currentProject.time.initialized !== undefined, + updated: currentProject.time.updated, + }).pipe( + Effect.mapError( + (error) => new RemoveFailedError({ message: `Failed to migrate legacy project memory: ${error.message}` }), + ), + ) + if (blocker) return yield* new RemoveFailedError({ message: blocker }) + yield* store.disposeDirectory(directory) + yield* dropRegistrations + return true + } + + // The WorktreeRemove hook may run user scripts that still write legacy + // memory files; fire it BEFORE taking the fail-closed memory proof so the + // proof observes everything the hook produced. + yield* FiberMap.remove(bootFibers, directory) + if (settingsHook) { + const wrResult = yield* settingsHook + .trigger( + { event: "WorktreeRemove", path: entry.path, branch: pathSvc.basename(entry.path) }, + { sessionID: "", transcriptPath: "" }, + ) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) + yield* SettingsHook.landSystemMessages(wrResult, { sessionID: "" }) + } + + const blocker = yield* reconcileLegacyMemory({ + projectID: ctx.project.id, + projectDirectory: ctx.project.worktree, + directory: entry.path, + directories: currentProject.sandboxes, + initialized: currentProject.time.initialized !== undefined, + updated: currentProject.time.updated, + }).pipe( + Effect.mapError( + (error) => new RemoveFailedError({ message: `Failed to migrate legacy project memory: ${error.message}` }), + ), + ) + if (blocker) return yield* new RemoveFailedError({ message: blocker }) + + if (entry.prunable) { + // git already considers this worktree gone (directory deleted, or a + // broken gitdir link). The destructive cleanup belongs on this action + // path — never on list(): prune the admin data, remove the directory if + // it still exists, then drop the registration(s). + yield* store.disposeDirectory(entry.path) + // `git worktree prune` is repo-global: it would also destroy the admin + // data of any OTHER merely-prunable worktree (e.g. an unmounted volume + // or locked parent — prunable does not mean gone). Only prune when this + // entry is the sole prunable one; otherwise leave the stale admin data + // for an explicit later cleanup. + if (entries.every((item) => !item.prunable || item === entry)) { + yield* git(["worktree", "prune"], { cwd: ctx.worktree }) + } + if (yield* fs.existsSafe(entry.path)) yield* cleanDirectory(entry.path) + const prunedBranch = entry.branch?.replace(/^refs\/heads\//, "") + if (prunedBranch) { + const deleted = yield* git(["branch", "-D", prunedBranch], { cwd: ctx.worktree }) + if (deleted.code !== 0) { + const restored = yield* git(["worktree", "add", entry.path, prunedBranch], { cwd: ctx.worktree }) + if (restored.code !== 0) yield* dropRegistrations + const recovery = + restored.code === 0 + ? "the worktree registration was restored" + : `the worktree could not be restored and its Project registration was removed: ${restored.stderr || restored.text}` + return yield* new RemoveFailedError({ + message: `Failed to delete worktree branch: ${deleted.stderr || deleted.text}; ${recovery}`, + }) + } + } + yield* dropRegistrations return true } @@ -491,12 +663,19 @@ export const layer: Layer.Layer< if (branch) { const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree }) if (deleted.code !== 0) { + const restored = yield* git(["worktree", "add", entry.path, branch], { cwd: ctx.worktree }) + if (restored.code !== 0) yield* dropRegistrations + const recovery = + restored.code === 0 + ? "the worktree registration was restored" + : `the worktree could not be restored and its Project registration was removed: ${restored.stderr || restored.text}` return yield* new RemoveFailedError({ - message: deleted.stderr || deleted.text || "Failed to delete worktree branch", + message: `Failed to delete worktree branch: ${deleted.stderr || deleted.text}; ${recovery}`, }) } } + yield* dropRegistrations return true }) @@ -519,7 +698,7 @@ export const layer: Layer.Layer< function* (directory: string, cmd: string) { const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]] const result = yield* appProcess.run( - ChildProcess.make(shell, args as string[], { cwd: directory, extendEnv: true, stdin: "ignore" }), + ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }), ) return { code: result.exitCode, stderr: result.stderr.toString("utf8") } }, @@ -569,14 +748,15 @@ export const layer: Layer.Layer< }) const sweep = Effect.fnUntraced(function* (root: string) { - const first = yield* git(["clean", "-ffdx"], { cwd: root }) + const args = ["clean", "-ffdx", ...MemoryPaths.PROJECT_PATHS.flatMap((relative) => ["-e", relative])] + const first = yield* git(args, { cwd: root }) if (first.code === 0) return first const entries = failedRemoves(first.stderr, first.text) if (!entries.length) return first yield* prune(root, entries) - return yield* git(["clean", "-ffdx"], { cwd: root }) + return yield* git(args, { cwd: root }) }) const resetLocked = Effect.fnUntraced(function* (input: ResetInput, directory: string) { @@ -585,9 +765,22 @@ export const layer: Layer.Layer< return yield* new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const primary = yield* canonical(ctx.worktree) - if (directory === primary) { - return yield* new ResetFailedError({ message: "Cannot reset the primary workspace" }) + const primary = yield* canonical(ctx.project.worktree) + const current = yield* canonical(ctx.worktree) + if (directory === primary || directory === current) { + return yield* new ResetFailedError({ message: "Cannot reset the primary or current worktree" }) + } + + // Fail closed if the identity row is gone (retired by an upgrade): never + // reconcile or mutate under a stale instance identity. + const currentProject = yield* project.get(ctx.project.id) + if (!currentProject) { + return yield* new ResetFailedError({ + message: "Project identity is no longer registered; reload the project before resetting worktrees", + }) + } + if (!(yield* registeredSandbox(currentProject.sandboxes, directory))) { + return yield* new ResetFailedError({ message: "Worktree is not registered with this Project" }) } yield* FiberMap.remove(bootFibers, directory) @@ -603,6 +796,20 @@ export const layer: Layer.Layer< const worktreePath = entry.path + const blocker = yield* reconcileLegacyMemory({ + projectID: ctx.project.id, + projectDirectory: ctx.project.worktree, + directory: worktreePath, + directories: currentProject.sandboxes, + initialized: currentProject.time.initialized !== undefined, + updated: currentProject.time.updated, + }).pipe( + Effect.mapError( + (error) => new ResetFailedError({ message: `Failed to migrate legacy project memory: ${error.message}` }), + ), + ) + if (blocker) return yield* new ResetFailedError({ message: blocker }) + const base = yield* gitSvc.defaultBranch(ctx.worktree) if (!base) { return yield* new ResetFailedError({ message: "Default branch not found" }) @@ -650,13 +857,18 @@ export const layer: Layer.Layer< (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }), ) - const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) + const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1", "--untracked-files=all"], { + cwd: worktreePath, + }) if (status.code !== 0) { return yield* new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" }) } - if (status.text.trim()) { - return yield* new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` }) + const dirty = status.text + .split("\n") + .filter((line) => line && !(line.startsWith("?? ") && MemoryPaths.isProjectMemoryPath(line.slice(3)))) + if (dirty.length > 0) { + return yield* new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty.join("\n")}` }) } yield* FiberMap.run( @@ -683,6 +895,7 @@ export const appLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(AppProcess.defaultLayer), Layer.provide(Project.defaultLayer), + Layer.provide(MemoryAdmission.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer), @@ -696,6 +909,7 @@ export const node = LayerNode.make(layer, [ AppProcess.node, Git.node, Project.node, + MemoryAdmission.node, InstanceStore.node, Database.node, SettingsHook.node, diff --git a/packages/opencode/test/fixture/memory-commit-worker.ts b/packages/opencode/test/fixture/memory-commit-worker.ts new file mode 100644 index 0000000000..492dd43470 --- /dev/null +++ b/packages/opencode/test/fixture/memory-commit-worker.ts @@ -0,0 +1,75 @@ +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProjectV2 } from "@opencode-ai/core/project" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Cause, Effect, Exit, Layer, Schema } from "effect" +import { MemoryHome } from "@/memory/home" +import { MemoryStore } from "@/memory/store" + +const Input = Schema.Struct({ + root: Schema.String, + projectID: Schema.String, + ready: Schema.String, + go: Schema.String, + expectedRevision: Schema.Number, + summary: Schema.String, +}) + +const input = Schema.decodeUnknownSync(Input)(JSON.parse(process.argv[2] ?? "{}")) +const home = Layer.succeed(MemoryHome.Service, MemoryHome.make(input.root)) +const store = MemoryStore.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), +) + +await Effect.runPromise( + Effect.gen(function* () { + const memory = yield* MemoryStore.Service + const projectID = ProjectV2.ID.make(input.projectID) + yield* Effect.promise(() => Bun.write(input.ready, String(process.pid))) + while (!(yield* Effect.promise(() => Bun.file(input.go).exists()))) yield* Effect.sleep("5 millis") + + const staleTopic = { + schema_version: 1, + id: "project-architecture", + name: "架构边界", + summary: input.summary, + metadata: { + categories: ["decision"], + status: "active", + importance: "core", + keywords: ["架构"], + related_topics: [], + created_at: "2026-08-11T00:00:00Z", + updated_at: "2026-08-11T00:00:00Z", + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: "decision-stale", + kind: "decision", + content: "已确认决定:这是一次陈旧修订的提交", + rationale: "该决定由用户确认并长期适用", + confirmed_at: "2026-08-11T00:00:00Z", + }, + ], + } as const + + const exit = yield* Effect.exit( + memory.commit(projectID, input.expectedRevision, { + topics: [staleTopic], + changed: [staleTopic.id], + deleted: [], + }), + ) + // Exit 0 only when the commit failed with the explicit conflict error — + // anything else (success, other failure) reports a broken protocol. + if (Exit.isFailure(exit) && Cause.pretty(exit.cause).includes("MemoryStore.CommitConflict")) { + process.exit(0) + } + process.exit(1) + }).pipe(Effect.provide(store)), +) diff --git a/packages/opencode/test/fixture/memory-store-worker.ts b/packages/opencode/test/fixture/memory-store-worker.ts new file mode 100644 index 0000000000..7447160359 --- /dev/null +++ b/packages/opencode/test/fixture/memory-store-worker.ts @@ -0,0 +1,56 @@ +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProjectV2 } from "@opencode-ai/core/project" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Effect, Layer } from "effect" +import { MemoryHome } from "@/memory/home" +import { MemoryStore } from "@/memory/store" + +type Input = { + root: string + projectID: string + ready: string + go: string + itemID: string + content: string +} + +const input = JSON.parse(process.argv[2] ?? "") as Input +const home = Layer.succeed(MemoryHome.Service, MemoryHome.make(input.root)) +const store = MemoryStore.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), +) + +await Effect.runPromise( + Effect.gen(function* () { + const memory = yield* MemoryStore.Service + const projectID = ProjectV2.ID.make(input.projectID) + yield* Effect.promise(() => Bun.write(input.ready, String(process.pid))) + while (!(yield* Effect.promise(() => Bun.file(input.go).exists()))) yield* Effect.sleep("5 millis") + yield* memory.updateTopics(projectID, (topics) => { + const current = topics[0] + if (!current) throw new Error("Missing base topic") + const items = [...current.items, { + id: input.itemID, + kind: "decision", + content: input.content, + rationale: "该决定由用户确认并长期适用", + confirmed_at: "2026-08-11T00:00:00Z", + } as const] + const updated = { + ...current, + metadata: { + ...current.metadata, + item_count: items.length, + revision: current.metadata.revision + 1, + }, + items, + } + return { + applied: { topics: [updated], changed: [updated.id], deleted: [] }, + result: undefined, + } + }) + }).pipe(Effect.provide(store)), +) diff --git a/packages/opencode/test/memory/memory-admission.test.ts b/packages/opencode/test/memory/memory-admission.test.ts new file mode 100644 index 0000000000..a514313c59 --- /dev/null +++ b/packages/opencode/test/memory/memory-admission.test.ts @@ -0,0 +1,353 @@ +import { describe, expect } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Cause, Duration, Effect, Exit, Fiber, Layer } from "effect" +import path from "node:path" +import { MemoryAdmission } from "@/memory/admission" +import { MemoryConfig } from "@/memory/config" +import { MemoryHome } from "@/memory/home" +import { MemoryIdentityFence } from "@/memory/identity-fence" +import { MemoryStore } from "@/memory/store" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const projectID = ProjectV2.ID.make("project-memory-admission") +const config = { + schema_version: 1, + enabled: true, + model: "test/memory-small", + topic_limit: 10, + topic_limit_floor: 10, + turn_interval: 5, + injection: { max_topics: 3, max_tokens: 1_200 }, +} as const +const now = "2026-08-11T00:00:00Z" + +function topic(id: string, summary = `已确认的 ${id} 决策`) { + return { + schema_version: 1, + id, + name: `${id} 决策`, + summary, + metadata: { + categories: ["decision"], + status: "active", + importance: "core", + keywords: [id], + related_topics: [], + created_at: now, + updated_at: now, + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: `${id}-item`, + kind: "decision", + content: `已确认决定:保留 ${id} 边界`, + rationale: "该边界由用户确认并长期适用", + confirmed_at: now, + }, + ], + } as const +} + +function layers(root: string) { + const home = Layer.succeed(MemoryHome.Service, MemoryHome.make(root)) + // One shared Database layer: the fence's liveness recheck and the test + // body's row setup must see the same rows. + const database = Database.defaultLayer + const store = MemoryStore.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), + ) + const fence = MemoryIdentityFence.layer.pipe( + Layer.provide(database), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), + ) + const admission = MemoryAdmission.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(MemoryConfig.defaultLayer), + Layer.provide(home), + Layer.provide(store), + Layer.provide(fence), + ) + return Layer.mergeAll(admission, store, MemoryConfig.defaultLayer, database) +} + +describe("MemoryAdmission", () => { + it.live("promotes one normalized sandbox configuration when the Project has no explicit configuration", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const first = yield* tmpdirScoped() + const second = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const admission = yield* MemoryAdmission.Service + const configStore = yield* MemoryConfig.Service + const { db } = yield* Database.Service + // ensure() runs for live identities; the fence re-checks the row. + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: AbsolutePath.make(primary), vcs: "git", sandboxes: [] }) + .run() + .pipe(Effect.orDie) + const files = [first, second].map((directory) => path.join(directory, ".opencode", "memory.jsonc")) + yield* Effect.forEach(files, (file) => fs.makeDirectory(path.dirname(file), { recursive: true }), { + concurrency: 1, + discard: true, + }) + yield* Effect.forEach(files, (file) => fs.writeFileString(file, JSON.stringify(config)), { + concurrency: 1, + discard: true, + }) + + const result = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [primary, first, second], + updated: 1, + }) + + expect(result.diagnostics.map((item) => item.code)).toEqual(["config.promoted", "config.duplicate"]) + expect((yield* configStore.load(primary))?.config).toEqual(config) + expect(yield* Effect.forEach(files, (file) => fs.existsSafe(file))).toEqual([false, false]) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("caches one Project snapshot until worktree lifecycle invalidates it", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const sandbox = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const admission = yield* MemoryAdmission.Service + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: AbsolutePath.make(primary), vcs: "git", sandboxes: [] }) + .run() + .pipe(Effect.orDie) + const snapshot = { projectID, projectDirectory: primary, directories: [primary, sandbox], updated: 1 } + + expect((yield* admission.ensure(snapshot)).diagnostics).toEqual([]) + const file = path.join(sandbox, ".opencode", "memory", "topics", "late.yaml") + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, "{ invalid") + + expect((yield* admission.ensure(snapshot)).diagnostics).toEqual([]) + yield* admission.invalidate(projectID) + expect((yield* admission.ensure(snapshot)).diagnostics.map((item) => item.code)).toEqual(["topic.invalid"]) + expect(yield* fs.existsSafe(file)).toBe(true) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("imports every worktree Topic in one Project revision", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const first = yield* tmpdirScoped() + const second = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const admission = yield* MemoryAdmission.Service + const store = yield* MemoryStore.Service + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: AbsolutePath.make(primary), vcs: "git", sandboxes: [] }) + .run() + .pipe(Effect.orDie) + const topics = [topic("architecture"), topic("product")] + const files = [first, second].map((directory, index) => + path.join(directory, ".opencode", "memory", "topics", `${topics[index].id}.yaml`), + ) + yield* Effect.forEach(files, (file, index) => + fs.makeDirectory(path.dirname(file), { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(file, Bun.YAML.stringify(topics[index]))), + ), + ) + + const result = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [primary, first, second], + updated: 1, + }) + + expect(result.diagnostics.map((item) => item.code)).toEqual(["topic.imported", "topic.imported"]) + expect(yield* store.readSnapshot(projectID)).toMatchObject({ revision: 1, topics }) + expect(yield* Effect.forEach(files, (file) => fs.existsSafe(file))).toEqual([false, false]) + }).pipe(Effect.provide(layers(root))) + }), + ) + + const fullLayers = (root: string) => { + const home = Layer.succeed(MemoryHome.Service, MemoryHome.make(root)) + const flock = EffectFlock.defaultLayer + const database = Database.defaultLayer + const fence = MemoryIdentityFence.layer.pipe( + Layer.provide(database), + Layer.provide(flock), + Layer.provide(home), + ) + const base = Layer.mergeAll(FSUtil.defaultLayer, flock, home, MemoryConfig.defaultLayer, database) + const store = MemoryStore.layer.pipe(Layer.provide(base)) + const admission = MemoryAdmission.layer.pipe(Layer.provide(base), Layer.provide(store), Layer.provide(fence)) + return Layer.mergeAll(base, store, admission) + } + + it.live( + "preserves a legacy topic file whose content changes between the scan and the delete (MEM-PR01-R1-04)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service + const home = yield* MemoryHome.Service + const admission = yield* MemoryAdmission.Service + const store = yield* MemoryStore.Service + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: AbsolutePath.make(primary), vcs: "git", sandboxes: [] }) + .run() + .pipe(Effect.orDie) + + const dir = path.join(primary, ".opencode", "memory", "topics") + const file = path.join(dir, "moving-topic.yaml") + yield* fs.makeDirectory(dir, { recursive: true }) + const original = topic("moving-topic") + yield* fs.writeFileString(file, Bun.YAML.stringify(original)) + + // Hold the store's project lock while ensure() runs: it scans first + // (reading the original), then blocks in updateTopics behind this lock. + // While it blocks, a concurrent writer that does not take the admission + // flock (older runtime, hand edit) replaces the file. When the lock is + // released the migration continues — the delete must then see the + // changed content and preserve the file instead of destroying it. + const ensureFiber = yield* flock.withLock( + Effect.gen(function* () { + const fiber = yield* admission + .ensure({ projectID, projectDirectory: primary, directories: [primary], updated: 1 }) + .pipe(Effect.forkDetach) + yield* Effect.sleep(Duration.millis(500)) + const modified = { ...original, summary: "迁移进行中被并发写入的新摘要" } + yield* fs.writeFileString(file, Bun.YAML.stringify(modified)) + return fiber + }), + `memory-project:${projectID}`, + home.locks, + ) + const result = yield* Fiber.join(ensureFiber) + + expect(yield* fs.existsSafe(file)).toBe(true) + expect(result.diagnostics.some((item) => item.code === "topic.conflict")).toBe(true) + // The scanned version still landed in Project Memory exactly once. + const snapshot = yield* store.readSnapshot(projectID) + expect(snapshot.topics.filter((value) => value.id === "moving-topic")).toHaveLength(1) + }).pipe(Effect.provide(fullLayers(root))) + }), + { timeout: 30_000 }, + ) + + it.live( + "follows the loader's jsonc-over-json precedence and diagnoses an in-project config fork (MEM-PR01-R1-10)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const sandbox = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const admission = yield* MemoryAdmission.Service + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: AbsolutePath.make(primary), vcs: "git", sandboxes: [] }) + .run() + .pipe(Effect.orDie) + + const configA = { ...config, model: "test/config-jsonc" } + const configB = { ...config, model: "test/config-json" } + const opencode = path.join(primary, ".opencode") + yield* fs.makeDirectory(opencode, { recursive: true }) + // The loader (MemoryConfig.load) prefers memory.jsonc; admission must + // agree, and the disagreeing memory.json must be diagnosed as a fork + // instead of silently becoming authoritative. + yield* fs.writeFileString(path.join(opencode, "memory.jsonc"), JSON.stringify(configA)) + yield* fs.writeFileString(path.join(opencode, "memory.json"), JSON.stringify(configB)) + // A sandbox legacy config equal to the NON-effective json content must + // not be deleted as a duplicate of the effective config. + const sandboxFile = path.join(sandbox, ".opencode", "memory.jsonc") + yield* fs.makeDirectory(path.dirname(sandboxFile), { recursive: true }) + yield* fs.writeFileString(sandboxFile, JSON.stringify(configB)) + + const result = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [primary, sandbox], + updated: 1, + }) + + const fork = result.diagnostics.filter((item) => item.path.endsWith("memory.json")) + expect(fork.length).toBe(1) + expect(fork[0].code).toBe("config.conflict") + expect(result.diagnostics.some((item) => item.path === sandboxFile && item.code === "config.conflict")).toBe(true) + expect(yield* fs.existsSafe(sandboxFile)).toBe(true) + expect(result.unresolved).toBeGreaterThan(0) + }).pipe(Effect.provide(fullLayers(root))) + }), + { timeout: 30_000 }, + ) + + it.live( + "does not import legacy topics for a retired identity nor delete the legacy files (MEM-PR01-R7-F1)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const admission = yield* MemoryAdmission.Service + + // A pre-upgrade writer left legacy topic files in the worktree. + const file = path.join(primary, ".opencode", "memory", "topics", "retired-import.yaml") + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, Bun.YAML.stringify(topic("retired-import"))) + + // The identity row was retired by a concurrent upgrade: the snapshot is + // still stamped under the old identity but the row no longer exists. + const result = yield* admission + .ensure({ projectID, projectDirectory: primary, directories: [primary], updated: 1 }) + .pipe(Effect.exit) + + // Fail-closed: the import must not re-create the retired Home and must + // not delete the only remaining copy of the legacy content. + expect(Exit.isFailure(result)).toBe(true) + const failReasons = Exit.isFailure(result) ? result.cause.reasons.filter(Cause.isFailReason) : [] + expect(failReasons.map((reason) => reason.error._tag)).toEqual(["MemoryAdmission.IdentityRetired"]) + expect(yield* fs.existsSafe(file)).toBe(true) + expect(yield* fs.existsSafe(home.directory(projectID))).toBe(false) + }).pipe(Effect.provide(fullLayers(root))) + }), + ) +}) diff --git a/packages/opencode/test/memory/memory-global-identity.test.ts b/packages/opencode/test/memory/memory-global-identity.test.ts new file mode 100644 index 0000000000..2fa507113d --- /dev/null +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -0,0 +1,381 @@ +import { describe, expect } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { eq } from "drizzle-orm" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { Effect, Layer } from "effect" +import { stringify } from "yaml" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import fs from "node:fs" +import path from "node:path" +import { Config } from "@/config/config" +import { Git } from "@/git" +import { MemoryAdmission } from "@/memory/admission" +import { MemoryConfig } from "@/memory/config" +import { MemoryHome } from "@/memory/home" +import { MemoryIdentityFence } from "@/memory/identity-fence" +import { MemoryLock } from "@/memory/lock" +import { Memory } from "@/memory/memory" +import { MemoryModel } from "@/memory/model" +import { MemorySchema } from "@/memory/schema" +import { MemoryStore } from "@/memory/store" +import { Project } from "@/project/project" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { ProviderTest } from "../fake/provider" +import { InstanceRef } from "@/effect/instance-ref" +import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const now = "2026-08-12T12:00:00Z" +const providerID = ProviderV2.ID.make("test") +const enabledModel = ProviderTest.model({ providerID, id: ModelV2.ID.make("memory-on") }) + +const baseConfig = { + schema_version: 1, + enabled: true, + model: "test/memory-on", + topic_limit: 10, + topic_limit_floor: 10, + turn_interval: 5, + injection: { max_topics: 3, max_tokens: 1_200 }, +} satisfies MemorySchema.Config + +function topic() { + return { + schema_version: 1, + id: "project-architecture", + name: "架构边界", + summary: "已确认的核心架构边界", + metadata: { + categories: ["decision"], + status: "active", + importance: "core", + keywords: ["架构"], + related_topics: [], + created_at: now, + updated_at: now, + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: "decision-01", + kind: "decision", + content: "已确认决定:核心模块之间使用稳定边界", + rationale: "该边界由用户确认并长期适用", + confirmed_at: now, + }, + ], + } satisfies MemorySchema.Topic +} + +function userMessage(sessionID: SessionID): SessionV1.WithParts { + const id = MessageID.ascending() + return { + info: { + id, + role: "user", + sessionID, + time: { created: 1 }, + agent: "build", + model: { providerID, modelID: ModelV2.ID.make("memory-on") }, + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID, + type: "text", + text: "架构边界是什么?", + }, + ], + } +} + +const emptyConfigLayer = Layer.mock(Config.Service, { + get: () => Effect.succeed({}), +}) + +const base = Layer.mergeAll( + emptyConfigLayer, + ProviderTest.fake({ model: enabledModel }).layer, + Project.defaultLayer, + Database.defaultLayer, + Git.defaultLayer, + EffectFlock.defaultLayer, + MemoryAdmission.defaultLayer, + MemoryConfig.defaultLayer, + MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, + MemoryLock.defaultLayer, + MemoryStore.defaultLayer, + Layer.mock(MemoryModel.Service, { + generate: () => Effect.die(new Error("model calls are not expected in global-identity tests")), + }), +) + +// provideMerge builds `base` once, provides it to Memory.layer AND re-exposes its +// services (Project/MemoryConfig/MemoryStore/...) to the test body. CrossSpawnSpawner +// is merged at the top level so the body itself can spawn git for the fixtures. +const layer = Layer.mergeAll(Memory.layer.pipe(Layer.provideMerge(base)), CrossSpawnSpawner.defaultLayer) + +const it = testEffect(layer) + +// A git repository WITHOUT any commit: identity resolution finds no remote, no +// cached id and no root commit, so it falls back to the shared ProjectV2.ID.global. +function gitInitWithoutCommit(dir: string) { + return Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const git = (...args: string[]) => + spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode)) + yield* git("init") + yield* git("config", "core.fsmonitor", "false") + yield* git("config", "commit.gpgsign", "false") + yield* git("config", "user.email", "test@opencode.test") + yield* git("config", "user.name", "Test") + }) +} + +describe("MEM-PR01-R1-03: memory is inert once the identity row is retired", () => { + it.live( + "a stale process whose project row was deleted by a concurrent upgrade does not fork a retired Home", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* provideInstance(dir)( + Effect.gen(function* () { + const project = yield* Project.Service + const memory = yield* Memory.Service + const configStore = yield* MemoryConfig.Service + const { db } = yield* Database.Service + + const { project: info } = yield* project.fromDirectory(dir) + expect(info.id).not.toBe(ProjectV2.ID.global) + yield* project.setInitialized(info.id) + yield* configStore.writeGlobal(baseConfig) + + const sessionID = SessionID.make("ses_retired_identity") + const active = yield* memory.search({ sessionID, messages: [userMessage(sessionID)], query: "任意查询" }) + expect(active.status).not.toBe("unavailable") + + // A long-running process holds a context stamped while the row + // existed. Read the stamped row, then let another process complete + // an identity upgrade: the old row is deleted. + const stamped = yield* project.get(info.id) + expect(stamped?.time.initialized).toBeDefined() + yield* db.delete(ProjectTable).where(eq(ProjectTable.id, info.id)).run().pipe(Effect.orDie) + + yield* Effect.provideService(InstanceRef, { directory: dir, worktree: info.worktree, project: stamped! })( + Effect.gen(function* () { + const retired = yield* memory.search({ sessionID, messages: [userMessage(sessionID)], query: "任意查询" }) + expect(retired.status).toBe("unavailable") + expect(yield* memory.setEnabled(true)).toBe("Memory remains off") + }), + ) + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) +}) + +describe("MEM-PR01-R1-23: the runtime admission snapshot covers every registered sandbox", () => { + it.live( + "a legacy topic living only in a registered sandbox is imported on activation", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const sandbox = yield* tmpdirScoped() + yield* provideInstance(dir)( + Effect.gen(function* () { + const project = yield* Project.Service + const memory = yield* Memory.Service + const configStore = yield* MemoryConfig.Service + const store = yield* MemoryStore.Service + + const { project: info } = yield* project.fromDirectory(dir) + yield* project.setInitialized(info.id) + yield* project.addSandbox(info.id, sandbox) + yield* configStore.writeGlobal(baseConfig) + + // The only legacy topic lives in the sandbox, not the primary. + const legacyDir = path.join(sandbox, ".opencode", "memory", "topics") + fs.mkdirSync(legacyDir, { recursive: true }) + const seeded = topic() + fs.writeFileSync(path.join(legacyDir, `${seeded.id}.yaml`), stringify(seeded)) + + // Activation (any product surface) must admit the FULL snapshot — + // primary plus every registered sandbox. + const sessionID = SessionID.make("ses_sandbox_snapshot") + yield* memory.search({ sessionID, messages: [userMessage(sessionID)], query: "架构边界" }) + + const snapshot = yield* store.readSnapshot(info.id) + expect(snapshot.topics.map((value) => value.id)).toContain(seeded.id) + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) +}) + +describe("MEM-PR01-R1-07: /memory writes the Project config to the primary directory", () => { + it.live( + "enabling memory from a non-primary instance context still writes to the project worktree", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const elsewhere = yield* tmpdirScoped() + yield* provideInstance(dir)( + Effect.gen(function* () { + const project = yield* Project.Service + const memory = yield* Memory.Service + const configStore = yield* MemoryConfig.Service + + const { project: info } = yield* project.fromDirectory(dir) + yield* project.setInitialized(info.id) + const stamped = (yield* project.get(info.id))! + // Memory activates from a DISABLED global config (no project config + // yet): enabling must then CREATE the project config. Write the + // global file directly because writeGlobal is a no-op over an + // existing valid config. Clean it up afterwards so later tests see + // a fresh global state. + const globalFile = path.join(MemoryConfig.globalConfigDir(), "memory.jsonc") + fs.mkdirSync(path.dirname(globalFile), { recursive: true }) + fs.writeFileSync(globalFile, JSON.stringify({ ...baseConfig, enabled: false })) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + fs.rmSync(globalFile, { force: true }) + }), + ) + + // The instance context lives in a different worktree than the + // project primary (a registered sandbox); the config must still + // land in the project worktree, not the context's worktree. + yield* Effect.provideService(InstanceRef, { + directory: elsewhere, + worktree: elsewhere, + project: stamped, + })( + Effect.gen(function* () { + expect(yield* memory.setEnabled(true)).toBe("Memory on") + }), + ) + + const written = yield* configStore.load(info.worktree) + expect(written?.config.enabled).toBe(true) + expect(written?.level).toBe("project") + expect(fs.existsSync(path.join(elsewhere, ".opencode", "memory.jsonc"))).toBe(false) + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) +}) + +describe("MEM-PR01-00: memory is inert under the shared global identity", () => { + it.live( + "search reports unavailable for a commit-less repository even when global config enables memory and the shared bucket holds topics", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* gitInitWithoutCommit(dir) + yield* provideInstance(dir)( + Effect.gen(function* () { + const project = yield* Project.Service + const memory = yield* Memory.Service + const configStore = yield* MemoryConfig.Service + const store = yield* MemoryStore.Service + + const { project: info } = yield* project.fromDirectory(dir) + expect(info.id).toBe(ProjectV2.ID.global) + yield* project.setInitialized(info.id) + + // An enabled global config must NOT activate memory for a project that + // has no identity of its own: every commit-less repository on the + // machine resolves to the same global bucket, so any read or write + // would leak across repositories and be orphaned by the first commit. + yield* configStore.writeGlobal(baseConfig) + // Simulate another commit-less repository having written into the + // shared bucket: memory must still refuse to serve it from here. + const seeded = topic() + yield* store.updateTopics(info.id, () => ({ + applied: { topics: [seeded], changed: [seeded.id], deleted: [] }, + result: undefined, + })) + + const sessionID = SessionID.make("ses_global_identity") + const result = yield* memory.search({ + sessionID, + messages: [userMessage(sessionID)], + query: "架构边界", + }) + expect(result.status).toBe("unavailable") + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) + + it.live( + "/memory on stays off for a commit-less repository and writes no project config", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* gitInitWithoutCommit(dir) + yield* provideInstance(dir)( + Effect.gen(function* () { + const project = yield* Project.Service + const memory = yield* Memory.Service + const configStore = yield* MemoryConfig.Service + + const { project: info } = yield* project.fromDirectory(dir) + expect(info.id).toBe(ProjectV2.ID.global) + yield* project.setInitialized(info.id) + yield* configStore.writeGlobal(baseConfig) + + expect(yield* memory.setEnabled(true)).toBe("Memory remains off") + expect(fs.existsSync(path.join(dir, ".opencode", "memory.jsonc"))).toBe(false) + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) + + it.live( + "inertness is identity-scoped: a repository with a commit activates normally under its real identity", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* provideInstance(dir)( + Effect.gen(function* () { + const project = yield* Project.Service + const memory = yield* Memory.Service + const configStore = yield* MemoryConfig.Service + + const { project: info } = yield* project.fromDirectory(dir) + expect(info.id).not.toBe(ProjectV2.ID.global) + yield* project.setInitialized(info.id) + yield* configStore.writeGlobal(baseConfig) + + const sessionID = SessionID.make("ses_real_identity") + const result = yield* memory.search({ + sessionID, + messages: [userMessage(sessionID)], + query: "架构边界", + }) + // Active (model calls are stubbed to fail, so search cannot succeed — + // but it must get PAST the activation gate, i.e. not "unavailable"). + expect(result.status).not.toBe("unavailable") + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) +}) diff --git a/packages/opencode/test/memory/memory-identity-migration.test.ts b/packages/opencode/test/memory/memory-identity-migration.test.ts new file mode 100644 index 0000000000..71d119c0d3 --- /dev/null +++ b/packages/opencode/test/memory/memory-identity-migration.test.ts @@ -0,0 +1,269 @@ +import { describe, expect } from "bun:test" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProjectV2 } from "@opencode-ai/core/project" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" +import { MemoryHome } from "@/memory/home" +import { MemoryIdentityMigration } from "@/memory/identity-migration" +import { MemorySchema } from "@/memory/schema" +import { MemoryStore } from "@/memory/store" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +const now = "2026-08-12T00:00:00Z" +const oldID = ProjectV2.ID.make("mig-old") +const newID = ProjectV2.ID.make("mig-new") + +function topic(id: string, summary: string): MemorySchema.Topic { + return { + schema_version: 1, + id, + name: `主题 ${id}`, + summary, + metadata: { + categories: ["decision"], + status: "active", + importance: "core", + keywords: ["架构"], + related_topics: [], + created_at: now, + updated_at: now, + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: `${id}-item`, + kind: "decision", + content: "已确认决定:核心模块之间使用稳定边界", + rationale: "该边界由用户确认并长期适用", + confirmed_at: now, + }, + ], + } satisfies MemorySchema.Topic +} + +function layers(root: string) { + const home = Layer.succeed(MemoryHome.Service, MemoryHome.make(root)) + const store = MemoryStore.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), + ) + const migration = MemoryIdentityMigration.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), + Layer.provide(store), + ) + return Layer.mergeAll(home, store, migration) +} + +function seed(projectID: ProjectV2.ID, topics: MemorySchema.Topic[]) { + return Effect.gen(function* () { + const store = yield* MemoryStore.Service + yield* store.updateTopics(projectID, () => ({ + applied: { topics, changed: topics.map((value) => value.id), deleted: [] }, + result: undefined, + })) + }) +} + +describe("MEM-PR01-R1-12: identity upgrade survives the store's own crash residue", () => { + it.live( + "a leftover manifest temp file in the source Home does not wedge the merge", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + const migration = yield* MemoryIdentityMigration.Service + + yield* seed(oldID, [topic("source-topic", "源仓库主题")]) + yield* seed(newID, [topic("target-topic", "另一仓库主题")]) + // Simulate a process killed between atomicWrite's temp write and rename: + // the store's own residue sits at the Home root next to manifest.json. + yield* fs.writeFileString(`${home.manifest(oldID)}.4242.deadbeef.tmp`, "partial") + + yield* migration.migrateHome(oldID, newID) + + const merged = yield* store.readSnapshot(newID) + expect(merged.topics.map((value) => value.id).sort()).toEqual(["source-topic", "target-topic"]) + expect(yield* fs.existsSafe(home.directory(oldID))).toBe(false) + }).pipe(Effect.provide(layers(root))) + }), + { timeout: 30_000 }, + ) + + it.live( + "a leftover manifest temp file in the target Home does not wedge the merge", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + const migration = yield* MemoryIdentityMigration.Service + + yield* seed(oldID, [topic("source-topic", "源仓库主题")]) + yield* seed(newID, [topic("target-topic", "另一仓库主题")]) + yield* fs.writeFileString(`${home.manifest(newID)}.4242.deadbeef.tmp`, "partial") + + yield* migration.migrateHome(oldID, newID) + + const merged = yield* store.readSnapshot(newID) + expect(merged.topics.map((value) => value.id).sort()).toEqual(["source-topic", "target-topic"]) + expect(yield* fs.existsSafe(home.directory(oldID))).toBe(false) + }).pipe(Effect.provide(layers(root))) + }), + { timeout: 30_000 }, + ) + + it.live( + "foreign files at the Home root still fail closed", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const migration = yield* MemoryIdentityMigration.Service + + yield* seed(oldID, [topic("source-topic", "源仓库主题")]) + yield* seed(newID, [topic("target-topic", "另一仓库主题")]) + yield* fs.writeFileString(`${home.directory(oldID)}/notes.txt`, "not ours") + + const error = yield* migration.migrateHome(oldID, newID).pipe(Effect.flip) + expect(error._tag).toBe("MemoryIdentityMigration.InvalidHome") + }).pipe(Effect.provide(layers(root))) + }), + { timeout: 30_000 }, + ) +}) + +describe("MEM-PR01-R1-24: opposite-direction migrations cannot deadlock", () => { + it.live( + "concurrent A→B and B→A migrations complete instead of wedging on nested flocks", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + const migration = yield* MemoryIdentityMigration.Service + + // Both Homes exist, so both directions take the merge path (not the + // rename fast path). Under the legacy locking, A→B holds flock(A) and + // waits for flock(B) inside the target update while B→A holds flock(B) + // and waits for flock(A) — a deadlock broken only by the 5 minute lock + // timeout, which this test's timeout deliberately undercuts. + yield* seed(oldID, [topic("topic-old", "旧身份的主题")]) + yield* seed(newID, [topic("topic-new", "新身份的主题")]) + + yield* Effect.all( + [migration.migrateHome(oldID, newID), migration.migrateHome(newID, oldID)], + { concurrency: 2 }, + ) + + const oldExists = yield* fs.existsSafe(home.directory(oldID)) + const newExists = yield* fs.existsSafe(home.directory(newID)) + // Exactly one Home survives, holding the union of both topic sets. + expect(oldExists).not.toBe(newExists) + const survivor = oldExists ? oldID : newID + const merged = yield* store.readSnapshot(survivor) + expect(merged.topics.map((value) => value.id).sort()).toEqual(["topic-new", "topic-old"]) + }).pipe(Effect.provide(layers(root))) + }), + { timeout: 20_000 }, + ) +}) + +describe("MEM-PR01-R1-13: interrupted migration retries to convergence", () => { + it.live( + "a crash after import but before source removal converges on retry", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + const migration = yield* MemoryIdentityMigration.Service + + const shared = topic("carried-topic", "迁移中断后仍然保留的主题") + // State a crash would leave behind: the import already landed in the + // target, the source Home still exists with the same content. + yield* seed(oldID, [shared]) + yield* seed(newID, [shared]) + + yield* migration.migrateHome(oldID, newID) + + expect(yield* fs.existsSafe(home.directory(oldID))).toBe(false) + const merged = yield* store.readSnapshot(newID) + expect(merged.topics.map((value) => value.id)).toEqual(["carried-topic"]) + }).pipe(Effect.provide(layers(root))) + }), + { timeout: 30_000 }, + ) +}) + +describe("MEM-PR01-R1-15: identity merge compares content, not controller metadata", () => { + it.live( + "the same topic with drifted match metadata is not a conflict", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + const migration = yield* MemoryIdentityMigration.Service + + const shared = topic("shared-topic", "两个仓库各自演化的同一主题") + // The target copy was matched live: controller metadata drifted while + // the content stayed identical. + const drifted = MemoryStore.markMatched([shared], ["shared-topic"]).topics[0] + expect(JSON.stringify(drifted)).not.toBe(JSON.stringify(shared)) + + yield* seed(oldID, [shared]) + yield* seed(newID, [drifted]) + + yield* migration.migrateHome(oldID, newID) + + const merged = yield* store.readSnapshot(newID) + expect(merged.topics.map((value) => value.id)).toEqual(["shared-topic"]) + // The target's own (newer) copy stays authoritative. + expect(merged.topics[0].metadata.match_count).toBe(drifted.metadata.match_count) + expect(yield* fs.existsSafe(home.directory(oldID))).toBe(false) + }).pipe(Effect.provide(layers(root))) + }), + { timeout: 30_000 }, + ) + + it.live( + "a real content difference is still a conflict", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const migration = yield* MemoryIdentityMigration.Service + + yield* seed(oldID, [topic("shared-topic", "源版本的内容")]) + yield* seed(newID, [topic("shared-topic", "新版本的内容完全不同")]) + + const error = yield* migration.migrateHome(oldID, newID).pipe(Effect.flip) + expect(error._tag).toBe("MemoryIdentityMigration.Conflict") + }).pipe(Effect.provide(layers(root))) + }), + { timeout: 30_000 }, + ) +}) diff --git a/packages/opencode/test/memory/memory-persistence.test.ts b/packages/opencode/test/memory/memory-persistence.test.ts new file mode 100644 index 0000000000..33f1dc95e0 --- /dev/null +++ b/packages/opencode/test/memory/memory-persistence.test.ts @@ -0,0 +1,906 @@ +import { describe, expect } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Exit, Fiber, Layer, Ref, Schema } from "effect" +import path from "node:path" +import { MemoryConfig } from "@/memory/config" +import { MemoryHome } from "@/memory/home" +import { MemoryIdentityFence } from "@/memory/identity-fence" +import { MemoryIdentityMigration } from "@/memory/identity-migration" +import { MemoryAdmission } from "@/memory/admission" +import { MemoryPaths } from "@/memory/paths" +import { MemorySchema } from "@/memory/schema" +import { MemoryStore } from "@/memory/store" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const projectID = ProjectV2.ID.make("project-memory-test") +const otherProjectID = ProjectV2.ID.make("project-memory-other") +const now = "2026-08-11T00:00:00Z" + +const config = { + schema_version: 1, + enabled: true, + model: "test/memory-small", + topic_limit: 10, + topic_limit_floor: 10, + turn_interval: 5, + injection: { max_topics: 3, max_tokens: 1_200 }, +} satisfies MemorySchema.Config + +function topic(summary = "已确认的核心架构边界") { + return { + schema_version: 1, + id: "project-architecture", + name: "架构边界", + summary, + metadata: { + categories: ["decision"], + status: "active", + importance: "core", + keywords: ["架构"], + related_topics: [], + created_at: now, + updated_at: now, + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: "decision-01", + kind: "decision", + content: "已确认决定:核心模块之间使用稳定边界", + rationale: "该边界由用户确认并长期适用", + confirmed_at: now, + }, + ], + } satisfies MemorySchema.Topic +} + +function terminologyTopic() { + const value = topic("术语 Project Memory 指项目级持久化记忆") + return { + ...value, + id: "project-memory-term", + name: "Project Memory 术语", + metadata: { + ...value.metadata, + categories: ["term"], + keywords: ["Project Memory"], + }, + items: [ + { + id: "term-01", + kind: "term", + content: "术语 Project Memory 指项目级持久化记忆", + rationale: "该术语由用户确认并长期适用", + confirmed_at: now, + }, + ], + } satisfies MemorySchema.Topic +} + +function layers(root: string) { + const home = Layer.succeed(MemoryHome.Service, MemoryHome.make(root)) + // One shared Database layer: the fence's liveness recheck and the test + // body's row setup must see the same rows. + const database = Database.defaultLayer + const store = MemoryStore.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), + ) + const fence = MemoryIdentityFence.layer.pipe( + Layer.provide(database), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), + ) + const admission = MemoryAdmission.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(MemoryConfig.defaultLayer), + Layer.provide(home), + Layer.provide(store), + Layer.provide(fence), + ) + return Layer.mergeAll(home, store, admission, MemoryConfig.defaultLayer, database) +} + +/** + * Inserts a live identity row. Production callers of ensure() always run with + * a live row (configuration() re-reads it, the worktree guard requires an + * initialized project); the fence's liveness recheck needs it in tests too. + */ +function insertLiveRow(id: ProjectV2.ID) { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id, worktree: AbsolutePath.make("/unused"), vcs: "git", sandboxes: [] }) + .run() + .pipe(Effect.orDie) + }) +} + +function replaceTopics(store: MemoryStore.Interface, id: ProjectV2.ID, topics: MemorySchema.Topic[]) { + return store + .updateTopics(id, () => ({ + applied: { topics, changed: topics.map((topic) => topic.id), deleted: [] }, + result: undefined, + })) + .pipe(Effect.asVoid) +} + +describe("Project-owned MEMORY persistence", () => { + it.live("derives a path-safe home from Project identity", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const home = MemoryHome.make(root) + const malicious = ProjectV2.ID.make("../../outside/project") + const directory = home.directory(malicious) + + expect(path.relative(root, directory)).not.toStartWith("..") + expect(path.dirname(directory)).toBe(path.join(root, "memory", "projects")) + expect(home.directory(malicious)).toBe(directory) + expect(home.directory(projectID)).not.toBe(home.directory(otherProjectID)) + }), + ) + + it.live("stores one authoritative Topic set per Project ID", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + const value = topic() + yield* replaceTopics(store, projectID, [value]) + + expect(yield* store.readTopics(projectID)).toEqual([value]) + expect(yield* store.readTopics(otherProjectID)).toEqual([]) + const manifest = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ generation: Schema.String })), + )(yield* fs.readFileString(home.manifest(projectID))) + const yaml = yield* fs.readFileString(path.join(home.generations(projectID), manifest.generation, `${value.id}.yaml`)) + expect(yaml).toContain("schema_version: 1") + expect(yaml).toContain("metadata:") + expect(MemoryStore.decodeTopic(value, "wrong-file-id")).toBeUndefined() + expect(MemoryStore.decodeTopic({ ...value, extra: "not allowed" })).toBeUndefined() + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("moves the authoritative Topic set when Project identity changes", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const migration = yield* MemoryIdentityMigration.Service + const store = yield* MemoryStore.Service + const value = topic() + yield* replaceTopics(store, projectID, [value]) + + yield* migration.migrateHome(projectID, otherProjectID) + + expect(yield* store.readTopics(otherProjectID)).toEqual([value]) + expect(yield* fs.exists(home.directory(projectID))).toBe(false) + }).pipe( + Effect.provide( + Layer.provideMerge( + MemoryIdentityMigration.layer.pipe(Layer.provide(EffectFlock.defaultLayer)), + layers(root), + ), + ), + ) + }), + ) + + it.live("merges non-conflicting Topic sets when the new identity already has Memory", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const migration = yield* MemoryIdentityMigration.Service + const store = yield* MemoryStore.Service + const source = topic() + const target = terminologyTopic() + yield* replaceTopics(store, projectID, [source]) + yield* replaceTopics(store, otherProjectID, [target]) + + yield* migration.migrateHome(projectID, otherProjectID) + + expect(yield* store.readTopics(otherProjectID)).toEqual([source, target]) + expect(yield* fs.exists(home.directory(projectID))).toBe(false) + }).pipe( + Effect.provide( + Layer.provideMerge( + MemoryIdentityMigration.layer.pipe(Layer.provide(EffectFlock.defaultLayer)), + layers(root), + ), + ), + ) + }), + ) + + it.live("fails closed when either Memory Home contains an unreadable Topic", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const migration = yield* MemoryIdentityMigration.Service + const store = yield* MemoryStore.Service + const target = terminologyTopic() + const invalid = path.join(home.topics(projectID), "broken.yaml") + yield* fs.makeDirectory(path.dirname(invalid), { recursive: true }) + yield* fs.writeFileString(invalid, "{ invalid") + yield* replaceTopics(store, otherProjectID, [target]) + + const exit = yield* Effect.exit(migration.migrateHome(projectID, otherProjectID)) + + expect(exit._tag).toBe("Failure") + expect(yield* fs.exists(invalid)).toBe(true) + expect(yield* store.readTopics(otherProjectID)).toEqual([target]) + }).pipe( + Effect.provide( + Layer.provideMerge( + MemoryIdentityMigration.layer.pipe(Layer.provide(EffectFlock.defaultLayer)), + layers(root), + ), + ), + ) + }), + ) + + it.live("preserves unknown Memory Home resources instead of deleting them during a merge", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const migration = yield* MemoryIdentityMigration.Service + const store = yield* MemoryStore.Service + const source = topic() + const target = terminologyTopic() + const unknown = path.join(home.directory(projectID), "future-resource.json") + yield* replaceTopics(store, projectID, [source]) + yield* replaceTopics(store, otherProjectID, [target]) + yield* fs.writeFileString(unknown, "{}") + + const exit = yield* Effect.exit(migration.migrateHome(projectID, otherProjectID)) + + expect(exit._tag).toBe("Failure") + expect(yield* fs.exists(unknown)).toBe(true) + expect(yield* store.readTopics(otherProjectID)).toEqual([target]) + }).pipe( + Effect.provide( + Layer.provideMerge( + MemoryIdentityMigration.layer.pipe(Layer.provide(EffectFlock.defaultLayer)), + layers(root), + ), + ), + ) + }), + ) + + it.live("preserves both Memory Homes when the same Topic ID has different content", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const migration = yield* MemoryIdentityMigration.Service + const store = yield* MemoryStore.Service + const source = topic("已确认的架构接口边界") + const target = topic("已确认的模块接口边界") + yield* replaceTopics(store, projectID, [source]) + yield* replaceTopics(store, otherProjectID, [target]) + + const exit = yield* Effect.exit(migration.migrateHome(projectID, otherProjectID)) + + expect(exit._tag).toBe("Failure") + expect(yield* store.readTopics(projectID)).toEqual([source]) + expect(yield* store.readTopics(otherProjectID)).toEqual([target]) + expect(yield* fs.exists(home.directory(projectID))).toBe(true) + }).pipe( + Effect.provide( + Layer.provideMerge( + MemoryIdentityMigration.layer.pipe(Layer.provide(EffectFlock.defaultLayer)), + layers(root), + ), + ), + ) + }), + ) + + it.live("imports, deduplicates, and preserves conflicting legacy Topics", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const sandbox = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const admission = yield* MemoryAdmission.Service + const store = yield* MemoryStore.Service + yield* insertLiveRow(projectID) + const file = path.join(MemoryPaths.legacyTopics(sandbox), "project-architecture.yaml") + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, Bun.YAML.stringify(topic())) + + const imported = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [primary, sandbox], + updated: 1, + }) + expect(imported.diagnostics.map((item) => item.code)).toEqual(["topic.imported"]) + expect(yield* fs.exists(file)).toBe(false) + expect(yield* store.readTopics(projectID)).toEqual([topic()]) + expect(yield* fs.exists(home.manifest(projectID))).toBe(true) + + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, Bun.YAML.stringify(topic())) + const duplicate = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [sandbox], + updated: 2, + }) + expect(duplicate.diagnostics.map((item) => item.code)).toEqual(["topic.duplicate"]) + expect(yield* fs.exists(file)).toBe(false) + + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, Bun.YAML.stringify(topic("不同的已确认架构边界"))) + const conflict = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [sandbox], + updated: 3, + }) + expect(conflict.diagnostics.map((item) => item.code)).toEqual(["topic.conflict"]) + expect(conflict.unresolved).toBe(1) + expect(yield* fs.exists(file)).toBe(true) + expect(yield* store.readTopics(projectID)).toEqual([topic()]) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("serializes concurrent migration attempts by Project ID", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const first = yield* tmpdirScoped() + const second = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const admission = yield* MemoryAdmission.Service + const store = yield* MemoryStore.Service + yield* insertLiveRow(projectID) + const firstFile = path.join(MemoryPaths.legacyTopics(first), "project-architecture.yaml") + const secondFile = path.join(MemoryPaths.legacyTopics(second), "project-architecture.yaml") + yield* fs.makeDirectory(path.dirname(firstFile), { recursive: true }) + yield* fs.makeDirectory(path.dirname(secondFile), { recursive: true }) + yield* fs.writeFileString(firstFile, Bun.YAML.stringify(topic("first"))) + yield* fs.writeFileString(secondFile, Bun.YAML.stringify(topic("second"))) + + const results = yield* Effect.all( + [ + admission.ensure({ projectID, projectDirectory: primary, directories: [first], updated: 1 }), + admission.ensure({ projectID, projectDirectory: primary, directories: [second], updated: 1 }), + ], + { concurrency: "unbounded" }, + ) + + expect(results.flatMap((result) => result.diagnostics.map((item) => item.code)).sort()).toEqual([ + "topic.conflict", + "topic.imported", + ]) + expect(yield* store.readTopics(projectID)).toHaveLength(1) + expect([yield* fs.exists(firstFile), yield* fs.exists(secondFile)].filter(Boolean)).toHaveLength(1) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("preserves concurrent updates from separate processes", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const coordination = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const store = yield* MemoryStore.Service + const value = topic() + yield* replaceTopics(store, projectID, [value]) + const go = path.join(coordination, "go") + const workers = [ + { itemID: "decision-a", content: "已确认决定:保留并发更新甲" }, + { itemID: "decision-b", content: "已确认决定:保留并发更新乙" }, + ].map((worker) => { + const ready = path.join(coordination, `${worker.itemID}.ready`) + const child = Bun.spawn([ + process.execPath, + path.join(import.meta.dir, "../fixture/memory-store-worker.ts"), + JSON.stringify({ root, projectID, ready, go, ...worker }), + ]) + return { child, ready } + }) + while ( + !(yield* Effect.promise(() => + Promise.all(workers.map((worker) => Bun.file(worker.ready).exists())).then((ready) => ready.every(Boolean)), + )) + ) + yield* Effect.sleep("5 millis") + yield* Effect.promise(() => Bun.write(go, "go")) + + expect(yield* Effect.promise(() => Promise.all(workers.map((worker) => worker.child.exited)))).toEqual([0, 0]) + expect((yield* store.readTopics(projectID))[0]?.items.map((item) => item.id).sort()).toEqual([ + "decision-01", + "decision-a", + "decision-b", + ]) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("recovers the complete committed generation after Store restart", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const value = topic() + const committed = yield* Effect.gen(function* () { + const store = yield* MemoryStore.Service + const initial = yield* store.readSnapshot(projectID) + expect(initial).toEqual({ revision: 0, topics: [] }) + return yield* store.updateTopics(projectID, () => ({ + applied: { topics: [value], changed: [value.id], deleted: [] }, + result: undefined, + })) + }).pipe(Effect.provide(layers(root))) + + const recovered = yield* Effect.gen(function* () { + const store = yield* MemoryStore.Service + return yield* store.readSnapshot(projectID) + }).pipe(Effect.provide(layers(root))) + + expect(committed.revision).toBe(1) + expect(recovered).toEqual({ revision: 1, topics: [value] }) + }), + ) + + it.live("rejects an invalid generation before publishing its manifest", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const store = yield* MemoryStore.Service + const value = topic() + yield* replaceTopics(store, projectID, [value]) + const invalid = { ...value, summary: "目标状态不允许进入 Project Memory" } + + const exit = yield* Effect.exit( + store.updateTopics(projectID, () => ({ + applied: { topics: [invalid], changed: [invalid.id], deleted: [] }, + result: undefined, + })), + ) + + expect(exit._tag).toBe("Failure") + expect(yield* store.readSnapshot(projectID)).toEqual({ revision: 1, topics: [value] }) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("rejects a stale expected revision without replacing the committed generation", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const store = yield* MemoryStore.Service + const first = topic() + const stale = terminologyTopic() + + const committed = yield* store.commit(projectID, 0, { + topics: [first], + changed: [first.id], + deleted: [], + }) + const exit = yield* Effect.exit( + store.commit(projectID, 0, { + topics: [stale], + changed: [stale.id], + deleted: [], + }), + ) + + expect(committed).toEqual({ revision: 1, topics: [first] }) + expect(exit._tag).toBe("Failure") + expect(yield* store.readSnapshot(projectID)).toEqual(committed) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("keeps invalid Topics and conflicting sandbox config for repair", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const sandbox = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const configStore = yield* MemoryConfig.Service + const admission = yield* MemoryAdmission.Service + yield* insertLiveRow(projectID) + const invalid = path.join(MemoryPaths.legacyTopics(sandbox), "broken.yaml") + const sandboxConfig = path.join(sandbox, ".opencode", "memory.jsonc") + yield* fs.makeDirectory(path.dirname(invalid), { recursive: true }) + yield* configStore.writeProject(primary, config) + yield* fs.writeFileString(invalid, "{ invalid") + yield* fs.writeFileString(sandboxConfig, JSON.stringify({ ...config, enabled: false })) + + const result = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [primary, sandbox], + updated: 1, + }) + expect(result.diagnostics.map((item) => item.code)).toEqual(["topic.invalid", "config.conflict"]) + expect(result.unresolved).toBe(2) + expect(yield* fs.exists(invalid)).toBe(true) + expect(yield* fs.exists(sandboxConfig)).toBe(true) + + yield* fs.writeFileString(sandboxConfig, JSON.stringify(config)) + const duplicate = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [sandbox], + updated: 2, + }) + expect(duplicate.diagnostics.map((item) => item.code)).toEqual(["topic.invalid", "config.duplicate"]) + expect(yield* fs.exists(sandboxConfig)).toBe(false) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live("promotes a sandbox config even when it matches the global fallback", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const sandbox = yield* tmpdirScoped() + const global = yield* tmpdirScoped() + const previous = process.env.OPENCODE_CONFIG_DIR + + yield* Effect.acquireUseRelease( + Effect.sync(() => { + process.env.OPENCODE_CONFIG_DIR = global + }), + () => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const admission = yield* MemoryAdmission.Service + yield* insertLiveRow(projectID) + const sandboxConfig = path.join(sandbox, ".opencode", "memory.jsonc") + yield* fs.writeFileString(path.join(global, "memory.jsonc"), JSON.stringify(config)) + yield* fs.makeDirectory(path.dirname(sandboxConfig), { recursive: true }) + yield* fs.writeFileString(sandboxConfig, JSON.stringify(config)) + + const result = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [sandbox], + updated: 1, + }) + + expect(result.diagnostics.map((item) => item.code)).toEqual(["config.promoted"]) + expect(yield* fs.exists(sandboxConfig)).toBe(false) + expect((yield* (yield* MemoryConfig.Service).load(primary))?.level).toBe("project") + }).pipe(Effect.provide(layers(root))), + () => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = previous + }), + ) + }), + ) + + it.live("compares normalized project and sandbox configs", () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const sandbox = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const configStore = yield* MemoryConfig.Service + const admission = yield* MemoryAdmission.Service + yield* insertLiveRow(projectID) + const value = { ...config, topic_limit: 50, topic_limit_floor: 10 } + const sandboxConfig = path.join(sandbox, ".opencode", "memory.jsonc") + yield* configStore.writeProject(primary, value) + yield* fs.makeDirectory(path.dirname(sandboxConfig), { recursive: true }) + yield* fs.writeFileString(sandboxConfig, JSON.stringify(value)) + + const result = yield* admission.ensure({ + projectID, + projectDirectory: primary, + directories: [sandbox], + updated: 1, + }) + + expect(result.diagnostics.map((item) => item.code)).toEqual(["config.duplicate"]) + expect(yield* fs.exists(sandboxConfig)).toBe(false) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live( + "fails closed on a corrupt manifest and never deletes the unread Home (MEM-PR01-R1-02)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const migration = yield* MemoryIdentityMigration.Service + const store = yield* MemoryStore.Service + // Both identities hold Memory so the migration takes the merge path + // (the rename fast path never deletes anything). + yield* replaceTopics(store, projectID, [topic()]) + yield* replaceTopics(store, otherProjectID, [topic("新身份的主题")]) + + // (a) invalid manifest JSON + yield* fs.writeFileString(home.manifest(projectID), "{ not json") + expect(Exit.isFailure(yield* Effect.exit(store.readSnapshot(projectID)))).toBe(true) + const invalid = yield* Effect.exit(migration.migrateHome(projectID, otherProjectID)) + expect(Exit.isFailure(invalid)).toBe(true) + expect(yield* fs.exists(home.directory(projectID))).toBe(true) + + // (b) manifest referencing a generation that does not exist + yield* fs.writeFileString( + home.manifest(projectID), + JSON.stringify({ schema_version: 1, revision: 1, generation: "1-deadbeef" }) + "\n", + ) + expect(Exit.isFailure(yield* Effect.exit(store.readSnapshot(projectID)))).toBe(true) + const missing = yield* Effect.exit(migration.migrateHome(projectID, otherProjectID)) + expect(Exit.isFailure(missing)).toBe(true) + expect(yield* fs.exists(home.directory(projectID))).toBe(true) + }).pipe( + Effect.provide( + Layer.provideMerge( + MemoryIdentityMigration.layer.pipe(Layer.provide(EffectFlock.defaultLayer)), + layers(root), + ), + ), + ) + }), + ) + + it.live( + "rejects Topics whose item_count disagrees with their items (MEM-PR01-R1-20)", + () => + Effect.gen(function* () { + const base = topic() + const drifted = { ...base, metadata: { ...base.metadata, item_count: base.items.length + 1 } } + expect(MemoryStore.decodeTopic(drifted, drifted.id)).toBeUndefined() + + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const store = yield* MemoryStore.Service + const exit = yield* Effect.exit(replaceTopics(store, projectID, [drifted])) + expect(Exit.isFailure(exit)).toBe(true) + expect(yield* store.readTopics(projectID)).toEqual([]) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live( + "serializes project config writes on a per-file lock (MEM-PR01-R2-02)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + yield* Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const fs = yield* FSUtil.Service + const configStore = yield* MemoryConfig.Service + const target = MemoryConfig.projectPath(primary) + + // Hold the file's write lock; a concurrent writeProject must queue + // behind it and may only complete after the release. + const done = yield* Ref.make(false) + const writerCell = yield* Ref.make | undefined>(undefined) + // Hold the file's write lock; a writer forked while the lock is held + // must stay blocked until the lock is released at the end of withLock. + yield* flock.withLock( + Effect.gen(function* () { + const writer = yield* Effect.gen(function* () { + yield* configStore.writeProject(primary, config) + yield* Ref.set(done, true) + }).pipe(Effect.forkDetach) + yield* Ref.set(writerCell, writer) + yield* Effect.sleep("300 millis") + expect(yield* Ref.get(done)).toBe(false) + }), + MemoryConfig.writeLockKey(target), + ) + const writer = (yield* Ref.get(writerCell))! + yield* Fiber.join(writer) + expect(yield* Ref.get(done)).toBe(true) + expect(yield* fs.existsSafe(target)).toBe(true) + }).pipe(Effect.provide(Layer.mergeAll(layers(root), EffectFlock.defaultLayer))) + }), + { timeout: 20_000 }, + ) + + it.live( + "a second process committing a stale revision observes the explicit conflict (MEM-PR01-R2-03)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const coordination = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const store = yield* MemoryStore.Service + const value = topic() + yield* replaceTopics(store, projectID, [value]) + + const go = path.join(coordination, "go") + const ready = path.join(coordination, "stale.ready") + const child = Bun.spawn([ + process.execPath, + path.join(import.meta.dir, "../fixture/memory-commit-worker.ts"), + JSON.stringify({ + root, + projectID, + ready, + go, + expectedRevision: 0, + summary: "跨进程的陈旧修订", + }), + ]) + while (!(yield* Effect.promise(() => Bun.file(ready).exists()))) yield* Effect.sleep("5 millis") + yield* Effect.promise(() => Bun.write(go, "go")) + + // Exit 0 means the worker observed CommitConflictError — the explicit + // cross-process conflict guarantee of the commit protocol. + expect(yield* Effect.promise(() => child.exited)).toBe(0) + expect(yield* store.readSnapshot(projectID)).toEqual({ revision: 1, topics: [value] }) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live( + "an orphaned staging generation never shadows the committed generation (MEM-PR01-R1-21)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + const value = topic() + yield* replaceTopics(store, projectID, [value]) + + // Simulate a crash mid-writeSnapshot: a staging generation exists but + // its manifest was never published. + const staging = path.join(home.generations(projectID), ".2-orphaned.tmp") + yield* fs.makeDirectory(staging, { recursive: true }) + yield* fs.writeFileString(path.join(staging, "orphan.yaml"), "id: orphan\n") + + expect(yield* store.readTopics(projectID)).toEqual([value]) + // The store still commits cleanly afterwards. + const next = topic("第二版边界") + yield* replaceTopics(store, projectID, [next]) + expect(yield* store.readTopics(projectID)).toEqual([next]) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live( + "write paths fail closed on a corrupt manifest (pins the strict re-read before write)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const store = yield* MemoryStore.Service + yield* replaceTopics(store, projectID, [topic()]) + + yield* fs.writeFileString(home.manifest(projectID), "{ not json") + + const exit = yield* Effect.exit(replaceTopics(store, projectID, [topic("修订后的边界")])) + expect(Exit.isFailure(exit)).toBe(true) + // The corrupt manifest is left untouched (no silent re-init). + expect(yield* fs.readFileString(home.manifest(projectID))).toBe("{ not json") + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live( + "never caches unresolved admission results (pins the ADR-0003 cache rule)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const primary = yield* tmpdirScoped({ git: true }) + const sandbox = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const admission = yield* MemoryAdmission.Service + yield* insertLiveRow(projectID) + const file = path.join(sandbox, ".opencode", "memory", "topics", "broken.yaml") + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, "id: broken\n") + + const snapshot = { projectID, projectDirectory: primary, directories: [primary, sandbox], updated: 1 } + const first = yield* admission.ensure(snapshot) + expect(first.unresolved).toBeGreaterThan(0) + + // Repair the legacy file. A cached unresolved result would keep + // blocking; the cache rule requires a fresh scan. + yield* fs.remove(file) + const second = yield* admission.ensure(snapshot) + expect(second.unresolved).toBe(0) + }).pipe(Effect.provide(layers(root))) + }), + ) + + it.live( + "fails closed with SourceChanged when the source changes mid-merge (pins the verify-before-delete guard)", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const home = yield* MemoryHome.Service + const flock = yield* EffectFlock.Service + const migration = yield* MemoryIdentityMigration.Service + const store = yield* MemoryStore.Service + + // Both Homes populated → merge path (not the rename fast path). + yield* replaceTopics(store, projectID, [topic()]) + yield* replaceTopics(store, otherProjectID, [terminologyTopic()]) + + // Hold the target's store lock in this flow; migrateHome blocks there + // in phase 2 AFTER snapshotting the source — a deterministic window in + // which the source may still change. Fork migrateHome detached so it + // survives the withLock scope closing, then bump the source while the + // target lock is still held; releasing the lock (withLock end) lets + // the migration proceed into the verify-before-delete check. + const migratingCell = yield* Ref.make | undefined>(undefined) + yield* flock.withLock( + Effect.gen(function* () { + const migrating = yield* migration.migrateHome(projectID, otherProjectID).pipe(Effect.forkDetach) + yield* Ref.set(migratingCell, migrating) + yield* Effect.sleep("500 millis") + // A concurrent writer bumps the source revision mid-merge. + yield* replaceTopics(store, projectID, [topic("迁移进行中被修订的边界")]) + }), + `memory-project:${otherProjectID}`, + home.locks, + ) + const migrating = (yield* Ref.get(migratingCell))! + const exit = yield* Fiber.join(migrating).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("SourceChanged") + // The source Home survives (verify-before-delete refused to remove it). + expect(yield* fs.exists(home.directory(projectID))).toBe(true) + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.provideMerge( + MemoryIdentityMigration.layer.pipe(Layer.provide(EffectFlock.defaultLayer)), + layers(root), + ), + EffectFlock.defaultLayer, + ), + ), + ) + }), + { timeout: 20_000 }, + ) +}) diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 205a3d3fde..eaf2185b04 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -1,12 +1,17 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Deferred, Duration, Effect, Fiber, Layer } from "effect" import fs from "node:fs/promises" import path from "node:path" import { Config } from "@/config/config" import { Git } from "@/git" +import { MemoryAdmission } from "@/memory/admission" import { MemoryConfig } from "@/memory/config" +import { MemoryHome } from "@/memory/home" +import { MemoryIdentityFence } from "@/memory/identity-fence" +import { MemoryLock } from "@/memory/lock" import { Memory } from "@/memory/memory" import { MemoryModel } from "@/memory/model" import { MemoryPrompts } from "@/memory/prompts" @@ -47,6 +52,13 @@ let writtenProjectConfig: MemorySchema.Config | undefined const emptyConfigLayer = Layer.mock(Config.Service, { get: () => Effect.succeed({}), }) +const readyAdmissionLayer = Layer.mock(MemoryAdmission.Service, { + ensure: () => + Effect.succeed(new MemoryAdmission.Result({ diagnostics: [], imported: 0, duplicates: 0, unresolved: 0 })), + invalidate: () => Effect.void, +}) +let loadedProjectDirectory: string | undefined +let migrationUnresolved = 0 function topic(id = "architecture-boundaries") { return { @@ -88,6 +100,9 @@ const unavailableModelIt = testEffect( Layer.provide( Layer.mergeAll( emptyConfigLayer, + EffectFlock.defaultLayer, + MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, replacementProvider.layer, Layer.mock(Project.Service, { get: (id) => @@ -101,10 +116,13 @@ const unavailableModelIt = testEffect( }), Layer.mock(MemoryConfig.Service, { load: (directory) => - Effect.succeed({ - config: { ...config, enabled: false, model: "removed/model" }, - path: directory, - level: "project" as const, + Effect.sync(() => { + loadedProjectDirectory = directory + return { + config: { ...config, enabled: false, model: "removed/model" }, + path: directory, + level: "project" as const, + } }), loadGlobal: () => Effect.succeed({ @@ -122,12 +140,28 @@ const unavailableModelIt = testEffect( writtenProjectConfig = next }), }), + Layer.mock(MemoryAdmission.Service, { + ensure: () => + Effect.succeed( + new MemoryAdmission.Result({ + diagnostics: [], + imported: 0, + duplicates: 0, + unresolved: migrationUnresolved, + }), + ), + invalidate: () => Effect.void, + }), + MemoryLock.defaultLayer, Layer.mock(MemoryModel.Service, { generate: () => Effect.succeed({ model: "test/replacement", topic_limit: 10, turn_interval: 5 }), }), Layer.mock(MemoryStore.Service, { - ensureGitExclude: () => Effect.void, - writeTopics: () => Effect.void, + updateTopics: (_projectID, update) => + Effect.sync(() => { + const next = update([]) + return { revision: 1, topics: next.applied.topics, result: next.result } + }), }), ), ), @@ -163,6 +197,9 @@ function bootstrapFixture() { const layer = Memory.layer.pipe( Layer.provide( Layer.mergeAll( + EffectFlock.defaultLayer, + MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, Layer.mock(Config.Service, { get: () => Effect.succeed({ @@ -237,10 +274,10 @@ function bootstrapFixture() { throw new Error("bootstrap must not call a model") }), }), + readyAdmissionLayer, + MemoryLock.defaultLayer, Layer.mock(MemoryStore.Service, { readTopics: () => Effect.succeed([]), - ensureGitExclude: () => Effect.void, - writeTopics: () => Effect.void, }), ), ), @@ -292,6 +329,9 @@ function recallFixture() { Layer.provide( Layer.mergeAll( emptyConfigLayer, + EffectFlock.defaultLayer, + MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, provider.layer, Layer.mock(Project.Service, { get: (id) => @@ -327,14 +367,20 @@ function recallFixture() { return { actions: [{ type: "no_change" }] } }), }), + readyAdmissionLayer, + MemoryLock.defaultLayer, Layer.mock(MemoryStore.Service, { readTopics: () => Effect.sync(() => { state.reads++ return state.topics }), - writeTopics: () => Effect.void, - ensureGitExclude: () => Effect.void, + updateTopics: (_projectID, update) => + Effect.sync(() => { + const next = update(state.topics) + state.topics = next.applied.topics + return { revision: 1, topics: state.topics, result: next.result } + }), }), ), ), @@ -456,54 +502,6 @@ describe("memory config and YAML store", () => { ) }), ) - - it.live("round-trips one fixed YAML document per topic and isolates worktrees", () => - Effect.gen(function* () { - const store = yield* MemoryStore.Service - const first = yield* tmpdirScoped({ git: true }) - const second = yield* tmpdirScoped() - const git = yield* Git.Service - yield* Effect.promise(() => fs.rm(second, { recursive: true, force: true })) - const added = yield* git.run(["worktree", "add", "-b", "memory-linked", second], { cwd: first }) - expect(added.exitCode).toBe(0) - const firstTopic = topic("first-worktree") - const secondTopic = topic("second-worktree") - - yield* store.writeTopics(first, { topics: [firstTopic], changed: [firstTopic.id], deleted: [] }) - yield* store.writeTopics(second, { - topics: [secondTopic], - changed: [secondTopic.id], - deleted: [], - }) - - expect(yield* store.readTopics(first)).toEqual([firstTopic]) - expect(yield* store.readTopics(second)).toEqual([secondTopic]) - expect(MemoryStore.topicsDir(first)).not.toBe(MemoryStore.topicsDir(second)) - - const yaml = yield* Effect.promise(() => - fs.readFile(path.join(MemoryStore.topicsDir(first), `${firstTopic.id}.yaml`), "utf-8"), - ) - expect(yaml).toContain("schema_version: 1") - expect(yaml).toContain("metadata:") - expect(yaml).toContain("items:") - expect(MemoryStore.decodeTopic(firstTopic, "wrong-file-id")).toBeUndefined() - expect(MemoryStore.decodeTopic({ ...firstTopic, extra: "not allowed" })).toBeUndefined() - expect( - MemoryStore.decodeTopic({ - ...firstTopic, - metadata: { ...firstTopic.metadata, item_count: 2 }, - }), - ).toBeUndefined() - - yield* Effect.promise(() => - fs.writeFile( - path.join(MemoryStore.topicsDir(first), "invalid-topic.yaml"), - "schema_version: 1\nid: invalid-topic\n", - ), - ) - expect(yield* store.readTopics(first)).toEqual([firstTopic]) - }), - ) }) describe("memory controller policy", () => { @@ -667,7 +665,7 @@ describe("memory controller policy", () => { apply("decision", "Confirmed decision: use stable boundaries", "User explicitly confirmed this durable decision"), ).not.toThrow() expect(() => - apply("term", "MEMORY means worktree-local durable preferences", "User explicitly confirmed this stable term"), + apply("term", "MEMORY means Project-owned durable preferences", "User explicitly confirmed this stable term"), ).not.toThrow() }) @@ -686,6 +684,7 @@ describe("memory controller policy", () => { expect(rendered).toHaveLength(1) expect(rendered[0]).toContain("first-topic") expect(rendered[0]).not.toContain("second-topic") + expect(rendered[0]).toContain("Project-owned historical data shared by this Project's worktrees") expect(rendered[0]).toContain("Current user input and higher-priority instructions always win") expect( [ @@ -1282,25 +1281,26 @@ describe("memory turn-scoped retrieval", () => { ) }) -describe("memory Git exclusions", () => { - it.live("installs exact local exclusions idempotently without touching .gitignore", () => +describe("memory project config Git exclusions", () => { + it.live("installs exact config exclusions idempotently without touching .gitignore", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) const git = yield* Git.Service - const store = yield* MemoryStore.Service + const configStore = yield* MemoryConfig.Service yield* Effect.promise(() => fs.writeFile(path.join(tmp, ".gitignore"), "keep-me\n")) - yield* store.ensureGitExclude(tmp) - yield* store.ensureGitExclude(tmp) + yield* configStore.writeProject(tmp, config) + yield* configStore.writeProject(tmp, config) const resolved = yield* git.run(["rev-parse", "--git-path", "info/exclude"], { cwd: tmp }) const raw = resolved.text().trim() const exclude = path.isAbsolute(raw) ? raw : path.resolve(tmp, raw) const lines = (yield* Effect.promise(() => fs.readFile(exclude, "utf-8"))).split(/\r?\n/) - for (const rule of [".opencode/memory.jsonc", ".opencode/memory.json", ".opencode/memory/"]) { + for (const rule of [".opencode/memory.jsonc", ".opencode/memory.json"]) { expect(lines.filter((line) => line === rule)).toHaveLength(1) } + expect(lines).not.toContain(".opencode/memory/") expect(yield* Effect.promise(() => fs.readFile(path.join(tmp, ".gitignore"), "utf-8"))).toBe("keep-me\n") }), ) @@ -1652,11 +1652,29 @@ describe("memory enablement", () => { Effect.gen(function* () { writtenGlobalConfig = undefined writtenProjectConfig = undefined + loadedProjectDirectory = undefined + migrationUnresolved = 0 const memory = yield* Memory.Service yield* memory.init() expect(writtenGlobalConfig).toMatchObject({ model: "test/replacement" }) expect(yield* memory.setEnabled(true)).toBe("Memory on") expect(writtenProjectConfig).toMatchObject({ enabled: true, model: "test/replacement" }) + expect(String(loadedProjectDirectory)).toBe("/unused") + }), + { git: true }, + ) + + unavailableModelIt.instance( + "keeps MEMORY inert until Project admission succeeds", + () => + Effect.gen(function* () { + loadedProjectDirectory = undefined + migrationUnresolved = 1 + const memory = yield* Memory.Service + + expect(yield* memory.context(SessionID.make("ses_memory_unresolved"))).toEqual([]) + expect(loadedProjectDirectory).toBeUndefined() + migrationUnresolved = 0 }), { git: true }, ) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 05e205cd87..5d6e291cbb 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -9,6 +9,9 @@ import { Database } from "@opencode-ai/core/database/database" import { ProjectTable } from "@opencode-ai/core/project/sql" import { SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { WorkflowTable } from "@opencode-ai/core/dag/sql" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { PermissionTable } from "@opencode-ai/core/permission/sql" import { eq } from "drizzle-orm" import { Hash } from "@opencode-ai/core/util/hash" import { SessionID } from "@/session/schema" @@ -21,8 +24,13 @@ import { AppProcess } from "@opencode-ai/core/process" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" +import { MemoryHome } from "@/memory/home" +import { MemoryIdentityMigration } from "@/memory/identity-migration" +import { MemoryStore } from "@/memory/store" +import { ProjectIdentityMigration } from "@/project/identity-migration" const encoder = new TextEncoder() @@ -79,6 +87,7 @@ function projectLayerWithFailure(failArg: string) { Layer.provide(NodePath.layer), Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(ProjectIdentityMigration.defaultLayer), ) } @@ -92,9 +101,44 @@ function projectLayerWithRuntimeFlags(flags: Parameters testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) @@ -223,6 +267,27 @@ describe("Project.fromDirectory", () => { .values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id }) .run() .pipe(Effect.orDie) + // A DAG workflow and a saved permission belong to the root identity. Both are + // ON DELETE CASCADE on project_id, so they must be repointed (not lost) on upgrade. + yield* db + .insert(WorkflowTable) + .values({ + id: "dag-app", + project_id: rootProject.id, + session_id: sessionID, + title: "App workflow", + status: "running", + config: "{}", + seq: 1, + wake_reported: false, + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(PermissionTable) + .values({ id: "perm-app" as never, project_id: rootProject.id, action: "allow", resource: "test" }) + .run() + .pipe(Effect.orDie) yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet()) const result = yield* projects.fromDirectory(tmp) @@ -239,6 +304,188 @@ describe("Project.fromDirectory", () => { (yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie)) ?.project_id, ).toBe(remoteID) + expect( + (yield* db.select().from(WorkflowTable).where(eq(WorkflowTable.id, "dag-app")).get().pipe(Effect.orDie)) + ?.project_id, + ).toBe(remoteID) + expect( + (yield* db.select().from(PermissionTable).where(eq(PermissionTable.id, "perm-app" as never)).get().pipe(Effect.orDie)) + ?.project_id, + ).toBe(remoteID) + }), + ) + + it.live( + "identity upgrade survives a permission uniqueness collision with the successor identity (MEM-PR01-R1-11)", + () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const tmp = yield* tmpdirScoped({ git: true }) + const projects = yield* Project.Service + const rootResult = yield* projects.fromDirectory(tmp) + const rootProject = rootResult.project + const remoteID = remoteProjectID("github.com/acme/collide") + + // The successor identity already exists (another checkout resolved it + // first) and owns a permission colliding with the root identity's on + // (project_id, action, resource). The upgrade must not wedge on the + // unique index: the successor row wins, the duplicate is dropped, and + // disjoint permissions still repoint. + const rootRow = yield* db + .select() + .from(ProjectTable) + .where(eq(ProjectTable.id, rootProject.id)) + .get() + .pipe(Effect.orDie) + yield* db + .insert(ProjectTable) + .values({ ...rootRow!, id: remoteID, time_updated: Date.now() }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(PermissionTable) + .values({ id: PermissionSaved.ID.make("perm-successor"), project_id: remoteID, action: "allow", resource: "test" }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(PermissionTable) + .values({ id: PermissionSaved.ID.make("perm-colliding"), project_id: rootProject.id, action: "allow", resource: "test" }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(PermissionTable) + .values({ id: PermissionSaved.ID.make("perm-disjoint"), project_id: rootProject.id, action: "allow", resource: "other" }) + .run() + .pipe(Effect.orDie) + yield* Effect.promise(() => $`git remote add origin git@github.com:acme/collide.git`.cwd(tmp).quiet()) + + const result = yield* projects.fromDirectory(tmp) + + expect(result.project.id).toBe(remoteID) + const permissions = yield* db + .select() + .from(PermissionTable) + .where(eq(PermissionTable.project_id, remoteID)) + .all() + .pipe(Effect.orDie) + expect(permissions.map((row) => row.id).sort()).toEqual([ + PermissionSaved.ID.make("perm-disjoint"), + PermissionSaved.ID.make("perm-successor"), + ]) + expect( + yield* db + .select() + .from(PermissionTable) + .where(eq(PermissionTable.id, PermissionSaved.ID.make("perm-colliding"))) + .get() + .pipe(Effect.orDie), + ).toBeUndefined() + }), + ) + + it.live("migrates Project Memory before retiring the previous Project identity", () => + Effect.gen(function* () { + const dataRoot = yield* tmpdirScoped() + const tmp = yield* tmpdirScoped({ git: true }) + yield* Effect.gen(function* () { + const home = yield* MemoryHome.Service + const projects = yield* Project.Service + const store = yield* MemoryStore.Service + const rootProject = (yield* projects.fromDirectory(tmp)).project + const value = { + schema_version: 1, + id: "project-term", + name: "项目术语", + summary: "术语 Project Memory 指项目级持久化记忆", + metadata: { + categories: ["term"], + status: "active", + importance: "core", + keywords: ["Project Memory"], + related_topics: [], + created_at: "2026-08-11T00:00:00Z", + updated_at: "2026-08-11T00:00:00Z", + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: "term-01", + kind: "term", + content: "术语 Project Memory 指项目级持久化记忆", + rationale: "该术语由用户确认并长期适用", + confirmed_at: "2026-08-11T00:00:00Z", + }, + ], + } as const + yield* store.commit(rootProject.id, 0, { topics: [value], changed: [value.id], deleted: [] }) + yield* Effect.promise(() => $`git remote add origin git@github.com:acme/memory-app.git`.cwd(tmp).quiet()) + + const migrated = yield* projects.fromDirectory(tmp) + + expect(yield* store.readTopics(migrated.project.id)).toEqual([value]) + expect(yield* Effect.promise(() => Bun.file(home.directory(rootProject.id)).exists())).toBe(false) + }).pipe(Effect.provide(projectLayerWithMemoryRoot(dataRoot))) + }), + ) + + it.live("keeps the previous Project identity when Memory migration conflicts", () => + Effect.gen(function* () { + const dataRoot = yield* tmpdirScoped() + const tmp = yield* tmpdirScoped({ git: true }) + yield* Effect.gen(function* () { + const { db } = yield* Database.Service + const projects = yield* Project.Service + const store = yield* MemoryStore.Service + const rootProject = (yield* projects.fromDirectory(tmp)).project + const remoteID = remoteProjectID("github.com/acme/conflicting-memory") + const base = { + schema_version: 1, + id: "project-term", + name: "项目术语", + summary: "术语 Project Memory 指项目级持久化记忆", + metadata: { + categories: ["term"], + status: "active", + importance: "core", + keywords: ["Project Memory"], + related_topics: [], + created_at: "2026-08-11T00:00:00Z", + updated_at: "2026-08-11T00:00:00Z", + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: "term-01", + kind: "term", + content: "术语 Project Memory 指项目级持久化记忆", + rationale: "该术语由用户确认并长期适用", + confirmed_at: "2026-08-11T00:00:00Z", + }, + ], + } as const + const conflicting = { ...base, summary: "术语 Project Memory 指共享的持久化记忆" } + yield* store.commit(rootProject.id, 0, { topics: [base], changed: [base.id], deleted: [] }) + yield* store.commit(remoteID, 0, { topics: [conflicting], changed: [conflicting.id], deleted: [] }) + yield* Effect.promise(() => + $`git remote add origin git@github.com:acme/conflicting-memory.git`.cwd(tmp).quiet(), + ) + + const exit = yield* Effect.exit(projects.fromDirectory(tmp)) + + expect(exit._tag).toBe("Failure") + expect( + yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get().pipe(Effect.orDie), + ).toBeDefined() + expect(yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, remoteID)).get().pipe(Effect.orDie)).toBeUndefined() + expect(yield* store.readTopics(rootProject.id)).toEqual([base]) + expect(yield* store.readTopics(remoteID)).toEqual([conflicting]) + }).pipe(Effect.provide(projectLayerWithMemoryRoot(dataRoot))) }), ) }) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index c717578024..503e6860aa 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -2,13 +2,31 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import * as fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" +import { Duration, Effect, Exit, Fiber, Layer } from "effect" +import { stringify } from "yaml" +import { Database } from "@opencode-ai/core/database/database" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { eq } from "drizzle-orm" +import { MemoryHome } from "@/memory/home" +import { MemoryStore } from "@/memory/store" import { Worktree } from "../../src/worktree" +import { Project } from "../../src/project/project" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll( + Worktree.defaultLayer, + Project.defaultLayer, + CrossSpawnSpawner.defaultLayer, + MemoryStore.defaultLayer, + Database.defaultLayer, + EffectFlock.defaultLayer, + MemoryHome.defaultLayer, + ), +) const wintest = process.platform === "win32" ? it.instance : it.instance.skip describe("Worktree.remove", () => { @@ -17,6 +35,7 @@ describe("Worktree.remove", () => { () => Effect.gen(function* () { const root = (yield* TestInstance).directory + const project = yield* Project.Service const svc = yield* Worktree.Service const name = `remove-regression-${Date.now().toString(36)}` const branch = `opencode/${name}` @@ -24,6 +43,8 @@ describe("Worktree.remove", () => { yield* Effect.promise(() => $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()) yield* Effect.promise(() => $`git reset --hard`.cwd(dir).quiet()) + const current = yield* project.fromDirectory(root) + yield* project.addSandbox(current.project.id, dir) const real = (yield* Effect.promise(() => $`which git`.quiet().text())).trim() expect(real).toBeTruthy() @@ -123,4 +144,319 @@ describe("Worktree.remove", () => { }), { git: true }, ) + + const exists = (file: string) => + Effect.promise(() => + fs + .stat(file) + .then(() => true) + .catch(() => false), + ) + + const legacyConfig = (model: string, enabled: boolean) => + JSON.stringify({ + schema_version: 1, + enabled, + model, + topic_limit: 10, + topic_limit_floor: 10, + turn_interval: 5, + injection: { max_topics: 3, max_tokens: 1_200 }, + }) + + it.instance( + "removing one worktree does not promote a lone sandbox config past disagreeing siblings (MEM-PR01-R1-06)", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory + const project = yield* Project.Service + const svc = yield* Worktree.Service + const current = yield* project.fromDirectory(root) + yield* project.setInitialized(current.project.id) + + const stamp = Date.now().toString(36) + const dirA = path.join(root, "..", `promote-a-${stamp}`) + const dirB = path.join(root, "..", `promote-b-${stamp}`) + yield* Effect.promise(() => $`git worktree add --no-checkout -b opencode/promote-a-${stamp} ${dirA}`.cwd(root).quiet()) + yield* Effect.promise(() => $`git worktree add --no-checkout -b opencode/promote-b-${stamp} ${dirB}`.cwd(root).quiet()) + yield* project.addSandbox(current.project.id, dirA) + yield* project.addSandbox(current.project.id, dirB) + + // Two sandboxes carry disagreeing legacy configs; the primary has none. + yield* Effect.promise(() => Bun.write(path.join(dirA, ".opencode", "memory.jsonc"), legacyConfig("test/config-a", false))) + yield* Effect.promise(() => Bun.write(path.join(dirB, ".opencode", "memory.jsonc"), legacyConfig("test/config-b", true))) + + // Removing A must reconcile against the FULL snapshot: A's lone config + // disagrees with B's, so nothing may be promoted and the removal fails + // closed instead of silently flipping the project-wide configuration. + const outcome = yield* Effect.exit(svc.remove({ directory: dirA })) + expect(Exit.isFailure(outcome)).toBe(true) + expect(yield* exists(path.join(root, ".opencode", "memory.jsonc"))).toBe(false) + expect(yield* exists(path.join(dirA, ".opencode", "memory.jsonc"))).toBe(true) + }), + { git: true }, + ) + + it.instance( + "worktree removal on an uninitialized project performs no memory migration (MEM-PR01-R1-08)", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory + const project = yield* Project.Service + const svc = yield* Worktree.Service + const current = yield* project.fromDirectory(root) + // Deliberately NOT initialized: the spec keeps uninitialized projects inert. + + const stamp = Date.now().toString(36) + const dirA = path.join(root, "..", `inert-${stamp}`) + yield* Effect.promise(() => $`git worktree add --no-checkout -b opencode/inert-${stamp} ${dirA}`.cwd(root).quiet()) + yield* project.addSandbox(current.project.id, dirA) + + const now = "2026-08-12T00:00:00Z" + const legacyTopic = stringify({ + schema_version: 1, + id: "legacy-topic", + name: "遗留主题", + summary: "工作树中遗留的合法主题", + metadata: { + categories: ["decision"], + status: "active", + importance: "core", + keywords: ["架构"], + related_topics: [], + created_at: now, + updated_at: now, + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: "legacy-item", + kind: "decision", + content: "已确认决定:核心模块之间使用稳定边界", + rationale: "该边界由用户确认并长期适用", + confirmed_at: now, + }, + ], + }) + yield* Effect.promise(() => Bun.write(path.join(dirA, ".opencode", "memory", "topics", "legacy-topic.yaml"), legacyTopic)) + + // No migration may run for an uninitialized project: the legacy file + // stays put and the removal fails closed on the residue. + const outcome = yield* Effect.exit(svc.remove({ directory: dirA })) + expect(Exit.isFailure(outcome)).toBe(true) + expect(yield* exists(path.join(dirA, ".opencode", "memory", "topics", "legacy-topic.yaml"))).toBe(true) + }), + { git: true }, + ) + + const legacyTopicYaml = (id: string) => + stringify({ + schema_version: 1, + id, + name: "生命周期测试主题", + summary: "用于验证工作树生命周期行为的主题", + metadata: { + categories: ["decision"], + status: "active", + importance: "core", + keywords: ["边界"], + related_topics: [], + created_at: "2026-08-12T00:00:00Z", + updated_at: "2026-08-12T00:00:00Z", + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: `${id}-item`, + kind: "decision", + content: `已确认决定:保留 ${id} 的稳定边界`, + rationale: "该边界由用户确认并长期适用", + confirmed_at: "2026-08-12T00:00:00Z", + }, + ], + }) + + it.instance( + "list never prunes or deregisters a merely-prunable worktree (MEM-PR01-R1-16)", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory + const project = yield* Project.Service + const svc = yield* Worktree.Service + const current = yield* project.fromDirectory(root) + + const stamp = Date.now().toString(36) + const dirA = path.join(root, "..", `prunable-${stamp}`) + yield* Effect.promise(() => $`git worktree add -b opencode/prunable-${stamp} ${dirA}`.cwd(root).quiet()) + yield* project.addSandbox(current.project.id, dirA) + + // Break the gitdir link: git now reports the entry as prunable even + // though the directory still exists. + const adminDir = path.join(root, ".git", "worktrees", `prunable-${stamp}`) + yield* Effect.promise(() => fs.writeFile(path.join(adminDir, "gitdir"), "/nonexistent/gitdir-link\n")) + const porcelain = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(root).quiet().text()) + expect(porcelain).toContain("prunable") + + yield* svc.list() + + // Observation must not destroy: git admin data and the registration + // both survive a list() that saw a prunable entry. + expect(yield* exists(path.join(adminDir, "gitdir"))).toBe(true) + const after = yield* project.get(current.project.id) + expect(after?.sandboxes.some((sandbox) => sandbox === dirA)).toBe(true) + }), + { git: true }, + ) + + it.instance( + "remove recovers a registered worktree whose git admin data is gone (MEM-PR01-R1-18)", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory + const project = yield* Project.Service + const svc = yield* Worktree.Service + const current = yield* project.fromDirectory(root) + + const stamp = Date.now().toString(36) + const dirA = path.join(root, "..", `zombie-${stamp}`) + yield* Effect.promise(() => $`git worktree add -b opencode/zombie-${stamp} ${dirA}`.cwd(root).quiet()) + yield* project.addSandbox(current.project.id, dirA) + + // Lose the git admin data while the directory survives. + yield* Effect.promise(() => + fs.rm(path.join(root, ".git", "worktrees", `zombie-${stamp}`), { recursive: true, force: true }), + ) + + expect(yield* svc.remove({ directory: dirA })).toBe(true) + const after = yield* project.get(current.project.id) + expect(after?.sandboxes.some((sandbox) => sandbox === dirA)).toBe(false) + // Registration cleanup must never delete the directory itself. + expect(yield* exists(dirA)).toBe(true) + }), + { git: true }, + ) + + it.instance( + "reset invalidates the admission cache before rescanning legacy memory (MEM-PR01-R1-19)", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory + const project = yield* Project.Service + const svc = yield* Worktree.Service + const store = yield* MemoryStore.Service + const current = yield* project.fromDirectory(root) + yield* project.setInitialized(current.project.id) + + const stamp = Date.now().toString(36) + const dirA = path.join(root, "..", `cache-a-${stamp}`) + const dirB = path.join(root, "..", `cache-b-${stamp}`) + yield* Effect.promise(() => $`git worktree add -b opencode/cache-a-${stamp} ${dirA}`.cwd(root).quiet()) + yield* Effect.promise(() => $`git worktree add -b opencode/cache-b-${stamp} ${dirB}`.cwd(root).quiet()) + yield* project.addSandbox(current.project.id, dirA) + yield* project.addSandbox(current.project.id, dirB) + + // Prime the admission cache with a clean full-snapshot scan. + yield* svc.reset({ directory: dirB }) + + // A legacy topic appears in A after the cached clean scan; the reset of + // A must invalidate the cache and rescan, importing it before the sweep. + const legacyDir = path.join(dirA, ".opencode", "memory", "topics") + yield* Effect.promise(() => fs.mkdir(legacyDir, { recursive: true })) + yield* Effect.promise(() => + fs.writeFile(path.join(legacyDir, "cache-topic.yaml"), legacyTopicYaml("cache-topic")), + ) + + const outcome = yield* Effect.exit(svc.reset({ directory: dirA })) + expect(Exit.isSuccess(outcome)).toBe(true) + const topics = yield* store.readTopics(current.project.id) + expect(topics.map((value) => value.id)).toContain("cache-topic") + }), + { git: true }, + ) + + it.instance( + "reset fails closed over invalid legacy memory and preserves it (MEM-PR01-R1-17)", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory + const project = yield* Project.Service + const svc = yield* Worktree.Service + const current = yield* project.fromDirectory(root) + yield* project.setInitialized(current.project.id) + + const stamp = Date.now().toString(36) + const dirA = path.join(root, "..", `resetblock-${stamp}`) + yield* Effect.promise(() => $`git worktree add -b opencode/resetblock-${stamp} ${dirA}`.cwd(root).quiet()) + yield* project.addSandbox(current.project.id, dirA) + + const legacyDir = path.join(dirA, ".opencode", "memory", "topics") + yield* Effect.promise(() => fs.mkdir(legacyDir, { recursive: true })) + const invalidFile = path.join(legacyDir, "broken.yaml") + yield* Effect.promise(() => fs.writeFile(invalidFile, "id: broken\n")) + + const outcome = yield* Effect.exit(svc.reset({ directory: dirA })) + expect(Exit.isFailure(outcome)).toBe(true) + if (Exit.isFailure(outcome)) expect(String(outcome.cause)).toContain("topic.invalid") + expect(yield* exists(invalidFile)).toBe(true) + }), + { git: true }, + ) + + it.instance( + "blocks removal when the identity retires mid-remove with un-admitted legacy memory (MEM-PR01-R9-P2A)", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory + const project = yield* Project.Service + const svc = yield* Worktree.Service + const flock = yield* EffectFlock.Service + const home = yield* MemoryHome.Service + const { db } = yield* Database.Service + const current = yield* project.fromDirectory(root) + yield* project.setInitialized(current.project.id) + + const stamp = Date.now().toString(36) + const dir = path.join(root, "..", `retired-remove-${stamp}`) + yield* Effect.promise(() => $`git worktree add -b opencode/retired-remove-${stamp} ${dir}`.cwd(root).quiet()) + yield* project.addSandbox(current.project.id, dir) + + // Legacy memory that was never admitted into any Home. + const legacyDir = path.join(dir, ".opencode", "memory", "topics") + yield* Effect.promise(() => fs.mkdir(legacyDir, { recursive: true })) + const legacyFile = path.join(legacyDir, "never-admitted.yaml") + yield* Effect.promise(() => fs.writeFile(legacyFile, "id: never-admitted\n")) + + // Hold the admission lock: the remove's reconcile blocks inside ensure + // AFTER its own row-liveness check passed. While it blocks, the + // identity row is retired by a concurrent upgrade. The in-fence + // liveness recheck must then fail the removal closed instead of + // destroying the never-admitted legacy content. + const fiber = yield* flock.withLock( + Effect.gen(function* () { + const fiber = yield* svc.remove({ directory: dir }).pipe(Effect.forkDetach) + yield* Effect.sleep(Duration.millis(500)) + yield* db + .delete(ProjectTable) + .where(eq(ProjectTable.id, current.project.id)) + .run() + .pipe(Effect.orDie) + return fiber + }), + `memory-admission:${current.project.id}`, + home.locks, + ) + const outcome = yield* Fiber.join(fiber).pipe(Effect.exit) + + expect(Exit.isFailure(outcome)).toBe(true) + if (Exit.isFailure(outcome)) expect(String(outcome.cause)).toContain("identity") + expect(yield* exists(legacyFile)).toBe(true) + }), + { git: true }, + ) }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 1b30ade88c..b58a974fce 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -6,17 +6,28 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppProcess } from "@opencode-ai/core/process" import { NodePath } from "@effect/platform-node" import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Ref } from "effect" +import { Global } from "@opencode-ai/core/global" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" import { SettingsHook } from "../../src/hook/settings" import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceState } from "../../src/effect/instance-state" +import { MemoryHome } from "../../src/memory/home" +import { MemoryStore } from "../../src/memory/store" import { Project } from "../../src/project/project" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { pollWithTimeout, testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll(Worktree.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer), + Layer.mergeAll( + Worktree.defaultLayer, + Project.defaultLayer, + FSUtil.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + MemoryStore.defaultLayer, + ), ) const wintest = process.platform !== "win32" ? it.instance : it.instance.skip @@ -181,9 +192,12 @@ function makeStartCommandProbe(directory: string, name: string) { const removeCreatedWorktree = (directory: string) => Effect.gen(function* () { - const svc = yield* Worktree.Service - const ok = yield* svc.remove({ directory }) - if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`)) + const fs = yield* FSUtil.Service + if (yield* fs.exists(directory).pipe(Effect.orDie)) { + const svc = yield* Worktree.Service + const ok = yield* svc.remove({ directory }) + if (!ok) yield* Effect.fail(new Error(`failed to remove worktree ${directory}`)) + } }) const withCreatedWorktree = ( @@ -330,6 +344,62 @@ describe("Worktree", () => { { git: true }, ) + it.instance( + "refuses to remove a worktree while project memory would be destroyed", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const svc = yield* Worktree.Service + const ctx = yield* InstanceState.context + const project = yield* Project.Service + yield* project.setInitialized(ctx.project.id) + const memory = path.join(info.directory, ".opencode", "memory", "topics", "project.yaml") + yield* fs.makeDirectory(path.dirname(memory), { recursive: true }) + yield* fs.writeFileString(memory, "id: project\n") + + const exit = yield* svc.remove({ directory: info.directory }).pipe(Effect.exit) + const preserved = yield* fs.exists(memory).pipe(Effect.orDie) + + // Let the fixture's release remove the worktree after the assertion + // signal has been captured. + yield* fs.remove(path.join(info.directory, ".opencode"), { recursive: true }).pipe(Effect.ignore) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("topic.invalid") + expect(preserved).toBe(true) + }), + ), + { git: true }, + ) + + it.instance( + "migrates valid legacy memory before removing a worktree", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const ctx = yield* InstanceState.context + const fs = yield* FSUtil.Service + const project = yield* Project.Service + const store = yield* MemoryStore.Service + const svc = yield* Worktree.Service + yield* project.setInitialized(ctx.project.id) + const home = MemoryHome.make(Global.Path.data) + const projectHome = home.directory(ctx.project.id) + const legacy = path.join(info.directory, ".opencode", "memory", "topics", "project-architecture.yaml") + yield* Effect.addFinalizer(() => fs.remove(projectHome, { recursive: true }).pipe(Effect.ignore)) + yield* fs.makeDirectory(path.dirname(legacy), { recursive: true }) + yield* fs.writeFileString(legacy, Bun.YAML.stringify(memoryTopic())) + + expect(yield* svc.remove({ directory: info.directory })).toBe(true) + expect(yield* fs.exists(info.directory).pipe(Effect.orDie)).toBe(false) + expect((yield* store.readTopics(ctx.project.id))[0]?.id).toBe("project-architecture") + expect((yield* project.get(ctx.project.id))?.sandboxes).not.toContain(info.directory) + }), + ), + { git: true }, + ) + it.instance( "create returns after setup and fires Event.Ready after bootstrap", () => @@ -462,13 +532,124 @@ describe("Worktree", () => { expect((yield* probe.overlap.pipe(Effect.timeoutOption("250 millis")))._tag).toBe("None") yield* probe.release expect(yield* Fiber.join(first)).toBe(true) - expect(yield* Fiber.join(second)).toBe(true) + const repeated = yield* Fiber.await(second) + expect(Exit.isFailure(repeated)).toBe(true) }), { git: true }, { timeout: 20_000 }, ) }) + describe("reset", () => { + it.instance( + "migrates project memory before removing other untracked files", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const ctx = yield* InstanceState.context + const svc = yield* Worktree.Service + const store = yield* MemoryStore.Service + const project = yield* Project.Service + yield* project.setInitialized(ctx.project.id) + const home = MemoryHome.make(Global.Path.data) + const projectHome = home.directory(ctx.project.id) + const topic = path.join(info.directory, ".opencode", "memory", "topics", "project-architecture.yaml") + const disposable = path.join(info.directory, ".opencode", "disposable.tmp") + yield* Effect.addFinalizer(() => fs.remove(projectHome, { recursive: true }).pipe(Effect.ignore)) + yield* fs.makeDirectory(path.dirname(topic), { recursive: true }) + yield* fs.writeFileString(topic, Bun.YAML.stringify(memoryTopic())) + yield* fs.writeFileString(disposable, "remove me\n") + + yield* svc.reset({ directory: info.directory }) + + const topicPreserved = (yield* store.readTopics(ctx.project.id)).length === 1 + const legacyPreserved = yield* fs.exists(topic).pipe(Effect.orDie) + const disposablePreserved = yield* fs.exists(disposable).pipe(Effect.orDie) + + expect(topicPreserved).toBe(true) + expect(legacyPreserved).toBe(false) + expect(disposablePreserved).toBe(false) + }), + ), + { git: true }, + ) + + it.instance( + "migrates modified tracked legacy memory before hard reset", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const ctx = yield* InstanceState.context + const fs = yield* FSUtil.Service + const svc = yield* Worktree.Service + const store = yield* MemoryStore.Service + const project = yield* Project.Service + yield* project.setInitialized(ctx.project.id) + const home = MemoryHome.make(Global.Path.data) + const projectHome = home.directory(ctx.project.id) + const legacy = path.join(info.directory, ".opencode", "memory", "topics", "project-architecture.yaml") + yield* Effect.addFinalizer(() => fs.remove(projectHome, { recursive: true }).pipe(Effect.ignore)) + yield* fs.makeDirectory(path.dirname(legacy), { recursive: true }) + yield* fs.writeFileString(legacy, Bun.YAML.stringify(memoryTopic("committed"))) + yield* git(info.directory, ["add", ".opencode/memory/topics/project-architecture.yaml"]) + yield* git(info.directory, ["commit", "-m", "test: add legacy memory"]) + yield* fs.writeFileString(legacy, Bun.YAML.stringify(memoryTopic("modified before reset"))) + + yield* svc.reset({ directory: info.directory }) + + expect((yield* store.readTopics(ctx.project.id))[0]?.summary).toBe("modified before reset") + }), + ), + { git: true }, + ) + + it.instance( + "rejects reset of the primary or current worktree", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const primary = yield* svc.reset({ directory: test.directory }).pipe(Effect.exit) + const current = yield* svc + .reset({ directory: info.directory }) + .pipe(provideInstance(info.directory), Effect.exit) + + expect(Exit.isFailure(primary)).toBe(true) + expect(Exit.isFailure(current)).toBe(true) + if (Exit.isFailure(primary)) expect(Cause.pretty(primary.cause)).toContain("primary or current") + if (Exit.isFailure(current)) expect(Cause.pretty(current.cause)).toContain("primary or current") + }), + ), + { git: true }, + ) + + it.instance( + "rejects reset of an unregistered git worktree", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const target = path.join(path.dirname(test.directory), `unregistered-reset-${Date.now()}`) + const branch = `unregistered-reset-${Date.now()}` + yield* git(test.directory, ["worktree", "add", "-b", branch, target]) + yield* Effect.addFinalizer(() => + gitResult(test.directory, ["worktree", "remove", "--force", target]).pipe( + Effect.andThen(gitResult(test.directory, ["branch", "-D", branch])), + Effect.ignore, + ), + ) + + const exit = yield* svc.reset({ directory: target }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("not registered") + }), + { git: true }, + ) + }) + describe("createFromInfo", () => { wintest( "creates git worktree and boots asynchronously", @@ -499,6 +680,8 @@ describe("Worktree", () => { Effect.gen(function* () { const test = yield* TestInstance const fs = yield* FSUtil.Service + const ctx = yield* InstanceState.context + const project = yield* Project.Service const svc = yield* Worktree.Service const parent = path.join(path.dirname(test.directory), `${path.basename(test.directory)}-parent`) const target = path.join(parent, path.basename(test.directory)) @@ -506,6 +689,7 @@ describe("Worktree", () => { yield* fs.ensureDir(parent) yield* git(test.directory, ["worktree", "add", "-b", branch, target]) + yield* project.addSandbox(ctx.project.id, target) const list = yield* svc.list() const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target))) @@ -520,17 +704,60 @@ describe("Worktree", () => { }), { git: true }, ) + + it.instance( + "hides a missing worktree in list and cleans it up on explicit remove", + () => + withCreatedWorktree(undefined, ({ info }) => + Effect.gen(function* () { + const ctx = yield* InstanceState.context + const fs = yield* FSUtil.Service + const project = yield* Project.Service + const svc = yield* Worktree.Service + yield* fs.remove(info.directory, { recursive: true }) + + // list() is non-destructive: the entry is hidden from the listing + // but the git admin data and registration stay untouched. + expect((yield* svc.list()).map((item) => item.directory)).not.toContain(info.directory) + expect(yield* git(ctx.worktree, ["worktree", "list", "--porcelain"])).toContain(info.directory) + expect((yield* project.get(ctx.project.id))?.sandboxes.length).toBeGreaterThan(0) + + // Explicit remove does the cleanup: prune admin data, drop the + // registration. + expect(yield* svc.remove({ directory: info.directory })).toBe(true) + expect(yield* git(ctx.worktree, ["worktree", "list", "--porcelain"])).not.toContain(info.directory) + const after = yield* project.get(ctx.project.id) + expect(after?.sandboxes.some((sandbox) => sandbox === info.directory)).toBe(false) + }), + ), + { git: true }, + ) }) describe("remove edge cases", () => { it.instance( - "remove non-existent directory succeeds silently", + "rejects a directory that is not a registered worktree", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const svc = yield* Worktree.Service + const exit = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("not registered") + }), + { git: true }, + ) + + it.instance( + "rejects removal of the primary or current worktree", () => Effect.gen(function* () { const test = yield* TestInstance const svc = yield* Worktree.Service - const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") }) - expect(ok).toBe(true) + const exit = yield* svc.remove({ directory: test.directory }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("primary or current") }), { git: true }, ) @@ -551,3 +778,34 @@ describe("Worktree", () => { ) }) }) + +function memoryTopic(summary = "已确认的核心架构边界") { + return { + schema_version: 1, + id: "project-architecture", + name: "架构边界", + summary, + metadata: { + categories: ["decision"], + status: "active", + importance: "core", + keywords: ["架构"], + related_topics: [], + created_at: "2026-08-11T00:00:00Z", + updated_at: "2026-08-11T00:00:00Z", + last_matched_at: null, + match_count: 0, + revision: 1, + item_count: 1, + }, + items: [ + { + id: "decision-01", + kind: "decision", + content: "已确认决定:核心模块之间使用稳定边界", + rationale: "该边界由用户确认并长期适用", + confirmed_at: "2026-08-11T00:00:00Z", + }, + ], + } +}