From 1263288db7666b6f1e36ace75bb7786f040fcad3 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 11 Aug 2026 15:12:50 +0800 Subject: [PATCH 01/18] fix(opencode): make project memory process safe --- CONTEXT-MAP.md | 1 + packages/opencode/src/memory/CONTEXT.md | 40 ++ packages/opencode/src/memory/admission.ts | 350 ++++++++++ packages/opencode/src/memory/config.ts | 41 +- .../docs/adr/0001-project-owned-memory.md | 32 + .../0002-project-memory-commit-protocol.md | 29 + .../memory/docs/adr/0003-memory-admission.md | 34 + packages/opencode/src/memory/home.ts | 36 ++ .../opencode/src/memory/identity-migration.ts | 127 ++++ packages/opencode/src/memory/lock.ts | 21 + packages/opencode/src/memory/memory.ts | 98 +-- packages/opencode/src/memory/paths.ts | 19 + packages/opencode/src/memory/store.ts | 227 +++++-- .../src/project/identity-migration.ts | 26 + packages/opencode/src/project/project.ts | 6 + packages/opencode/src/worktree/index.ts | 183 ++++-- .../test/fixture/memory-store-worker.ts | 56 ++ .../test/memory/memory-admission.test.ts | 167 +++++ .../test/memory/memory-persistence.test.ts | 605 ++++++++++++++++++ packages/opencode/test/memory/memory.test.ts | 136 ++-- .../opencode/test/project/project.test.ts | 143 +++++ .../test/project/worktree-remove.test.ts | 6 +- .../opencode/test/project/worktree.test.ts | 257 +++++++- 23 files changed, 2443 insertions(+), 197 deletions(-) create mode 100644 packages/opencode/src/memory/CONTEXT.md create mode 100644 packages/opencode/src/memory/admission.ts create mode 100644 packages/opencode/src/memory/docs/adr/0001-project-owned-memory.md create mode 100644 packages/opencode/src/memory/docs/adr/0002-project-memory-commit-protocol.md create mode 100644 packages/opencode/src/memory/docs/adr/0003-memory-admission.md create mode 100644 packages/opencode/src/memory/home.ts create mode 100644 packages/opencode/src/memory/identity-migration.ts create mode 100644 packages/opencode/src/memory/lock.ts create mode 100644 packages/opencode/src/memory/paths.ts create mode 100644 packages/opencode/src/project/identity-migration.ts create mode 100644 packages/opencode/test/fixture/memory-store-worker.ts create mode 100644 packages/opencode/test/memory/memory-admission.test.ts create mode 100644 packages/opencode/test/memory/memory-persistence.test.ts 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/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md new file mode 100644 index 0000000000..8a112602e4 --- /dev/null +++ b/packages/opencode/src/memory/CONTEXT.md @@ -0,0 +1,40 @@ +# 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. + +## 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. Its identity follows the Project, not a checkout path. | +| 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 valid content, or where legacy configuration differs from the Project configuration. | +| Project Configuration | The user-editable MEMORY policy owned by the Project and shared by its worktrees. | +| Memory Admission | The single legacy input seam that scans one Project snapshot, reconciles it once, and caches only conflict-free results. | + +## Invariants + +- One Project identity has one authoritative Project Memory. +- Two worktrees of the same Project cannot form independent Memory namespaces. +- 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 removing a legacy copy. +- A Memory Conflict is explicit and fail-closed; no component silently chooses or overwrites conflicting durable context. +- Removing or resetting a worktree cannot imply deleting Project Memory. +- Removing Project Memory requires a separate Project retention decision. +- Runtime reads never perform ad-hoc legacy migration; they consume a Project snapshot admitted by `MemoryAdmission.ensure`. + +## Boundaries + +- Project identity and registered worktrees come from the Project context. +- Worktree lifecycle invalidates and reruns Memory admission before destructive operations, but it does not own Project Memory 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. + +## 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) diff --git a/packages/opencode/src/memory/admission.ts b/packages/opencode/src/memory/admission.ts new file mode 100644 index 0000000000..aed2e7b4b2 --- /dev/null +++ b/packages/opencode/src/memory/admission.ts @@ -0,0 +1,350 @@ +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 { 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, +}) {} + +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 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)))) + }) + + 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: string[] = [] + 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.file) + 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.file) + 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 }, + } + }) + yield* Effect.forEach(updated.result.removable, (file) => fs.remove(file, { force: true }), { + concurrency: 1, + discard: true, + }) + return updated.result.diagnostics + }) + + const readConfigCandidates = Effect.fnUntraced(function* (directories: ReadonlyArray) { + const files = directories.flatMap((directory) => + MemoryPaths.PROJECT_CONFIG_PATHS.map((relative) => join(directory, relative)), + ) + 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) => left.file.localeCompare(right.file)), + ), + ) + }) + + 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 projectDiagnostic = explicit.config + ? [] + : [ + new Diagnostic({ + code: "config.invalid", + path: explicit.file, + message: "Project MEMORY config is invalid and was preserved", + }), + ] + const diagnostics = yield* Effect.forEach( + legacy, + (candidate) => { + if (candidate.config && explicit.config && same(candidate.config, explicit.config)) + return fs.remove(candidate.file, { force: true }).pipe( + Effect.as( + new Diagnostic({ + code: "config.duplicate", + path: candidate.file, + message: "Legacy sandbox MEMORY config duplicates the Project config", + }), + ), + ) + return Effect.succeed( + 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", + }), + ) + }, + { concurrency: 1 }, + ) + return [...projectDiagnostic, ...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) + yield* Effect.forEach(valid, (candidate) => fs.remove(candidate.file, { force: true }), { + concurrency: 1, + discard: true, + }) + return legacy.map( + (candidate) => + new Diagnostic({ + code: !candidate.config + ? "config.invalid" + : candidate.file === promoted.file + ? "config.promoted" + : "config.duplicate", + path: candidate.file, + message: !candidate.config + ? "Legacy sandbox MEMORY config is invalid 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) + yield* fs.remove(topics, { recursive: true }) + const legacy = join(directory, ".opencode", "memory") + if ((yield* fs.existsSafe(legacy)) && (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]) + return yield* flock.withLock( + ensureUnsafe(normalized, key), + `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), +) + +export const node = LayerNode.make(layer, [ + FSUtil.node, + EffectFlock.node, + MemoryConfig.node, + MemoryHome.node, + MemoryStore.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..31eaa35c5f 100644 --- a/packages/opencode/src/memory/config.ts +++ b/packages/opencode/src/memory/config.ts @@ -1,13 +1,16 @@ export * as MemoryConfig from "./config" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" 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 = { @@ -33,6 +36,21 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service + const git = yield* Git.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,13 +61,13 @@ 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 }) + const config = normalizeConfig(decoded.value) yield* MemoryFile.atomicWrite(fs, found.path, serialize(config)) return config }) @@ -78,6 +96,7 @@ export const layer = Layer.effect( config: MemorySchema.Config, existingPath?: string, ) { + yield* ensureProjectExclude(projectDir) yield* MemoryFile.atomicWrite(fs, existingPath ?? projectPath(projectDir), serialize(config)) }) @@ -107,9 +126,12 @@ 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(Git.defaultLayer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))), +) -export const node = LayerNode.make(layer, [FSUtil.node]) +export const node = LayerNode.make(layer, [FSUtil.node, Git.node]) export function projectPath(projectDir: string) { return join(projectDir, ".opencode", "memory.jsonc") @@ -123,7 +145,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 +157,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 +166,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..55a52c04bc --- /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 holds the old Project's process lock while moving or merging its Memory 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/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-migration.ts b/packages/opencode/src/memory/identity-migration.ts new file mode 100644 index 0000000000..6d3f17b0bf --- /dev/null +++ b/packages/opencode/src/memory/identity-migration.ts @@ -0,0 +1,127 @@ +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, Layer, Schema } from "effect" +import { dirname, join } from "node:path" +import { MemoryHome } from "./home" +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 + > +} + +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), + }, +) {} + +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") + ), + ) + if (unexpected.length === 0) return + yield* new InvalidHomeError({ paths: unexpected.map((entry) => join(directory, entry.name)) }) + }) + + const migrateHomeUnsafe = Effect.fnUntraced(function* ( + oldID: ProjectV2.ID, + newID: ProjectV2.ID, + ) { + const source = home.directory(oldID) + if (!(yield* fs.existsSafe(source))) return + const target = home.directory(newID) + yield* fs.makeDirectory(dirname(target), { recursive: true }) + if (!(yield* fs.existsSafe(target))) { + yield* fs.rename(source, target) + return + } + + yield* inspectHome(source) + yield* inspectHome(target) + const sourceTopics = yield* store.inspectTopics(oldID) + const targetTopics = yield* store.inspectTopics(newID) + const targetByID = new Map(targetTopics.map((topic) => [topic.id, topic])) + const conflicts = sourceTopics + .filter((topic) => { + const current = targetByID.get(topic.id) + return current && JSON.stringify(current) !== JSON.stringify(topic) + }) + .map((topic) => topic.id) + if (conflicts.length > 0) yield* new ConflictError({ topic_ids: conflicts }) + + const imported = sourceTopics.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 && JSON.stringify(existing) !== JSON.stringify(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, + } + }) + } + yield* fs.remove(source, { recursive: true }) + }) + + const migrateHome: Interface["migrateHome"] = (oldID, newID) => { + if (oldID === newID) return Effect.void + return flock + .withLock(migrateHomeUnsafe(oldID, newID), `memory-project:${oldID}`, 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..7f4c03698f 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -1,7 +1,6 @@ export * as Memory from "./memory" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" 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 +10,9 @@ 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 { MemoryLock } from "./lock" import { MemoryModel } from "./model" import { MemoryPrompts } from "./prompts" import { MemorySchema } from "./schema" @@ -62,19 +63,27 @@ 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 + | 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 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* () { @@ -166,7 +175,22 @@ export const layer: Layer.Layer< const ctx = yield* InstanceState.context const current = (yield* project.get(ctx.project.id)) ?? ctx.project 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, + }) + 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 +253,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 +283,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 +300,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,16 +346,16 @@ export const layer: Layer.Layer< session.firstTurnAttempted = true if (!due && !shouldMatch) return - yield* locks.withLock(current.ctx.worktree)( + yield* lock.withProject(current.project.id)( Effect.gen(function* () { - const topics = yield* store.readTopics(current.ctx.worktree) + const topics = yield* store.readTopics(current.project.id) const maintained = due ? yield* maintain({ model: current.model, config: current.loaded.config, topics, messages: input.messages, - worktree: current.ctx.worktree, + projectID: current.project.id, }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { @@ -354,7 +371,7 @@ export const layer: Layer.Layer< config: current.loaded.config, topics: maintained, text: user.text, - worktree: current.ctx.worktree, + projectID: current.project.id, })).rendered : (data.sessions.get(input.sessionID)?.turn.rendered ?? []) const entry = data.sessions.get(input.sessionID) @@ -423,7 +440,7 @@ export const layer: Layer.Layer< } const origin = user.info.id - return yield* locks.withLock(current.ctx.worktree)( + 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 } @@ -436,13 +453,13 @@ export const layer: Layer.Layer< } if (activeTurn.queryCount >= 2) return { status: "limit" as const } activeTurn.queryCount++ - const topics = yield* store.readTopics(current.ctx.worktree) + const topics = yield* store.readTopics(current.project.id) const selected = yield* select({ model: current.model, config: current.loaded.config, topics, text: query, - worktree: current.ctx.worktree, + projectID: current.project.id, }) const latest = data.sessions.get(input.sessionID)?.turn if (latest?.messageID !== origin) return { status: "stale" as const } @@ -477,15 +494,15 @@ export const layer: Layer.Layer< return [] } const user = latestRealUser(input.messages) - return yield* locks.withLock(current.ctx.worktree)( + return yield* lock.withProject(current.project.id)( Effect.gen(function* () { - const topics = yield* store.readTopics(current.ctx.worktree) + const topics = yield* store.readTopics(current.project.id) const maintained = yield* maintain({ model: current.model, config: current.loaded.config, topics, messages: input.messages, - worktree: current.ctx.worktree, + projectID: current.project.id, }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { @@ -499,7 +516,7 @@ export const layer: Layer.Layer< config: current.loaded.config, topics: maintained, text: user?.text ?? "", - worktree: current.ctx.worktree, + projectID: current.project.id, })).rendered return rendered }), @@ -533,11 +550,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 +583,9 @@ 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(MemoryLock.defaultLayer), Layer.provide(MemoryModel.defaultLayer), Layer.provide(MemoryStore.defaultLayer), ), @@ -577,7 +595,9 @@ export const node = LayerNode.make(layer, [ Config.node, Provider.node, Project.node, + MemoryAdmission.node, MemoryConfig.node, + MemoryLock.node, MemoryModel.node, MemoryStore.node, ]) @@ -725,7 +745,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..4afd08a62f --- /dev/null +++ b/packages/opencode/src/project/identity-migration.ts @@ -0,0 +1,26 @@ +export * as ProjectIdentityMigration from "./identity-migration" + +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Context, Effect, Layer } from "effect" +import { MemoryIdentityMigration } from "@/memory/identity-migration" + +export interface Interface { + readonly migrate: (oldID: ProjectV2.ID, newID: ProjectV2.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ProjectIdentityMigration") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const memory = yield* MemoryIdentityMigration.Service + return Service.of({ + migrate: (oldID, newID) => memory.migrateHome(oldID, newID).pipe(Effect.orDie), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(MemoryIdentityMigration.defaultLayer)) + +export const node = LayerNode.make(layer, [MemoryIdentityMigration.node]) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 82ae979ba3..44e7013a3c 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -22,6 +22,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 +113,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,6 +153,8 @@ export const layer = Layer.effect( if (oldID === ProjectV2.ID.global) return if (oldID === newID) return + yield* identityMigration.migrate(oldID, newID) + yield* db .transaction( (d) => @@ -472,6 +476,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 +489,7 @@ export const node = LayerNode.make(layer, [ ProjectDirectories.node, EventV2Bridge.node, RuntimeFlags.node, + ProjectIdentityMigration.node, Database.node, ]) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 255e5fce8d..c15ce1dda3 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,18 @@ 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 + }) + 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 +366,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 +394,31 @@ export const layer: Layer.Layer< return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) } + const entries = parseWorktreeList(result.text) + const prunable = entries.flatMap((entry) => (entry.prunable && entry.path ? [entry.path] : [])) + if (prunable.length > 0) { + const pruned = yield* git(["worktree", "prune"], { cwd: ctx.worktree }) + if (pruned.code !== 0) + return yield* new ListFailedError({ + message: pruned.stderr || pruned.text || "Failed to prune stale git worktrees", + }) + const current = (yield* project.get(ctx.project.id)) ?? ctx.project + yield* Effect.forEach( + prunable, + (directory) => + Effect.gen(function* () { + const sandbox = yield* registeredSandbox(current.sandboxes, directory) + if (sandbox) yield* project.removeSandbox(ctx.project.id, sandbox) + }), + { concurrency: 1, discard: true }, + ) + } + 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,42 +458,89 @@ 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 + updated: number + }) { + if (memoryAdmission) { + yield* memoryAdmission.invalidate(input.projectID) + const memory = yield* memoryAdmission.ensure({ + projectID: input.projectID, + projectDirectory: input.projectDirectory, + directories: [input.directory], + updated: input.updated, + }) + 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) + const currentProject = (yield* project.get(ctx.project.id)) ?? ctx.project + const registered = yield* registeredSandbox(currentProject.sandboxes, directory) + if (!registered) { + return yield* new RemoveFailedError({ message: "Worktree is not registered with this Project" }) + } const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) if (list.code !== 0) { return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) } - const entries = parseWorktreeList(list.text) - const entry = yield* locateWorktree(entries, directory) - + const entry = yield* locateWorktree(parseWorktreeList(list.text), directory) if (!entry?.path) { - const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie) - if (directoryExists) { - yield* stopFsmonitor(directory) - yield* cleanDirectory(directory) - } - return true + return yield* new RemoveFailedError({ message: "Worktree is not registered with this Project" }) + } + + const blocker = yield* reconcileLegacyMemory({ + projectID: ctx.project.id, + projectDirectory: ctx.project.worktree, + directory: entry.path, + 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* 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: "" }) } // Git may return the original casing when a caller supplied a normalized Windows path. @@ -491,12 +569,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* project.removeSandbox(ctx.project.id, registered) + 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* project.removeSandbox(ctx.project.id, registered) return true }) @@ -519,7 +604,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 +654,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 +671,15 @@ 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" }) + } + + const currentProject = (yield* project.get(ctx.project.id)) ?? ctx.project + 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 +695,18 @@ export const layer: Layer.Layer< const worktreePath = entry.path + const blocker = yield* reconcileLegacyMemory({ + projectID: ctx.project.id, + projectDirectory: ctx.project.worktree, + directory: worktreePath, + 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 +754,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 +792,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 +806,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-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..a0cc6b9130 --- /dev/null +++ b/packages/opencode/test/memory/memory-admission.test.ts @@ -0,0 +1,167 @@ +import { describe, expect } from "bun:test" +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 { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Effect, Layer } from "effect" +import path from "node:path" +import { MemoryAdmission } from "@/memory/admission" +import { MemoryConfig } from "@/memory/config" +import { MemoryHome } from "@/memory/home" +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)) + const store = MemoryStore.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + 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), + ) + return Layer.mergeAll(admission, store, MemoryConfig.defaultLayer) +} + +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 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 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 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))) + }), + ) +}) 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..c9f91e3a5c --- /dev/null +++ b/packages/opencode/test/memory/memory-persistence.test.ts @@ -0,0 +1,605 @@ +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, Schema } from "effect" +import path from "node:path" +import { MemoryConfig } from "@/memory/config" +import { MemoryHome } from "@/memory/home" +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)) + const store = MemoryStore.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + 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), + ) + return Layer.mergeAll(home, store, admission, MemoryConfig.defaultLayer) +} + +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 + 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 + 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 + 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 + 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 + 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))) + }), + ) +}) diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 205a3d3fde..fb506cc2a8 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -6,7 +6,9 @@ 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 { MemoryLock } from "@/memory/lock" import { Memory } from "@/memory/memory" import { MemoryModel } from "@/memory/model" import { MemoryPrompts } from "@/memory/prompts" @@ -47,6 +49,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 { @@ -101,10 +110,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 +134,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 } + }), }), ), ), @@ -237,10 +265,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, }), ), ), @@ -327,14 +355,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 +490,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 +653,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 +672,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 +1269,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 +1640,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..8cea0b6426 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -21,8 +21,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 +84,7 @@ function projectLayerWithFailure(failArg: string) { Layer.provide(NodePath.layer), Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(ProjectIdentityMigration.defaultLayer), ) } @@ -92,9 +98,40 @@ function projectLayerWithRuntimeFlags(flags: Parameters testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) @@ -241,6 +278,112 @@ describe("Project.fromDirectory", () => { ).toBe(remoteID) }), ) + + 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))) + }), + ) }) describe("Project.fromDirectory git failure paths", () => { diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index c717578024..e4e99f78b5 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -5,10 +5,11 @@ import path from "path" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" 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)) const wintest = process.platform === "win32" ? it.instance : it.instance.skip describe("Worktree.remove", () => { @@ -17,6 +18,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 +26,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() diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 1b30ade88c..20bc6b000c 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,58 @@ 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 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 + 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 +528,120 @@ 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 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 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 +672,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 +681,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 +696,51 @@ describe("Worktree", () => { }), { git: true }, ) + + it.instance( + "prunes missing worktrees and removes their Project registration", + () => + 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 }) + + expect((yield* svc.list()).map((item) => item.directory)).not.toContain(info.directory) + expect(yield* git(ctx.worktree, ["worktree", "list", "--porcelain"])).not.toContain(info.directory) + expect((yield* project.get(ctx.project.id))?.sandboxes).not.toContain(info.directory) + }), + ), + { 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 ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") }) - expect(ok).toBe(true) + 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 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 +761,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", + }, + ], + } +} From 2494b45eaa0fdb9833f13b5703a3c0717883aa09 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 09:13:50 +0800 Subject: [PATCH 02/18] docs(memory): add ProjectMemoryAuthority redo plan from d7b011738 Phased reconstruction of the lost ProjectMemoryAuthority redesign on top of the d7b011738 process-safe baseline. 8 phases (P1-P8), each a commit. P1=identity+atomic store API, P6=fromDirectory cutover (1C), P7=crash harness (1A). Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/memory-authority-redo-plan-2026-08-12.md 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..13bac859fb --- /dev/null +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -0,0 +1,123 @@ +# Memory Authority Redo Plan — from `d7b011738` + +Date: 2026-08-12. Worktree: `/private/tmp/oc-dag-wt-lifecycle` (branch `chore/worktree-lifecycle-audit`). +Status: PLANNING (awaiting user confirmation before any implementation/loop). + +## 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? From baf8598966f9cc0239861cd0c6705d20524715a2 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 09:19:53 +0800 Subject: [PATCH 03/18] docs(memory): fold plan-review findings; add P0 spec phase + resume protocol Adversarial review (5 critics, ultracode) found the lost ADR-0004 is unrecoverable, so 'faithful reconstruction' is unverifiable. Add blocking product decisions D1-D4 (Policy-in-Home vs ADR-0001, source-Home preserve retention, retirement-as-merge/lineage, new journal/Revision/ChangeMemory surface) requiring user sign-off, a mandatory P0 (recreate ADR-0004 + CONTEXT for approval before P1), technical revisions (P4 before P3, FK per-table rules, mutation-gate fixes, ledger locations, phase enum), and a resume protocol for fresh sessions. Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 13bac859fb..eedebf54b6 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -121,3 +121,47 @@ Order is dependency-driven; each phase has a Green proof + a mutation gate. 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. From 16f1e1be9faf0124bb4bc3af9a18a6a5ede2c05a Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 09:45:55 +0800 Subject: [PATCH 04/18] docs(memory): recreate ADR-0004 + CONTEXT authority glossary (P0 proposed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 design-of-record for the ProjectMemoryAuthority redo, reconstructing the lost ADR-0004 (uncommitted WIP, /tmp-cleaned). Resolves the §7.A product decisions D1-D4 and encodes the user principles confirmed 2026-08-12: one shared Memory per Project (worktrees share it, no per-worktree memory), Memory never forks, and identity upgrade is imperceptible. Status: Proposed (P0 gate) — P1 is blocked until the user approves this ADR. Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 14 ++++ packages/opencode/src/memory/CONTEXT.md | 35 ++++++++-- .../docs/adr/0004-project-memory-authority.md | 70 +++++++++++++++++++ 3 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index eedebf54b6..3461b3e4ad 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -165,3 +165,17 @@ The lost ADR-0004 is **unrecoverable** (repo's only ADR-0004 is an unrelated DAG 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 | diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index 8a112602e4..3b104992a2 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -2,6 +2,12 @@ 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. + ## Glossary | Term | Meaning | @@ -11,30 +17,45 @@ Project Memory preserves user-confirmed, durable human context for one Project. | 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 valid content, or where legacy configuration differs from the Project configuration. | -| Project Configuration | The user-editable MEMORY policy owned by the Project and shared by its worktrees. | +| Project Configuration | The MEMORY policy owned by the Project and shared by its worktrees. Under ADR-0004 it lives in the Memory Home, atomically versioned with Topics; worktree/global config files are admission candidates only. | | Memory Admission | The single legacy input seam that scans one Project snapshot, reconciles it once, and caches only conflict-free results. | +| Identity Alias | A durable old→new Project identity tombstone owned by `ProjectIdentity`. Every Memory read and mutation resolves it before choosing a Home or lock. | +| Requested Project ID | A Project ID held by a caller. It may already be retired and therefore is not an ownership key. | +| Canonical Project ID | The current terminal Project ID that owns Project Memory. Resolved inside the Project Memory authority and not supplied by callers. | +| Identity Retirement | A forward-only replacement of one Project ID by its successor while preserving one logical Project and all Project-owned state — merge into one Memory, not a fork. | +| Project Merge | A product operation that combines two independently owned Projects. Identity Retirement never performs an implicit Project Merge. | +| Project Memory Revision | An opaque version of one Project Memory snapshot, including Topics, Project Configuration, topology, and admission inputs. | ## Invariants - One Project identity has one authoritative Project Memory. -- Two worktrees of the same Project cannot form independent Memory namespaces. +- 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 removing a legacy copy. +- 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. - Removing or resetting a worktree cannot imply deleting Project Memory. - Removing Project Memory requires a separate Project retention decision. -- Runtime reads never perform ad-hoc legacy migration; they consume a Project snapshot admitted by `MemoryAdmission.ensure`. +- Runtime reads never perform ad-hoc legacy migration; they consume a Project snapshot admitted by the Project Memory authority. +- Project identity retirement validates the full transition before durable state changes, prepares the successor while preserving the source, publishes one identity commit point (the tombstone), and completes Project-owned reference migration by forward recovery. +- Routine Project Memory commands resolve identity, acquire one canonical Project commit right, and recheck identity before reading or writing. +- A missing Memory Home is empty; an existing corrupt Home is an error and is never projected as an empty Topic set. +- Project configuration and Topic mutations publish under one generation, one manifest, and one opaque Revision, in the same cross-process Project lock. +- Application callers never receive canonical IDs, Home paths, locks, cache keys, or migration callbacks. +- A revision issued before Identity Retirement cannot commit after the identity commit point. +- Destructive Memory Admission always observes current candidate files; it never trusts a process-local success cache. +- The retired source Home is preserved as a non-authoritative backup; its GC is a separate, deferred decision. ## Boundaries -- Project identity and registered worktrees come from the Project context. -- Worktree lifecycle invalidates and reruns Memory admission before destructive operations, but it does not own Project Memory retention. +- The Project Memory authority obtains identity, the primary checkout, and every registered worktree from durable Project state; callers provide only a requested Project ID. +- Worktree lifecycle requests destructive admission as one command through the internal destruction guard; it does not invalidate caches, assemble snapshots, or own Project Memory 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. ## Decisions -- [ADR-0001: Project identity owns Memory](docs/adr/0001-project-owned-memory.md) +- [ADR-0001: Project identity owns Memory](docs/adr/0001-project-owned-memory.md) *(Policy-source clause superseded by ADR-0004)* - [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) — **Proposed (P0, awaiting approval)** 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..99d7c64b6d --- /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: **Proposed** (awaiting user approval — P0 gate) +- Date: 2026-08-12 +- Supersedes: 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); adds Identity Retirement. Reconstructs the lost authority redesign (uncommitted WIP, /tmp-cleaned) from design memory, now written down so it is 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. From 5704eeabd79118ea416218ace2c680b0b9c437a3 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 10:03:27 +0800 Subject: [PATCH 05/18] fix(project): repoint workflow+permission FK on identity upgrade (Occam Fix #1) migrateProjectId deleted the old ProjectTable row, which ON DELETE CASCADE silently destroyed every DAG workflow and every saved permission whenever a repo gained its first remote (root -> first-remote identity upgrade). Repoint both project_id FKs inside the existing immediate transaction before the old row is deleted. A (newID, action, resource) collision on permission fails the transaction closed (no data loss). Extended 'migrates cached root project data when origin becomes available' to seed a workflow + permission and assert both survive the upgrade. Mutation gate: removing the repointing flips the test Red (rows cascade-deleted). Co-Authored-By: Claude --- packages/opencode/src/project/project.ts | 14 +++++++++ .../opencode/test/project/project.test.ts | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 44e7013a3c..f2ee5ee496 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" @@ -189,6 +191,18 @@ export const layer = Layer.effect( .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. A (newID, action, resource) + // collision on permission fails the immediate transaction closed (no data loss). + yield* d.update(WorkflowTable).set({ project_id: newID }).where(eq(WorkflowTable.project_id, oldID)).run() + yield* d + .update(PermissionTable) + .set({ project_id: newID }) + .where(eq(PermissionTable.project_id, oldID)) + .run() + if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run() }), { behavior: "immediate" }, diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 8cea0b6426..f0a8956e8d 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -9,6 +9,8 @@ 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 { 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" @@ -260,6 +262,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) @@ -276,6 +299,14 @@ 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) }), ) From 4b1898994c47abc37648def866c96f1e8efa19b3 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 10:05:13 +0800 Subject: [PATCH 06/18] =?UTF-8?q?docs(memory):=20adopt=20Occam=20minimal?= =?UTF-8?q?=20path=20(=C2=A710);=20reject=20elaborate=20ADR-0004=20redesig?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User applied Occam's Razor: the 8-phase ProjectMemoryAuthority redesign is over-engineered for the real needs (shared/no-fork memory already in baseline; imperceptible upgrade + no data loss via small in-place fixes). ADR-0004 → Rejected. Plan §10 = 4 targeted fixes; Fix #1 already done. Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 17 +++++++++++++++++ .../docs/adr/0004-project-memory-authority.md | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 3461b3e4ad..9dfc18edea 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -179,3 +179,20 @@ The lost ADR-0004 is **unrecoverable** (repo's only ADR-0004 is an unrelated DAG | 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) | pending | — | +| **#3** `migrateHome` acquires its two project flocks in **sorted** order (no reverse-retirement ABBA) | MEM-LOCK-02 | pending | — | +| **#4** Destructive admission (worktree reset/remove) **force-rescans**, never trusts the process-local admission cache | MEM-ADMIT-03 / RET-04 | pending | — | + +**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). Per fix: 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 §10 table. Exclusions unchanged: no Goal/DAG-config/CI/push/PR, no source-Home GC. 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 index 99d7c64b6d..b1e9f6d766 100644 --- a/packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md +++ b/packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md @@ -1,6 +1,6 @@ # ADR-0004: Project Memory authority owns identity and commits -- Status: **Proposed** (awaiting user approval — P0 gate) +- 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: 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); adds Identity Retirement. Reconstructs the lost authority redesign (uncommitted WIP, /tmp-cleaned) from design memory, now written down so it is auditable. From 7b30af3143811aefb423c48a5f9b5c6c8a5cf2b2 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 10:19:30 +0800 Subject: [PATCH 07/18] =?UTF-8?q?docs(memory):=20close=20Occam=20#3/#4=20a?= =?UTF-8?q?s=20non-gaps;=20defer=20#2=20(cascade)=20=E2=80=94=20path=20com?= =?UTF-8?q?plete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-assessed the remaining Occam fixes against the actual code: - #3 (ABBA): unreachable — migrateHome is one-way (root→remote), no reverse caller, so the two project flocks are never taken in opposite orders. - #4 (destructive rescan): already handled — worktree reconcileLegacyMemory invalidates the admission cache before ensure, forcing a fresh rescan. - #2 (typed errors): deferred — full propagation is a multi-file cascade for a marginal HTTP-status gain on a rare conflict (.orDie preserves the diagnostic in the Die cause). Awaits user decision (Occam cut vs invariant #5). Fix #1 (the real data-loss gap) stands; full regression green. Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 9dfc18edea..898ec05649 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -189,9 +189,15 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor | 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) | pending | — | -| **#3** `migrateHome` acquires its two project flocks in **sorted** order (no reverse-retirement ABBA) | MEM-LOCK-02 | pending | — | -| **#4** Destructive admission (worktree reset/remove) **force-rescans**, never trusts the process-local admission cache | MEM-ADMIT-03 / RET-04 | pending | — | +| **#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` acquires its two project flocks in **sorted** order (no reverse-retirement ABBA) | MEM-LOCK-02 | ✅ **closed — not reachable** | — | +| **#4** Destructive admission (worktree reset/remove) **force-rescans**, never trusts the process-local admission cache | MEM-ADMIT-03 / RET-04 | ✅ **closed — already handled** | — | + +**#3 rationale:** `migrateHome` is called only via `migrateProjectId(previous=oldID, current=newID)`; identity retirement is one-way (root→remote), so there is no `migrateHome(B,A)` reverse caller — the two project flocks are never acquired in opposite orders. ABBA is unreachable; no code change warranted. + +**#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). #3 and #4 verified as non-gaps; #2 deferred as a cascade awaiting the user's Occam-vs-invariant-#5 call. The driving loop is removed; nothing more to advance autonomously. **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). From 9f7885820f006a0230c0f340b3e7320719acef65 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 13:46:07 +0800 Subject: [PATCH 08/18] fix(memory): make Memory fail-closed inert under the shared global identity (MEM-PR01-00) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every commit-less repository resolves to the same ProjectV2.ID.global, and the branch keys Memory Home by project ID. Before this change an enabled global config activated Memory for all commit-less repos at once: one shared Home leaked topics across unrelated repositories, and the first commit moved the identity to root/remote while migrateProjectId never migrates away from global — silently orphaning everything written pre-commit. Fix with the minimal Occam seam: one fail-closed guard in Memory.configuration (the single activation gate behind active/prepare/search/checkpoint/setEnabled) returning undefined while the project identity is global. Memory activates normally once the repository gains a real identity; migrating the shared bucket is structurally infeasible (no per-repo provenance) and pre-existing orphans belong to the deferred retention/GC decision. - Red: search must report "unavailable" and /memory on must stay off for a commit-less repo even with an enabled global config and seeded topics - Green: single guard; identity-scoped (repos with a commit activate normally) - Mutation: removing the guard turns both Red tests red again - Domain regression: memory+project suites 162 pass / 0 fail; opencode+core typecheck clean - redo plan: record Fix #5 decision; reopen #3 (ABBA reachable via remote→remote identity change, MEM-PR01-R1-24) - remove leftover no-assertion diagnostic scaffold (repro-scope-finding); its scenario is captured in finding MEM-PR01-R1-06 for the M-C slice Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 5 +- packages/opencode/src/memory/memory.ts | 7 + .../memory/memory-global-identity.test.ts | 235 ++++++++++++++++++ 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/memory/memory-global-identity.test.ts diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 898ec05649..ab4277273f 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -190,8 +190,11 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor |---|---|---|---| | **#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` acquires its two project flocks in **sorted** order (no reverse-retirement ABBA) | MEM-LOCK-02 | ✅ **closed — not reachable** | — | +| **#3** `migrateHome` acquires its two project flocks in **sorted** order (no reverse-retirement ABBA) | MEM-LOCK-02 | ⚠️ **reopened by two-round review (MEM-PR01-R1-24, P2)**: retirement is NOT one-way — a changed origin URL yields `previous=remote(A), current=remote(B)` (resolve: `remote ?? previous`), so a reverse-ordered `migrateHome` pair IS reachable across two repos sharing identities. Sorted-flock fix pending in the M-A 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) | 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:** `migrateHome` is called only via `migrateProjectId(previous=oldID, current=newID)`; identity retirement is one-way (root→remote), so there is no `migrateHome(B,A)` reverse caller — the two project flocks are never acquired in opposite orders. ABBA is unreachable; no code change warranted. diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 7f4c03698f..f5a05cfc08 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -1,6 +1,7 @@ export * as Memory from "./memory" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +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" @@ -174,6 +175,12 @@ 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 + // 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 const migration = yield* admission.ensure({ projectID: current.id, 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..2bb55cd9ed --- /dev/null +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -0,0 +1,235 @@ +import { describe, expect } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +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 { SessionV1 } from "@opencode-ai/core/v1/session" +import { Effect, Layer } from "effect" +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 { 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 { 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, + MemoryAdmission.defaultLayer, + MemoryConfig.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-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 }, + ) +}) From 419ac4551c565e747d8cd0962794f6beb0fa91a5 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 14:38:18 +0800 Subject: [PATCH 09/18] =?UTF-8?q?fix(memory):=20harden=20identity=20migrat?= =?UTF-8?q?ion=20=E2=80=94=20residue=20tolerance,=20content=20merge,=20FK?= =?UTF-8?q?=20collision,=20deadlock-freedom=20(MEM-PR01=20M-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-round review confirmed four P2 defects in the identity-upgrade path; all fixed at the existing seams with Red→Green→mutation evidence per finding. R1-12 inspectHome wedged every upgrade after a crash: the store's own atomicWrite residue (manifest.json...tmp) was rejected as foreign state. Tolerate the store's own temp pattern; foreign files still fail closed (pinned). R1-15 The merge compared full topic JSON, so controller metadata drift from MemoryStore.markMatched (last_matched_at/match_count/revision/ updated_at) registered as a user-visible ConflictError and wedged the upgrade. Compare content only; the target's own copy stays authoritative; real content differences still conflict (pinned). R1-11 Permission FK repoint used a bulk UPDATE that violated the unique (project_id, action, resource) index whenever the successor identity already held the same (action, resource) — the immediate transaction died and the whole upgrade wedged on every retry. Repoint per row; on collision the successor row wins and the duplicate old row is dropped; disjoint rows still repoint. R1-24 The redo plan claimed ABBA unreachable because retirement was "one-way" — false: a changed origin URL yields remote→remote transitions, and the old lock structure (hold flock(old) across the merge while updateTopics locks flock(new) inside) deadlocks opposite-direction migrations (Red: 20 s test timeout on the legacy structure). Sorted pre-acquisition is impossible because the flock is non-reentrant, so fix by construction: a 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. A source that changed mid-merge now fails closed with retryable SourceChangedError instead of risking deletion of new data. Crash-retry convergence pinned (R1-13). - Domain regression: memory+project suites 169 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 7 +- .../opencode/src/memory/identity-migration.ts | 106 +++++-- packages/opencode/src/project/project.ts | 24 +- .../memory/memory-identity-migration.test.ts | 269 ++++++++++++++++++ .../opencode/test/project/project.test.ts | 69 +++++ 5 files changed, 450 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/test/memory/memory-identity-migration.test.ts diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index ab4277273f..6fc81308a6 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -190,9 +190,12 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor |---|---|---|---| | **#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` acquires its two project flocks in **sorted** order (no reverse-retirement ABBA) | MEM-LOCK-02 | ⚠️ **reopened by two-round review (MEM-PR01-R1-24, P2)**: retirement is NOT one-way — a changed origin URL yields `previous=remote(A), current=remote(B)` (resolve: `remote ?? previous`), so a reverse-ordered `migrateHome` pair IS reachable across two repos sharing identities. Sorted-flock fix pending in the M-A slice. | — | +| **#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) | this slice | +| **#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". diff --git a/packages/opencode/src/memory/identity-migration.ts b/packages/opencode/src/memory/identity-migration.ts index 6d3f17b0bf..f33794fdc0 100644 --- a/packages/opencode/src/memory/identity-migration.ts +++ b/packages/opencode/src/memory/identity-migration.ts @@ -7,6 +7,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Context, Effect, 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 { @@ -15,7 +16,7 @@ export interface Interface { newID: ProjectV2.ID, ) => Effect.Effect< void, - FSUtil.Error | EffectFlock.LockError | MemoryStore.StoreError | ConflictError | InvalidHomeError + FSUtil.Error | EffectFlock.LockError | MemoryStore.StoreError | ConflictError | InvalidHomeError | SourceChangedError > } @@ -32,6 +33,43 @@ export class InvalidHomeError extends Schema.TaggedErrorClass( }, ) {} +/** + * 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* () { @@ -46,46 +84,69 @@ export const layer = Layer.effect( !( (entry.name === "topics" && entry.type === "directory") || (entry.name === "generations" && entry.type === "directory") || - (entry.name === "manifest.json" && entry.type === "file") + (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) - if (!(yield* fs.existsSafe(source))) return const target = home.directory(newID) - yield* fs.makeDirectory(dirname(target), { recursive: true }) - if (!(yield* fs.existsSafe(target))) { - yield* fs.rename(source, target) - return - } - yield* inspectHome(source) + // 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))) { + yield* fs.rename(source, target) + return undefined + } + 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 sourceTopics = yield* store.inspectTopics(oldID) const targetTopics = yield* store.inspectTopics(newID) const targetByID = new Map(targetTopics.map((topic) => [topic.id, topic])) - const conflicts = sourceTopics + const conflicts = snapshot.topics .filter((topic) => { const current = targetByID.get(topic.id) - return current && JSON.stringify(current) !== JSON.stringify(topic) + return current && !sameContent(current, topic) }) .map((topic) => topic.id) if (conflicts.length > 0) yield* new ConflictError({ topic_ids: conflicts }) - const imported = sourceTopics.filter((topic) => !targetByID.has(topic.id)) + 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 && JSON.stringify(existing) !== JSON.stringify(topic) + return existing && !sameContent(existing, topic) }) if (conflicts.length > 0) throw new MemoryStore.StoreError({ @@ -103,13 +164,26 @@ export const layer = Layer.effect( } }) } - yield* fs.remove(source, { recursive: true }) + + // 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("|") return flock - .withLock(migrateHomeUnsafe(oldID, newID), `memory-project:${oldID}`, home.locks) + .withLock(migrateHomeUnsafe(oldID, newID), `memory-migrate:${pair}`, home.locks) .pipe(Effect.asVoid, Effect.withSpan("MemoryIdentityMigration.migrateHome")) } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f2ee5ee496..f63cf32126 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -194,14 +194,24 @@ export const layer = Layer.effect( // 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. A (newID, action, resource) - // collision on permission fails the immediate transaction closed (no data loss). + // workflow and every saved permission for the project. yield* d.update(WorkflowTable).set({ project_id: newID }).where(eq(WorkflowTable.project_id, oldID)).run() - yield* d - .update(PermissionTable) - .set({ project_id: newID }) - .where(eq(PermissionTable.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() }), 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/project/project.test.ts b/packages/opencode/test/project/project.test.ts index f0a8956e8d..07eae3474c 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -10,6 +10,7 @@ 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" @@ -310,6 +311,74 @@ describe("Project.fromDirectory", () => { }), ) + 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() From 216f6494654d3f6683c8fb5ada2d930e06183e13 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 15:51:48 +0800 Subject: [PATCH 10/18] =?UTF-8?q?fix(memory):=20close=20admission/lifecycl?= =?UTF-8?q?e=20review=20findings=20=E2=80=94=20full-snapshot=20reconcile,?= =?UTF-8?q?=20TOCTOU=20revalidation,=20retired-identity=20inertness=20(MEM?= =?UTF-8?q?-PR01=20M-C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1-06 (blocking): worktree remove/reset reconciled admission against a SINGLE directory, so a lone sandbox legacy config could be promoted to the project config past disagreeing siblings (order-dependent, silent effective-config flip). Both call sites now pass the complete snapshot (primary + every registered sandbox); disagreeing siblings fail closed with no promotion. R1-03: configuration() fell back to the stale instance context when the identity row was gone, letting a process holding a retired identity fork a Home under it. The fallback is removed: missing row = inert. R1-04: admission deleted scanned legacy topic/config files without re-reading them; a writer outside the admission flock (older runtime, hand edit) landing between scan and delete lost content. Each file is now re-read and compared immediately before removal; changed content is preserved and surfaced as a conflict. Deterministic TOCTOU test pins the scan→delete window via the store flock. R1-08: worktree remove/reset migration ran for uninitialized projects despite the memory path's inertness rule; reconcile is now gated on time.initialized (residue still fails closed). Existing migration tests stamp initialized. R1-10: admission's explicit-config choice used a localeCompare sort that put memory.json before memory.jsonc, disagreeing with MemoryConfig.load. The scan now keeps loader precedence and a jsonc/json fork in the project directory is diagnosed as config.conflict instead of silently picking a side; legacy configs equal only to the non-effective file are no longer deleted as duplicates. Pins: R1-07 (/memory writes the project config to the project worktree from a non-primary instance context) and R1-23 (runtime admission snapshot covers every registered sandbox). - Domain regression: memory+project suites 176 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 12 ++ packages/opencode/src/memory/admission.ts | 182 ++++++++++++++---- packages/opencode/src/memory/memory.ts | 7 +- packages/opencode/src/worktree/index.ts | 14 +- .../test/memory/memory-admission.test.ts | 105 +++++++++- .../memory/memory-global-identity.test.ts | 140 ++++++++++++++ .../test/project/worktree-remove.test.ts | 110 ++++++++++- .../opencode/test/project/worktree.test.ts | 8 + 8 files changed, 535 insertions(+), 43 deletions(-) diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 6fc81308a6..6aa08c883b 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -208,3 +208,15 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor **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). Per fix: 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 §10 table. Exclusions unchanged: no Goal/DAG-config/CI/push/PR, no source-Home GC. + +### 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 | diff --git a/packages/opencode/src/memory/admission.ts b/packages/opencode/src/memory/admission.ts index aed2e7b4b2..d12da3a0db 100644 --- a/packages/opencode/src/memory/admission.ts +++ b/packages/opencode/src/memory/admission.ts @@ -109,12 +109,28 @@ export const layer = Layer.effect( ).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: string[] = [] + const removable: TopicCandidate[] = [] const diagnostics = candidates.map((candidate) => { if (!candidate.topic) return new Diagnostic({ @@ -128,7 +144,7 @@ export const layer = Layer.effect( next.push(candidate.topic) byID.set(candidate.id, candidate.topic) changed.push(candidate.id) - removable.push(candidate.file) + removable.push(candidate) return new Diagnostic({ code: "topic.imported", path: candidate.file, @@ -137,7 +153,7 @@ export const layer = Layer.effect( }) } if (same(existing, candidate.topic)) { - removable.push(candidate.file) + removable.push(candidate) return new Diagnostic({ code: "topic.duplicate", path: candidate.file, @@ -157,17 +173,40 @@ export const layer = Layer.effect( result: { diagnostics, removable }, } }) - yield* Effect.forEach(updated.result.removable, (file) => fs.remove(file, { force: true }), { - concurrency: 1, - discard: true, - }) - return updated.result.diagnostics + 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) => @@ -185,11 +224,34 @@ export const layer = Layer.effect( Effect.map((items) => items .filter((item): item is ConfigCandidate => item !== undefined) - .sort((left, right) => left.file.localeCompare(right.file)), + .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( @@ -197,29 +259,73 @@ export const layer = Layer.effect( ) const explicit = project[0] if (explicit) { - const projectDiagnostic = explicit.config - ? [] - : [ + 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: explicit.file, + path: extra.file, message: "Project MEMORY config is invalid and was preserved", }), - ] - const diagnostics = yield* Effect.forEach( - legacy, - (candidate) => { - if (candidate.config && explicit.config && same(candidate.config, explicit.config)) - return fs.remove(candidate.file, { force: true }).pipe( - Effect.as( - new Diagnostic({ + ) + } 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", }), - ), - ) - return Effect.succeed( + ) + } else { + diagnostics.push( new Diagnostic({ code: candidate.config ? "config.conflict" : "config.invalid", path: candidate.file, @@ -228,10 +334,9 @@ export const layer = Layer.effect( : "Legacy sandbox MEMORY config is invalid and was preserved", }), ) - }, - { concurrency: 1 }, - ) - return [...projectDiagnostic, ...diagnostics] + } + } + return diagnostics } const valid = legacy.filter( @@ -253,24 +358,25 @@ export const layer = Layer.effect( const promoted = valid[0] yield* config.writeProject(snapshot.projectDirectory, promoted.config) - yield* Effect.forEach(valid, (candidate) => fs.remove(candidate.file, { force: true }), { - concurrency: 1, - discard: true, - }) + const preserved = yield* removeValidated(valid) return legacy.map( (candidate) => new Diagnostic({ code: !candidate.config ? "config.invalid" - : candidate.file === promoted.file - ? "config.promoted" - : "config.duplicate", + : 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" - : candidate.file === promoted.file - ? "Legacy sandbox MEMORY config was promoted to the Project config" - : "Legacy sandbox MEMORY config duplicates the promoted Project config", + : 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", }), ) }) diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index f5a05cfc08..b4de306c53 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -174,7 +174,12 @@ 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 diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index c15ce1dda3..16cdf0ce25 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -471,14 +471,20 @@ export const layer: Layer.Layer< projectID: ProjectV2.ID projectDirectory: string directory: string + directories: ReadonlyArray + initialized: boolean updated: number }) { - if (memoryAdmission) { + // 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.directory], + directories: input.directories, updated: input.updated, }) if (memory.unresolved > 0) @@ -523,6 +529,8 @@ export const layer: Layer.Layer< 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( @@ -699,6 +707,8 @@ export const layer: Layer.Layer< 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( diff --git a/packages/opencode/test/memory/memory-admission.test.ts b/packages/opencode/test/memory/memory-admission.test.ts index a0cc6b9130..0ab1ca8798 100644 --- a/packages/opencode/test/memory/memory-admission.test.ts +++ b/packages/opencode/test/memory/memory-admission.test.ts @@ -3,7 +3,7 @@ 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 { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { Effect, Layer } from "effect" +import { Duration, Effect, Fiber, Layer } from "effect" import path from "node:path" import { MemoryAdmission } from "@/memory/admission" import { MemoryConfig } from "@/memory/config" @@ -164,4 +164,107 @@ describe("MemoryAdmission", () => { }).pipe(Effect.provide(layers(root))) }), ) + + const fullLayers = (root: string) => { + const home = Layer.succeed(MemoryHome.Service, MemoryHome.make(root)) + const flock = EffectFlock.defaultLayer + const base = Layer.mergeAll(FSUtil.defaultLayer, flock, home, MemoryConfig.defaultLayer) + const store = MemoryStore.layer.pipe(Layer.provide(base)) + const admission = MemoryAdmission.layer.pipe(Layer.provide(base), Layer.provide(store)) + 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 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 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 }, + ) }) diff --git a/packages/opencode/test/memory/memory-global-identity.test.ts b/packages/opencode/test/memory/memory-global-identity.test.ts index 2bb55cd9ed..99f007fdd8 100644 --- a/packages/opencode/test/memory/memory-global-identity.test.ts +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -1,11 +1,14 @@ 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 { 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" @@ -21,6 +24,7 @@ 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" @@ -133,6 +137,142 @@ function gitInitWithoutCommit(dir: string) { }) } +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", diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index e4e99f78b5..b78dd610b8 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -2,7 +2,8 @@ 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 { Effect, Exit, Layer } from "effect" +import { stringify } from "yaml" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Worktree } from "../../src/worktree" import { Project } from "../../src/project/project" @@ -127,4 +128,111 @@ 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 }, + ) }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 20bc6b000c..f1a8c5e114 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -351,6 +351,9 @@ describe("Worktree", () => { 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") @@ -380,6 +383,7 @@ describe("Worktree", () => { 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") @@ -546,6 +550,8 @@ describe("Worktree", () => { 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") @@ -578,6 +584,8 @@ describe("Worktree", () => { 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") From f6fc23e13128802d0d2e3c496593e6ce5eed0191 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 16:33:23 +0800 Subject: [PATCH 11/18] fix(worktree): make list() non-destructive and move worktree cleanup to remove (MEM-PR01 M-D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1-16 (blocking): list() ran `git worktree prune` and deregistered sandboxes for every merely-prunable entry. "prunable" does not prove a worktree is gone — git also marks inaccessible directories (unmounted volume, locked parent) and broken gitdir links whose directories still exist, so a read call could destroy git admin data and live registrations. list() is now a pure observation path: prunable entries stay hidden from the listing but are otherwise untouched. The destructive cleanup moves to the action path, where each case can be proven: - remove() gains a prunable branch: reconcile legacy memory fail-closed, prune the admin data, remove the directory if it still exists, delete the branch, drop the registration. - remove() gains a git-unknown recovery branch (R1-18): a registered worktree with no git record previously failed forever with a false "not registered" error and no remediation; it now reconciles legacy memory fail-closed and drops the stale registration without ever deleting the directory. - Registration cleanup drops every canonically-equal entry, not just the first — symlinked paths (/var vs /private/var) could register the same worktree twice and leave a zombie entry that broke serialized removal. Pins (both mutation-proven): - R1-17: reset fails closed over invalid legacy memory and preserves it. - R1-19 (blocking): reset/remove invalidate the admission cache before the rescan; a reset-primed clean cache must never hide a legacy file that appears before a later destructive operation. - Updated the prune-era list test to the new semantics (list hides but does not touch; explicit remove cleans up). - Domain regression: memory+project suites 180 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 9 + packages/opencode/src/worktree/index.ts | 101 ++++++++--- .../test/project/worktree-remove.test.ts | 161 +++++++++++++++++- .../opencode/test/project/worktree.test.ts | 13 +- 4 files changed, 257 insertions(+), 27 deletions(-) diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 6aa08c883b..620163fd90 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -220,3 +220,12 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor | **#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) | diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 16cdf0ce25..e8b362e2c5 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -351,6 +351,18 @@ export const layer: Layer.Layer< )).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") @@ -395,25 +407,12 @@ export const layer: Layer.Layer< } const entries = parseWorktreeList(result.text) - const prunable = entries.flatMap((entry) => (entry.prunable && entry.path ? [entry.path] : [])) - if (prunable.length > 0) { - const pruned = yield* git(["worktree", "prune"], { cwd: ctx.worktree }) - if (pruned.code !== 0) - return yield* new ListFailedError({ - message: pruned.stderr || pruned.text || "Failed to prune stale git worktrees", - }) - const current = (yield* project.get(ctx.project.id)) ?? ctx.project - yield* Effect.forEach( - prunable, - (directory) => - Effect.gen(function* () { - const sandbox = yield* registeredSandbox(current.sandboxes, directory) - if (sandbox) yield* project.removeSandbox(ctx.project.id, sandbox) - }), - { concurrency: 1, discard: true }, - ) - } - + // 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(entries, (entry) => @@ -510,10 +509,15 @@ export const layer: Layer.Layer< } const currentProject = (yield* project.get(ctx.project.id)) ?? ctx.project - const registered = yield* registeredSandbox(currentProject.sandboxes, directory) - if (!registered) { + 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) { @@ -522,7 +526,28 @@ export const layer: Layer.Layer< const entry = yield* locateWorktree(parseWorktreeList(list.text), directory) if (!entry?.path) { - return yield* new RemoveFailedError({ message: "Worktree is not registered with this Project" }) + // 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. + 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* FiberMap.remove(bootFibers, directory) + yield* store.disposeDirectory(directory) + yield* dropRegistrations + return true } const blocker = yield* reconcileLegacyMemory({ @@ -539,6 +564,34 @@ export const layer: Layer.Layer< ) 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* FiberMap.remove(bootFibers, directory) + yield* store.disposeDirectory(entry.path) + 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 + } + yield* FiberMap.remove(bootFibers, directory) if (settingsHook) { @@ -578,7 +631,7 @@ export const layer: Layer.Layer< 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* project.removeSandbox(ctx.project.id, registered) + if (restored.code !== 0) yield* dropRegistrations const recovery = restored.code === 0 ? "the worktree registration was restored" @@ -589,7 +642,7 @@ export const layer: Layer.Layer< } } - yield* project.removeSandbox(ctx.project.id, registered) + yield* dropRegistrations return true }) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index b78dd610b8..217920a725 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -5,12 +5,15 @@ import path from "path" import { Effect, Exit, Layer } from "effect" import { stringify } from "yaml" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +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, Project.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll(Worktree.defaultLayer, Project.defaultLayer, CrossSpawnSpawner.defaultLayer, MemoryStore.defaultLayer), +) const wintest = process.platform === "win32" ? it.instance : it.instance.skip describe("Worktree.remove", () => { @@ -235,4 +238,160 @@ describe("Worktree.remove", () => { }), { 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 }, + ) }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index f1a8c5e114..b58a974fce 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -706,7 +706,7 @@ describe("Worktree", () => { ) it.instance( - "prunes missing worktrees and removes their Project registration", + "hides a missing worktree in list and cleans it up on explicit remove", () => withCreatedWorktree(undefined, ({ info }) => Effect.gen(function* () { @@ -716,9 +716,18 @@ describe("Worktree", () => { 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) - expect((yield* project.get(ctx.project.id))?.sandboxes).not.toContain(info.directory) + const after = yield* project.get(ctx.project.id) + expect(after?.sandboxes.some((sandbox) => sandbox === info.directory)).toBe(false) }), ), { git: true }, From 5506755d971b00cbfad3e4fcea6db09348d74552 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 16:50:52 +0800 Subject: [PATCH 12/18] =?UTF-8?q?test(memory):=20pin=20store=20resilience?= =?UTF-8?q?=20=E2=80=94=20corrupt-manifest=20fail-closed,=20item=5Fcount,?= =?UTF-8?q?=20torn-commit=20(MEM-PR01=20M-E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1-02 (P2 test-gap): the corrupt-manifest fail-closed guards had no test, so reverting them would let migrateHome delete an unread Memory Home. Now pinned: an invalid manifest and a manifest referencing a missing generation both fail readSnapshot, and migrateHome fails closed on the merge path with the source Home preserved. Both guards proven load-bearing by mutation (fail-open revert turns the test Red). R1-20 (P3 test-gap): decodeTopic item_count/items.length consistency was only covered by a since-deleted test. Re-pinned at both the decoder and the writeSnapshot generation gate (mutation-proven). R1-21 (P3 test-gap): a crash mid-writeSnapshot leaves an orphaned staging generation whose manifest was never published; pinned that it never shadows the committed generation and the store still commits cleanly (mutation-proven). Test-only change; no production code touched. - memory+project suites 183 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 8 ++ .../test/memory/memory-persistence.test.ts | 89 ++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 620163fd90..4fa78da7c2 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -229,3 +229,11 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor | **#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) | diff --git a/packages/opencode/test/memory/memory-persistence.test.ts b/packages/opencode/test/memory/memory-persistence.test.ts index c9f91e3a5c..56fcd27539 100644 --- a/packages/opencode/test/memory/memory-persistence.test.ts +++ b/packages/opencode/test/memory/memory-persistence.test.ts @@ -3,7 +3,7 @@ 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, Schema } from "effect" +import { Effect, Exit, Layer, Schema } from "effect" import path from "node:path" import { MemoryConfig } from "@/memory/config" import { MemoryHome } from "@/memory/home" @@ -602,4 +602,91 @@ describe("Project-owned MEMORY persistence", () => { }).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( + "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))) + }), + ) }) From 8c91cbd0e356b8baee569b910570f21156006943 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 17:20:54 +0800 Subject: [PATCH 13/18] fix(memory): serialize MEMORY config file writers per file; pin cross-process commit conflict (MEM-PR01 M-F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-02: the branch collapses MEMORY config onto one project-primary file, written by three paths under mutually disjoint locks — /memory on|off (in-process KeyedMutex), admission promotion (memory-admission flock), and readConfig's normalization rewrite (no lock). atomicWrite prevents torn bytes but not whole-document last-writer-wins across worktrees/processes. All config file writes now serialize on a per-file cross-process flock (memory-config:): writeProject, writeGlobal, and the normalization rewrite. Pinned by a blocking-observation test; mutation-proven (dropping the lock lets a concurrent writer complete while the lock is held). Residual, documented rather than fixed (Occam): decision-level read-modify-write across processes is not CAS-protected — only the write primitives are serialized. A full cross-process RMW protocol would be over-engineering for the exposure. R2-03: the cross-process commit protocol's explicit-conflict guarantee (ADR-0002) was only exercised within one process. A new spawned-worker test commits with a stale expectedRevision from a second OS process and observes CommitConflictError deterministically (the pre-existing updateTopics race test only overlaps probabilistically). - memory+project suites 185 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 7 ++ packages/opencode/src/memory/config.ts | 35 ++++++--- .../test/fixture/memory-commit-worker.ts | 75 ++++++++++++++++++ .../test/memory/memory-persistence.test.ts | 77 ++++++++++++++++++- 4 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/fixture/memory-commit-worker.ts diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 4fa78da7c2..3d69af22f1 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -237,3 +237,10 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor | **#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 | diff --git a/packages/opencode/src/memory/config.ts b/packages/opencode/src/memory/config.ts index 31eaa35c5f..4d1b641edf 100644 --- a/packages/opencode/src/memory/config.ts +++ b/packages/opencode/src/memory/config.ts @@ -2,6 +2,7 @@ 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" @@ -20,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") {} @@ -37,6 +41,7 @@ export const layer = Layer.effect( 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 }) @@ -68,7 +73,7 @@ export const layer = Layer.effect( } if (decoded.value.topic_limit === decoded.value.topic_limit_floor) return decoded.value const config = normalizeConfig(decoded.value) - yield* MemoryFile.atomicWrite(fs, found.path, serialize(config)) + yield* flock.withLock(MemoryFile.atomicWrite(fs, found.path, serialize(config)), writeLockKey(found.path)) return config }) @@ -97,7 +102,13 @@ export const layer = Layer.effect( existingPath?: string, ) { yield* ensureProjectExclude(projectDir) - yield* MemoryFile.atomicWrite(fs, existingPath ?? projectPath(projectDir), serialize(config)) + // 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* ( @@ -105,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 }) @@ -128,10 +139,16 @@ export const layer = Layer.effect( 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, Git.node]) +export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node, Git.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") 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/memory/memory-persistence.test.ts b/packages/opencode/test/memory/memory-persistence.test.ts index 56fcd27539..d4141c2dab 100644 --- a/packages/opencode/test/memory/memory-persistence.test.ts +++ b/packages/opencode/test/memory/memory-persistence.test.ts @@ -3,7 +3,7 @@ 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, Exit, Layer, Schema } from "effect" +import { Effect, Exit, Fiber, Layer, Ref, Schema } from "effect" import path from "node:path" import { MemoryConfig } from "@/memory/config" import { MemoryHome } from "@/memory/home" @@ -663,6 +663,81 @@ describe("Project-owned MEMORY persistence", () => { }), ) + 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)", () => From 2382186f9c5168e707da10dec3488a630121379a Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 17:34:37 +0800 Subject: [PATCH 14/18] docs(memory): align CONTEXT.md and redo plan with the shipped Occam design (MEM-PR01 M-G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1-01 (blocking): CONTEXT.md still shipped the rejected ADR-0004 authority design as the domain's governing self-doc. Rewritten to the actual authority structure (Store/Config/Admission/migrateHome/worktree guard + project identity migration): rejected-design glossary and invariants removed (Identity Alias, Canonical Project ID, tombstone retirement, opaque Revision, destruction guard); source Home described as migrate-then-remove with retention deferred; Project Configuration described as the unversioned .opencode/memory.jsonc; the read-leniency split stated (runtime read projects empty, strict reads and migration fail closed); ADR-0001's policy clause restored as live; ADR-0004 marked Rejected; the M-A…M-F behaviors reflected (global inertness, content-only conflicts, non-destructive list, fail-closed reset/remove, per-file config lock). R1-25: 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). R1-09 (spec-gap → decision): the git-exclusion narrowing to the two config candidates is intentional and now documented: preserved fail-closed legacy topic files stay visible in git status and committable; surfacing repair- pending files beats silently excluding user data. R1-14 (spec-gap → requirement): the openspec workspace is untracked, so this plan now carries the identity-upgrade requirement ("Identity upgrade preserves Project Memory and Project-owned references") with its scenarios, pinned by the M-A/M-B/M-C/M-E tests. Docs-only; no production code touched. Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 13 ++++- packages/opencode/src/memory/CONTEXT.md | 54 ++++++++++--------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 3d69af22f1..6385dc5d37 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -1,7 +1,7 @@ # Memory Authority Redo Plan — from `d7b011738` Date: 2026-08-12. Worktree: `/private/tmp/oc-dag-wt-lifecycle` (branch `chore/worktree-lifecycle-audit`). -Status: PLANNING (awaiting user confirmation before any implementation/loop). +Status: ADR-0004 **Rejected**; Occam path (§10) **adopted and implemented** (#1 done; #2 deferred by user; #3/#4 closed as non-gaps). The two-round MEM-PR01 review then landed fixes/pins #5–#18 below. Nothing is left to implement autonomously; remaining items are user decisions (#2 typed-error cascade, source-Home retention/GC). ## 0. Why this plan exists @@ -207,7 +207,7 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor **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). Per fix: 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 §10 table. Exclusions unchanged: no Goal/DAG-config/CI/push/PR, no source-Home GC. +**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) @@ -244,3 +244,12 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor |---|---|---| | **#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 | diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index 3b104992a2..cc95f18b7b 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -8,23 +8,30 @@ Project Memory preserves user-confirmed, durable human context for one Project. - **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. Its identity follows the Project, not a checkout path. | +| 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 valid content, or where legacy configuration differs from the Project configuration. | -| Project Configuration | The MEMORY policy owned by the Project and shared by its worktrees. Under ADR-0004 it lives in the Memory Home, atomically versioned with Topics; worktree/global config files are admission candidates only. | +| 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 Alias | A durable old→new Project identity tombstone owned by `ProjectIdentity`. Every Memory read and mutation resolves it before choosing a Home or lock. | -| Requested Project ID | A Project ID held by a caller. It may already be retired and therefore is not an ownership key. | -| Canonical Project ID | The current terminal Project ID that owns Project Memory. Resolved inside the Project Memory authority and not supplied by callers. | -| Identity Retirement | A forward-only replacement of one Project ID by its successor while preserving one logical Project and all Project-owned state — merge into one Memory, not a fork. | -| Project Merge | A product operation that combines two independently owned Projects. Identity Retirement never performs an implicit Project Merge. | -| Project Memory Revision | An opaque version of one Project Memory snapshot, including Topics, Project Configuration, topology, and admission inputs. | +| 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 @@ -33,29 +40,28 @@ Project Memory preserves user-confirmed, durable human context for one Project. - 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. -- Removing or resetting a worktree cannot imply deleting Project Memory. +- 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 the Project Memory authority. -- Project identity retirement validates the full transition before durable state changes, prepares the successor while preserving the source, publishes one identity commit point (the tombstone), and completes Project-owned reference migration by forward recovery. -- Routine Project Memory commands resolve identity, acquire one canonical Project commit right, and recheck identity before reading or writing. -- A missing Memory Home is empty; an existing corrupt Home is an error and is never projected as an empty Topic set. -- Project configuration and Topic mutations publish under one generation, one manifest, and one opaque Revision, in the same cross-process Project lock. -- Application callers never receive canonical IDs, Home paths, locks, cache keys, or migration callbacks. -- A revision issued before Identity Retirement cannot commit after the identity commit point. -- Destructive Memory Admission always observes current candidate files; it never trusts a process-local success cache. -- The retired source Home is preserved as a non-authoritative backup; its GC is a separate, deferred 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 -- The Project Memory authority obtains identity, the primary checkout, and every registered worktree from durable Project state; callers provide only a requested Project ID. -- Worktree lifecycle requests destructive admission as one command through the internal destruction guard; it does not invalidate caches, assemble snapshots, or own Project Memory retention. +- 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) *(Policy-source clause superseded by ADR-0004)* +- [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) — **Proposed (P0, awaiting approval)** +- [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. From c5584bbb7d2d9b6fbd12087e159d309533d2e0bf Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 20:43:52 +0800 Subject: [PATCH 15/18] =?UTF-8?q?fix(memory):=20close=20Round=203/4=20P2?= =?UTF-8?q?=20findings=20=E2=80=94=20identity-lock=20protocol,=20scoped=20?= =?UTF-8?q?prune,=20proof-after-hook=20(MEM-PR01=20M-H)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3-P2-a in-flight old-identity writer could recreate a retired Home: writers (prepare/search/checkpoint) now 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. Lock order admission -> migrate(pair) -> identity -> project is cycle-free. R3-P2-d git worktree prune is repo-global; it now runs only when the removed entry is the sole prunable one (else stale admin data is left for explicit cleanup), so sibling worktrees' admin data is not destroyed. R3-P2-e the WorktreeRemove hook now fires BEFORE the reconcile proof on all remove paths, so the proof observes everything the hook produced. R3-P2-f remove/reset no longer fall back to the stale instance identity (?? ctx.project); they fail closed when the identity row is gone. R3-P2-b cleanupLegacyDirectory re-checks the listing immediately before removing each dir. Pins (mutation-proven): SourceChanged verify-before-delete guard (R3-P2-c); store write paths fail closed on a corrupt manifest (R4-P2-a); unresolved admission results are never cached (R4-P2-b). Docs: ADR-0002 updated to the three-phase merge + identity-lock protocol; rejected ADR-0004 no longer claims to supersede live clauses; redo-plan #3 ABBA narrative corrected. Also drops two no-op non-null assertions (session/summary.ts, format/index.ts) surfaced by type-aware churn from the identity-migration FK fix, returning the tree to the 4852 lint ratchet with no behavior change. Co-Authored-By: Claude --- docs/memory-authority-redo-plan-2026-08-12.md | 26 ++- packages/opencode/src/format/index.ts | 2 +- packages/opencode/src/memory/admission.ts | 18 +- .../0002-project-memory-commit-protocol.md | 2 +- .../docs/adr/0004-project-memory-authority.md | 2 +- .../opencode/src/memory/identity-migration.ts | 10 +- packages/opencode/src/memory/memory.ts | 200 +++++++++++------- packages/opencode/src/session/summary.ts | 2 +- packages/opencode/src/worktree/index.ts | 68 ++++-- .../memory/memory-global-identity.test.ts | 2 + .../test/memory/memory-persistence.test.ts | 104 +++++++++ packages/opencode/test/memory/memory.test.ts | 4 + 12 files changed, 330 insertions(+), 110 deletions(-) diff --git a/docs/memory-authority-redo-plan-2026-08-12.md b/docs/memory-authority-redo-plan-2026-08-12.md index 6385dc5d37..aa5fc070b6 100644 --- a/docs/memory-authority-redo-plan-2026-08-12.md +++ b/docs/memory-authority-redo-plan-2026-08-12.md @@ -1,7 +1,7 @@ # 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; #3/#4 closed as non-gaps). The two-round MEM-PR01 review then landed fixes/pins #5–#18 below. Nothing is left to implement autonomously; remaining items are user decisions (#2 typed-error cascade, source-Home retention/GC). +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 @@ -199,11 +199,11 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor **#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:** `migrateHome` is called only via `migrateProjectId(previous=oldID, current=newID)`; identity retirement is one-way (root→remote), so there is no `migrateHome(B,A)` reverse caller — the two project flocks are never acquired in opposite orders. ABBA is unreachable; no code change warranted. +**#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). #3 and #4 verified as non-gaps; #2 deferred as a cascade awaiting the user's Occam-vs-invariant-#5 call. The driving loop is removed; nothing more to advance autonomously. +**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). @@ -253,3 +253,23 @@ After the survey + ultracode adversarial review, the user applied Occam's Razor | **#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/admission.ts b/packages/opencode/src/memory/admission.ts index d12da3a0db..7e7fded009 100644 --- a/packages/opencode/src/memory/admission.ts +++ b/packages/opencode/src/memory/admission.ts @@ -383,11 +383,16 @@ export const layer = Layer.effect( const cleanupLegacyDirectory = Effect.fnUntraced(function* (directory: string) { const topics = MemoryPaths.legacyTopics(directory) - if ((yield* fs.existsSafe(topics)) && (yield* fs.readDirectoryEntries(topics)).length === 0) - yield* fs.remove(topics, { recursive: true }) + 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) - yield* fs.remove(legacy, { recursive: true }) + 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) { @@ -418,8 +423,11 @@ export const layer = Layer.effect( 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 lock serializes the + // import against a concurrent identity retirement renaming the Home. return yield* flock.withLock( - ensureUnsafe(normalized, key), + flock.withLock(ensureUnsafe(normalized, key), `memory-identity:${snapshot.projectID}`, home.locks), `memory-admission:${snapshot.projectID}`, home.locks, ) 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 index 55a52c04bc..958efd3fe6 100644 --- 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 @@ -18,7 +18,7 @@ Each successful mutation writes a complete Topic generation into a temporary dir 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 holds the old Project's process lock while moving or merging its Memory Home. The Project database retires the old identity only after Memory migration succeeds. +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 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 index b1e9f6d766..8fa9a0c94c 100644 --- a/packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md +++ b/packages/opencode/src/memory/docs/adr/0004-project-memory-authority.md @@ -2,7 +2,7 @@ - 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: 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); adds Identity Retirement. Reconstructs the lost authority redesign (uncommitted WIP, /tmp-cleaned) from design memory, now written down so it is auditable. +- 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 diff --git a/packages/opencode/src/memory/identity-migration.ts b/packages/opencode/src/memory/identity-migration.ts index f33794fdc0..ef2f0b90c7 100644 --- a/packages/opencode/src/memory/identity-migration.ts +++ b/packages/opencode/src/memory/identity-migration.ts @@ -182,8 +182,16 @@ export const layer = Layer.effect( const migrateHome: Interface["migrateHome"] = (oldID, newID) => { if (oldID === newID) return Effect.void const pair = [oldID, newID].sort().join("|") + // Lock order (outermost→innermost): memory-migrate (pair) → memory-identity + // (oldID) → memory-project (inside migrateHomeUnsafe). The identity lock + // fences out in-flight writers still producing under oldID: they hold + // memory-identity:oldID for their whole read-modify-write, so the rename + // waits for them and moves their writes along with the Home instead of + // letting them recreate a retired Home afterwards. Writers take + // identity→project, the same relative order, so no ABBA. + const body = flock.withLock(migrateHomeUnsafe(oldID, newID), `memory-identity:${oldID}`, home.locks) return flock - .withLock(migrateHomeUnsafe(oldID, newID), `memory-migrate:${pair}`, home.locks) + .withLock(body, `memory-migrate:${pair}`, home.locks) .pipe(Effect.asVoid, Effect.withSpan("MemoryIdentityMigration.migrateHome")) } diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index b4de306c53..42e1a1f862 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -2,6 +2,7 @@ export * as Memory from "./memory" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProjectV2 } from "@opencode-ai/core/project" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Context, Duration, Effect, Layer, Option, Ref, Schema, Semaphore } from "effect" import { stringify } from "yaml" @@ -67,6 +68,7 @@ export const layer: Layer.Layer< | Config.Service | Provider.Service | Project.Service + | EffectFlock.Service | MemoryAdmission.Service | MemoryConfig.Service | MemoryLock.Service @@ -78,6 +80,7 @@ export const layer: Layer.Layer< const config = yield* Config.Service const provider = yield* Provider.Service const project = yield* Project.Service + const flock = yield* EffectFlock.Service const admission = yield* MemoryAdmission.Service const configStore = yield* MemoryConfig.Service const lock = yield* MemoryLock.Service @@ -358,38 +361,49 @@ export const layer: Layer.Layer< session.firstTurnAttempted = true if (!due && !shouldMatch) return - yield* lock.withProject(current.project.id)( + // Cross-process identity guard (see checkpointUnsafe): re-check identity + // liveness under the identity lock before writing. + yield* flock.withLock( 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 (!(yield* project.get(current.project.id))) { + yield* clearSession(input.sessionID) + return + } + 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 } + }), + ) }), + `memory-identity:${current.project.id}`, ) }) @@ -452,35 +466,46 @@ export const layer: Layer.Layer< } const origin = user.info.id - return yield* lock.withProject(current.project.id)( + // Cross-process identity guard (see checkpointUnsafe): re-check identity + // liveness under the identity lock before matching/writing. + return yield* flock.withLock( 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 (!(yield* project.get(current.project.id))) { + yield* clearSession(input.sessionID) + return { status: "unavailable" as const } } - 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 } + 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 } + }), + ) }), + `memory-identity:${current.project.id}`, ) }) @@ -506,32 +531,47 @@ export const layer: Layer.Layer< return [] } const user = latestRealUser(input.messages) - return yield* lock.withProject(current.project.id)( + // 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). + return yield* flock.withLock( 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 - }), - ), + if (!(yield* project.get(current.project.id))) { + yield* clearSession(input.sessionID) + return [] + } + 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 ?? "", - projectID: current.project.id, - })).rendered - return rendered }), + `memory-identity:${current.project.id}`, ) }) @@ -595,6 +635,7 @@ export const defaultLayer: Layer.Layer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Project.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), Layer.provide(MemoryAdmission.defaultLayer), Layer.provide(MemoryConfig.defaultLayer), Layer.provide(MemoryLock.defaultLayer), @@ -607,6 +648,7 @@ export const node = LayerNode.make(layer, [ Config.node, Provider.node, Project.node, + EffectFlock.node, MemoryAdmission.node, MemoryConfig.node, MemoryLock.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 e8b362e2c5..1f3c05c1df 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -508,7 +508,14 @@ export const layer: Layer.Layer< return yield* new RemoveFailedError({ message: "Cannot remove the primary or current worktree" }) } - const currentProject = (yield* project.get(ctx.project.id)) ?? ctx.project + // 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" }) @@ -524,13 +531,24 @@ export const layer: Layer.Layer< return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" }) } - const entry = yield* locateWorktree(parseWorktreeList(list.text), directory) + const entries = parseWorktreeList(list.text) + const entry = yield* locateWorktree(entries, directory) if (!entry?.path) { // 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, @@ -544,12 +562,25 @@ export const layer: Layer.Layer< ), ) if (blocker) return yield* new RemoveFailedError({ message: blocker }) - yield* FiberMap.remove(bootFibers, directory) 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, @@ -569,9 +600,15 @@ export const layer: Layer.Layer< // 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* FiberMap.remove(bootFibers, directory) yield* store.disposeDirectory(entry.path) - yield* git(["worktree", "prune"], { cwd: ctx.worktree }) + // `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) { @@ -592,18 +629,6 @@ export const layer: Layer.Layer< return true } - 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: "" }) - } - // Git may return the original casing when a caller supplied a normalized Windows path. yield* store.disposeDirectory(entry.path) yield* stopFsmonitor(entry.path) @@ -738,7 +763,14 @@ export const layer: Layer.Layer< return yield* new ResetFailedError({ message: "Cannot reset the primary or current worktree" }) } - const currentProject = (yield* project.get(ctx.project.id)) ?? ctx.project + // 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" }) } diff --git a/packages/opencode/test/memory/memory-global-identity.test.ts b/packages/opencode/test/memory/memory-global-identity.test.ts index 99f007fdd8..85dcd7e19b 100644 --- a/packages/opencode/test/memory/memory-global-identity.test.ts +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -6,6 +6,7 @@ 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" @@ -106,6 +107,7 @@ const base = Layer.mergeAll( Project.defaultLayer, Database.defaultLayer, Git.defaultLayer, + EffectFlock.defaultLayer, MemoryAdmission.defaultLayer, MemoryConfig.defaultLayer, MemoryLock.defaultLayer, diff --git a/packages/opencode/test/memory/memory-persistence.test.ts b/packages/opencode/test/memory/memory-persistence.test.ts index d4141c2dab..89635d1b75 100644 --- a/packages/opencode/test/memory/memory-persistence.test.ts +++ b/packages/opencode/test/memory/memory-persistence.test.ts @@ -764,4 +764,108 @@ describe("Project-owned MEMORY persistence", () => { }).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 + 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 fb506cc2a8..569dc5ca1d 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -1,6 +1,7 @@ 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" @@ -97,6 +98,7 @@ const unavailableModelIt = testEffect( Layer.provide( Layer.mergeAll( emptyConfigLayer, + EffectFlock.defaultLayer, replacementProvider.layer, Layer.mock(Project.Service, { get: (id) => @@ -191,6 +193,7 @@ function bootstrapFixture() { const layer = Memory.layer.pipe( Layer.provide( Layer.mergeAll( + EffectFlock.defaultLayer, Layer.mock(Config.Service, { get: () => Effect.succeed({ @@ -320,6 +323,7 @@ function recallFixture() { Layer.provide( Layer.mergeAll( emptyConfigLayer, + EffectFlock.defaultLayer, provider.layer, Layer.mock(Project.Service, { get: (id) => From 3dd5999779bc2c3d180c9f043fae9d70f1b4c1b7 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Aug 2026 23:25:31 +0800 Subject: [PATCH 16/18] fix(memory): hold identity fence across the full retirement seam (MEM-PR01 M-I) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two Round 5+6 convergence findings: - P1-a (writer fence in the wrong lock dir): memory.ts's writer fence (prepare/search/checkpoint) now passes home.locks so it lives in the same lock namespace as identity migration. Previously it fell back to the default XDG-state lock dir, a DIFFERENT directory, so the writer fence and the migration fence never actually serialized. - flock-leak P2 (fence released before row deletion): ProjectIdentityMigration .migrate now holds memory-identity: for the WHOLE retirement — the Memory Home migration AND the caller's reference/row retirement — via a retireReferences callback. The fence is no longer released between the Home move and the old-row deletion, so an in-flight writer under oldID cannot slip into the gap. Callers pass their row retirement as the callback and no longer touch the fence themselves (single authority for the fence). Mutation check: removing retireReferences() from inside the fence turns the MEM-PR01-R1-11 permission-collision test Red (FK repoint no longer happens), confirming the seam wiring is load-bearing. Co-Authored-By: Claude --- .../opencode/src/memory/identity-migration.ts | 16 +++---- packages/opencode/src/memory/memory.ts | 8 ++++ .../src/project/identity-migration.ts | 43 +++++++++++++++++-- packages/opencode/src/project/project.ts | 26 ++++++----- .../memory/memory-global-identity.test.ts | 2 + packages/opencode/test/memory/memory.test.ts | 4 ++ .../opencode/test/project/project.test.ts | 6 ++- 7 files changed, 80 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/memory/identity-migration.ts b/packages/opencode/src/memory/identity-migration.ts index ef2f0b90c7..3ec1decc29 100644 --- a/packages/opencode/src/memory/identity-migration.ts +++ b/packages/opencode/src/memory/identity-migration.ts @@ -182,16 +182,14 @@ export const layer = Layer.effect( const migrateHome: Interface["migrateHome"] = (oldID, newID) => { if (oldID === newID) return Effect.void const pair = [oldID, newID].sort().join("|") - // Lock order (outermost→innermost): memory-migrate (pair) → memory-identity - // (oldID) → memory-project (inside migrateHomeUnsafe). The identity lock - // fences out in-flight writers still producing under oldID: they hold - // memory-identity:oldID for their whole read-modify-write, so the rename - // waits for them and moves their writes along with the Home instead of - // letting them recreate a retired Home afterwards. Writers take - // identity→project, the same relative order, so no ABBA. - const body = flock.withLock(migrateHomeUnsafe(oldID, newID), `memory-identity:${oldID}`, home.locks) + // 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(body, `memory-migrate:${pair}`, home.locks) + .withLock(migrateHomeUnsafe(oldID, newID), `memory-migrate:${pair}`, home.locks) .pipe(Effect.asVoid, Effect.withSpan("MemoryIdentityMigration.migrateHome")) } diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 42e1a1f862..968b5cb008 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -14,6 +14,7 @@ import { MessageID, SessionID } from "@/session/schema" import { Token } from "@/util/token" import { MemoryAdmission } from "./admission" import { MemoryConfig } from "./config" +import { MemoryHome } from "./home" import { MemoryLock } from "./lock" import { MemoryModel } from "./model" import { MemoryPrompts } from "./prompts" @@ -71,6 +72,7 @@ export const layer: Layer.Layer< | EffectFlock.Service | MemoryAdmission.Service | MemoryConfig.Service + | MemoryHome.Service | MemoryLock.Service | MemoryModel.Service | MemoryStore.Service @@ -81,6 +83,7 @@ export const layer: Layer.Layer< const provider = yield* Provider.Service const project = yield* Project.Service const flock = yield* EffectFlock.Service + const home = yield* MemoryHome.Service const admission = yield* MemoryAdmission.Service const configStore = yield* MemoryConfig.Service const lock = yield* MemoryLock.Service @@ -404,6 +407,7 @@ export const layer: Layer.Layer< ) }), `memory-identity:${current.project.id}`, + home.locks, ) }) @@ -506,6 +510,7 @@ export const layer: Layer.Layer< ) }), `memory-identity:${current.project.id}`, + home.locks, ) }) @@ -572,6 +577,7 @@ export const layer: Layer.Layer< ) }), `memory-identity:${current.project.id}`, + home.locks, ) }) @@ -638,6 +644,7 @@ export const defaultLayer: Layer.Layer = Layer.suspend(() => Layer.provide(EffectFlock.defaultLayer), Layer.provide(MemoryAdmission.defaultLayer), Layer.provide(MemoryConfig.defaultLayer), + Layer.provide(MemoryHome.defaultLayer), Layer.provide(MemoryLock.defaultLayer), Layer.provide(MemoryModel.defaultLayer), Layer.provide(MemoryStore.defaultLayer), @@ -651,6 +658,7 @@ export const node = LayerNode.make(layer, [ EffectFlock.node, MemoryAdmission.node, MemoryConfig.node, + MemoryHome.node, MemoryLock.node, MemoryModel.node, MemoryStore.node, diff --git a/packages/opencode/src/project/identity-migration.ts b/packages/opencode/src/project/identity-migration.ts index 4afd08a62f..a56c0b5316 100644 --- a/packages/opencode/src/project/identity-migration.ts +++ b/packages/opencode/src/project/identity-migration.ts @@ -1,12 +1,27 @@ 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 { MemoryIdentityMigration } from "@/memory/identity-migration" export interface Interface { - readonly migrate: (oldID: ProjectV2.ID, newID: ProjectV2.ID) => Effect.Effect + /** + * 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") {} @@ -15,12 +30,32 @@ 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) => memory.migrateHome(oldID, newID).pipe(Effect.orDie), + migrate: (oldID, newID, retireReferences) => + flock + .withLock( + Effect.gen(function* () { + yield* memory.migrateHome(oldID, newID) + yield* retireReferences() + }), + `memory-identity:${oldID}`, + home.locks, + ) + .pipe(Effect.orDie, Effect.withSpan("ProjectIdentityMigration.migrate")), }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(MemoryIdentityMigration.defaultLayer)) +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]) +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 f63cf32126..f114637bb1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -155,12 +155,16 @@ export const layer = Layer.effect( if (oldID === ProjectV2.ID.global) return if (oldID === newID) return - yield* identityMigration.migrate(oldID, newID) - - yield* db - .transaction( - (d) => - Effect.gen(function* () { + // 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) { @@ -213,11 +217,11 @@ export const layer = Layer.effect( } } - if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run() - }), - { behavior: "immediate" }, - ) - .pipe(Effect.orDie) + 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: { diff --git a/packages/opencode/test/memory/memory-global-identity.test.ts b/packages/opencode/test/memory/memory-global-identity.test.ts index 85dcd7e19b..9fd429d1e5 100644 --- a/packages/opencode/test/memory/memory-global-identity.test.ts +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -17,6 +17,7 @@ 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 { MemoryLock } from "@/memory/lock" import { Memory } from "@/memory/memory" import { MemoryModel } from "@/memory/model" @@ -110,6 +111,7 @@ const base = Layer.mergeAll( EffectFlock.defaultLayer, MemoryAdmission.defaultLayer, MemoryConfig.defaultLayer, + MemoryHome.defaultLayer, MemoryLock.defaultLayer, MemoryStore.defaultLayer, Layer.mock(MemoryModel.Service, { diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 569dc5ca1d..34ea968234 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -9,6 +9,7 @@ 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 { MemoryLock } from "@/memory/lock" import { Memory } from "@/memory/memory" import { MemoryModel } from "@/memory/model" @@ -99,6 +100,7 @@ const unavailableModelIt = testEffect( Layer.mergeAll( emptyConfigLayer, EffectFlock.defaultLayer, + MemoryHome.defaultLayer, replacementProvider.layer, Layer.mock(Project.Service, { get: (id) => @@ -194,6 +196,7 @@ function bootstrapFixture() { Layer.provide( Layer.mergeAll( EffectFlock.defaultLayer, + MemoryHome.defaultLayer, Layer.mock(Config.Service, { get: () => Effect.succeed({ @@ -324,6 +327,7 @@ function recallFixture() { Layer.mergeAll( emptyConfigLayer, EffectFlock.defaultLayer, + MemoryHome.defaultLayer, provider.layer, Layer.mock(Project.Service, { get: (id) => diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 07eae3474c..5d6e291cbb 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -119,7 +119,11 @@ function projectLayerWithMemoryRoot(root: string) { Layer.provide(home), Layer.provide(store), ) - const identityMigration = ProjectIdentityMigration.layer.pipe(Layer.provide(memoryMigration)) + const identityMigration = ProjectIdentityMigration.layer.pipe( + Layer.provide(memoryMigration), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(home), + ) const project = Project.layer.pipe( Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(ProjectV2.defaultLayer), From 705593101328783af26060030131bbd0e3b8f95c Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 13 Aug 2026 00:29:20 +0800 Subject: [PATCH 17/18] fix(memory): single authority for the memory-identity fence protocol (MEM-PR01 M-J) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 7 converged on one residual P1 (F1) and one P2 (F2), both pre-existing: - F1 (P1): admission.ensure took the memory-identity fence but never re-checked identity liveness inside it. A retirement could complete while admission waited on the fence, and admission would then import legacy topics into the re-created retired Home AND delete the legacy source files (permanent orphaning — the identity cache already points at the successor, so no migration would ever run for the pair again). - F2 (P2): the phase-1 Home rename raced a concurrent newID writer creating the target between existsSafe and rename (ENOTEMPTY), dying fromDirectory. Self-healing (next boot retries into the merge path), no data loss. The identity-race TOCTOU class has now been found in three consecutive review rounds, and the memory-identity protocol (key + lock dir + in-fence liveness recheck) was hand-duplicated at four sites across three modules — exactly why admission could diverge from the writer discipline. Per the redesign rule this is a seam redesign, not a patch: - New MemoryIdentityFence (memory/identity-fence.ts) is the single authority for the protocol: key() builds the lock key, withLiveIdentity() holds the fence on home.locks AND re-checks the identity row inside the fence (None = retired, callers fail closed). A future path cannot forget the recheck. - Writers (prepare/search/checkpoint) and admission route through it; the retirement seam (ProjectIdentityMigration) stays the only raw fence holder (it deletes the row inside the fence) and builds its key from key(). - admission.ensure now fails with a tagged IdentityRetired error when the row is gone; configuration() stays inert, the worktree guard proceeds (the migration is moot after a completed retirement). - F2: rename failure with a target that appeared falls through to the snapshot-merge path; genuine FS failures still rethrow. Verification: Red test MEM-PR01-R7-F1 (ensure after row retirement must not import nor delete the legacy files) confirmed Red before, Green after; mutation removing the in-fence recheck turns it Red again. memory+project 189 pass / 0 fail; typecheck clean; lint flat at 4852. F2's mutation is registered as a test-gap: the race window is between two file ops with no observable state between them, so no deterministic public-seam test exists (the fallback routes into the already-tested merge path). Co-Authored-By: Claude --- packages/opencode/src/memory/admission.ts | 30 +++++- .../opencode/src/memory/identity-fence.ts | 75 +++++++++++++++ .../opencode/src/memory/identity-migration.ts | 10 +- packages/opencode/src/memory/memory.ts | 81 ++++++++--------- .../src/project/identity-migration.ts | 3 +- packages/opencode/src/worktree/index.ts | 17 ++-- .../test/memory/memory-admission.test.ts | 91 ++++++++++++++++++- .../memory/memory-global-identity.test.ts | 2 + .../test/memory/memory-persistence.test.ts | 37 +++++++- packages/opencode/test/memory/memory.test.ts | 4 + 10 files changed, 290 insertions(+), 60 deletions(-) create mode 100644 packages/opencode/src/memory/identity-fence.ts diff --git a/packages/opencode/src/memory/admission.ts b/packages/opencode/src/memory/admission.ts index 7e7fded009..1dabe4ea8d 100644 --- a/packages/opencode/src/memory/admission.ts +++ b/packages/opencode/src/memory/admission.ts @@ -9,6 +9,7 @@ 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" @@ -45,10 +46,20 @@ export class ProjectSnapshot extends Schema.Class("MemoryAdmiss 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 + ) => Effect.Effect readonly invalidate: (projectID: ProjectV2.ID) => Effect.Effect } @@ -70,6 +81,7 @@ export const layer = Layer.effect( 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 @@ -424,10 +436,18 @@ export const layer = Layer.effect( }) const key = JSON.stringify([snapshot.projectID, directories, snapshot.updated]) // Lock order (outermost→innermost): memory-admission → memory-identity → - // memory-project (inside updateTopics). The identity lock serializes the - // import against a concurrent identity retirement renaming the Home. + // 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( - flock.withLock(ensureUnsafe(normalized, key), `memory-identity:${snapshot.projectID}`, home.locks), + 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, ) @@ -449,6 +469,7 @@ export const defaultLayer = layer.pipe( Layer.provide(MemoryConfig.defaultLayer), Layer.provide(MemoryHome.defaultLayer), Layer.provide(MemoryStore.defaultLayer), + Layer.provide(MemoryIdentityFence.defaultLayer), ) export const node = LayerNode.make(layer, [ @@ -457,6 +478,7 @@ export const node = LayerNode.make(layer, [ MemoryConfig.node, MemoryHome.node, MemoryStore.node, + MemoryIdentityFence.node, ]) function same(left: unknown, right: unknown) { 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 index 3ec1decc29..d63d1a0e4c 100644 --- a/packages/opencode/src/memory/identity-migration.ts +++ b/packages/opencode/src/memory/identity-migration.ts @@ -4,7 +4,7 @@ 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, Schema } from "effect" +import { Context, Effect, Exit, Layer, Schema } from "effect" import { dirname, join } from "node:path" import { MemoryHome } from "./home" import { MemorySchema } from "./schema" @@ -117,8 +117,12 @@ export const layer = Layer.effect( if (!(yield* fs.existsSafe(source))) return undefined yield* fs.makeDirectory(dirname(target), { recursive: true }) if (!(yield* fs.existsSafe(target))) { - yield* fs.rename(source, target) - return undefined + 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) diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 968b5cb008..a9a3fe0b3f 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -2,7 +2,6 @@ export * as Memory from "./memory" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProjectV2 } from "@opencode-ai/core/project" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Context, Duration, Effect, Layer, Option, Ref, Schema, Semaphore } from "effect" import { stringify } from "yaml" @@ -14,7 +13,7 @@ import { MessageID, SessionID } from "@/session/schema" import { Token } from "@/util/token" import { MemoryAdmission } from "./admission" import { MemoryConfig } from "./config" -import { MemoryHome } from "./home" +import { MemoryIdentityFence } from "./identity-fence" import { MemoryLock } from "./lock" import { MemoryModel } from "./model" import { MemoryPrompts } from "./prompts" @@ -69,10 +68,9 @@ export const layer: Layer.Layer< | Config.Service | Provider.Service | Project.Service - | EffectFlock.Service | MemoryAdmission.Service | MemoryConfig.Service - | MemoryHome.Service + | MemoryIdentityFence.Service | MemoryLock.Service | MemoryModel.Service | MemoryStore.Service @@ -82,8 +80,7 @@ export const layer: Layer.Layer< const config = yield* Config.Service const provider = yield* Provider.Service const project = yield* Project.Service - const flock = yield* EffectFlock.Service - const home = yield* MemoryHome.Service + const fence = yield* MemoryIdentityFence.Service const admission = yield* MemoryAdmission.Service const configStore = yield* MemoryConfig.Service const lock = yield* MemoryLock.Service @@ -193,12 +190,17 @@ export const layer: Layer.Layer< // 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 - 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, - }) + 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, @@ -364,14 +366,11 @@ export const layer: Layer.Layer< session.firstTurnAttempted = true if (!due && !shouldMatch) return - // Cross-process identity guard (see checkpointUnsafe): re-check identity - // liveness under the identity lock before writing. - yield* flock.withLock( + // 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* () { - if (!(yield* project.get(current.project.id))) { - yield* clearSession(input.sessionID) - return - } yield* lock.withProject(current.project.id)( Effect.gen(function* () { const topics = yield* store.readTopics(current.project.id) @@ -406,9 +405,11 @@ export const layer: Layer.Layer< }), ) }), - `memory-identity:${current.project.id}`, - home.locks, ) + if (Option.isNone(live)) { + yield* clearSession(input.sessionID) + return + } }) const prepare: Interface["prepare"] = Effect.fn("Memory.prepare")((input) => @@ -470,14 +471,11 @@ export const layer: Layer.Layer< } const origin = user.info.id - // Cross-process identity guard (see checkpointUnsafe): re-check identity - // liveness under the identity lock before matching/writing. - return yield* flock.withLock( + // 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* () { - if (!(yield* project.get(current.project.id))) { - yield* clearSession(input.sessionID) - return { status: "unavailable" as const } - } return yield* lock.withProject(current.project.id)( Effect.gen(function* () { const activeTurn = data.sessions.get(input.sessionID)?.turn @@ -509,9 +507,12 @@ export const layer: Layer.Layer< }), ) }), - `memory-identity:${current.project.id}`, - home.locks, ) + 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) => @@ -542,12 +543,9 @@ export const layer: Layer.Layer< // 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). - return yield* flock.withLock( + const live = yield* fence.withLiveIdentity( + current.project.id, Effect.gen(function* () { - if (!(yield* project.get(current.project.id))) { - yield* clearSession(input.sessionID) - return [] - } return yield* lock.withProject(current.project.id)( Effect.gen(function* () { const topics = yield* store.readTopics(current.project.id) @@ -576,9 +574,12 @@ export const layer: Layer.Layer< }), ) }), - `memory-identity:${current.project.id}`, - home.locks, ) + if (Option.isNone(live)) { + yield* clearSession(input.sessionID) + return [] + } + return live.value }) const checkpoint: Interface["checkpoint"] = Effect.fn("Memory.checkpoint")((input) => @@ -641,10 +642,9 @@ export const defaultLayer: Layer.Layer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Project.defaultLayer), - Layer.provide(EffectFlock.defaultLayer), Layer.provide(MemoryAdmission.defaultLayer), Layer.provide(MemoryConfig.defaultLayer), - Layer.provide(MemoryHome.defaultLayer), + Layer.provide(MemoryIdentityFence.defaultLayer), Layer.provide(MemoryLock.defaultLayer), Layer.provide(MemoryModel.defaultLayer), Layer.provide(MemoryStore.defaultLayer), @@ -655,10 +655,9 @@ export const node = LayerNode.make(layer, [ Config.node, Provider.node, Project.node, - EffectFlock.node, MemoryAdmission.node, MemoryConfig.node, - MemoryHome.node, + MemoryIdentityFence.node, MemoryLock.node, MemoryModel.node, MemoryStore.node, diff --git a/packages/opencode/src/project/identity-migration.ts b/packages/opencode/src/project/identity-migration.ts index a56c0b5316..244c40c9f0 100644 --- a/packages/opencode/src/project/identity-migration.ts +++ b/packages/opencode/src/project/identity-migration.ts @@ -5,6 +5,7 @@ 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 { @@ -40,7 +41,7 @@ export const layer = Layer.effect( yield* memory.migrateHome(oldID, newID) yield* retireReferences() }), - `memory-identity:${oldID}`, + MemoryIdentityFence.key(oldID), home.locks, ) .pipe(Effect.orDie, Effect.withSpan("ProjectIdentityMigration.migrate")), diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 1f3c05c1df..1dd75653df 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -480,12 +480,17 @@ export const layer: Layer.Layer< // 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, - }) + 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: the legacy migration is moot + // (the Home moved to the successor) — do not block the operation. + if (!memory) return undefined if (memory.unresolved > 0) return `Cannot continue with unresolved legacy project memory: ${memory.diagnostics .filter((item) => item.code.endsWith(".invalid") || item.code.endsWith(".conflict")) diff --git a/packages/opencode/test/memory/memory-admission.test.ts b/packages/opencode/test/memory/memory-admission.test.ts index 0ab1ca8798..a514313c59 100644 --- a/packages/opencode/test/memory/memory-admission.test.ts +++ b/packages/opencode/test/memory/memory-admission.test.ts @@ -1,13 +1,17 @@ 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 { Duration, Effect, Fiber, Layer } from "effect" +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" @@ -58,19 +62,28 @@ function topic(id: string, summary = `已确认的 ${id} 决策`) { 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) + return Layer.mergeAll(admission, store, MemoryConfig.defaultLayer, database) } describe("MemoryAdmission", () => { @@ -84,6 +97,13 @@ describe("MemoryAdmission", () => { 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, @@ -116,6 +136,12 @@ describe("MemoryAdmission", () => { 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([]) @@ -141,6 +167,12 @@ describe("MemoryAdmission", () => { 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`), @@ -168,9 +200,15 @@ describe("MemoryAdmission", () => { const fullLayers = (root: string) => { const home = Layer.succeed(MemoryHome.Service, MemoryHome.make(root)) const flock = EffectFlock.defaultLayer - const base = Layer.mergeAll(FSUtil.defaultLayer, flock, home, MemoryConfig.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)) + const admission = MemoryAdmission.layer.pipe(Layer.provide(base), Layer.provide(store), Layer.provide(fence)) return Layer.mergeAll(base, store, admission) } @@ -186,6 +224,12 @@ describe("MemoryAdmission", () => { 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") @@ -234,6 +278,12 @@ describe("MemoryAdmission", () => { 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" } @@ -267,4 +317,37 @@ describe("MemoryAdmission", () => { }), { 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 index 9fd429d1e5..2fa507113d 100644 --- a/packages/opencode/test/memory/memory-global-identity.test.ts +++ b/packages/opencode/test/memory/memory-global-identity.test.ts @@ -18,6 +18,7 @@ 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" @@ -112,6 +113,7 @@ const base = Layer.mergeAll( MemoryAdmission.defaultLayer, MemoryConfig.defaultLayer, MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, MemoryLock.defaultLayer, MemoryStore.defaultLayer, Layer.mock(MemoryModel.Service, { diff --git a/packages/opencode/test/memory/memory-persistence.test.ts b/packages/opencode/test/memory/memory-persistence.test.ts index 89635d1b75..33f1dc95e0 100644 --- a/packages/opencode/test/memory/memory-persistence.test.ts +++ b/packages/opencode/test/memory/memory-persistence.test.ts @@ -1,12 +1,16 @@ 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" @@ -86,19 +90,44 @@ function terminologyTopic() { 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) + 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[]) { @@ -304,6 +333,7 @@ describe("Project-owned MEMORY persistence", () => { 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())) @@ -356,6 +386,7 @@ describe("Project-owned MEMORY persistence", () => { 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 }) @@ -503,6 +534,7 @@ describe("Project-owned MEMORY persistence", () => { 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 }) @@ -550,6 +582,7 @@ describe("Project-owned MEMORY persistence", () => { 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 }) @@ -584,6 +617,7 @@ describe("Project-owned MEMORY persistence", () => { 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) @@ -796,6 +830,7 @@ describe("Project-owned MEMORY persistence", () => { 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") diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 34ea968234..eaf2185b04 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -10,6 +10,7 @@ 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" @@ -101,6 +102,7 @@ const unavailableModelIt = testEffect( emptyConfigLayer, EffectFlock.defaultLayer, MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, replacementProvider.layer, Layer.mock(Project.Service, { get: (id) => @@ -197,6 +199,7 @@ function bootstrapFixture() { Layer.mergeAll( EffectFlock.defaultLayer, MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, Layer.mock(Config.Service, { get: () => Effect.succeed({ @@ -328,6 +331,7 @@ function recallFixture() { emptyConfigLayer, EffectFlock.defaultLayer, MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, provider.layer, Layer.mock(Project.Service, { get: (id) => From 580c62438b72e7572c5e9a6e29727ae5f71bbd4f Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 13 Aug 2026 01:20:32 +0800 Subject: [PATCH 18/18] fix(worktree): fail removal closed when the identity retires mid-remove (MEM-PR01 M-K) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 9 product-invariant review found P2-A (introduced by M-J): the reconcileLegacyMemory guard returned undefined on IdentityRetired, so a worktree remove could proceed past the fence and `git worktree remove --force` would destroy legacy .opencode/memory content that was never admitted into any Home. The window sits between removeLocked's own row-liveness check and the admission fence recheck — widened by the WorktreeRemove hook (user scripts) that runs between the two. Fix: on IdentityRetired the guard now returns a blocker message (fail closed), matching the reset path's existing stance. A retry under the successor identity imports the legacy content first and then removes safely. Red-first + mutation evidence: - New test MEM-PR01-R9-P2A (worktree-remove.test.ts): holds the memory-admission flock so the remove blocks inside ensure after its own row check passed, retires the identity row, then releases — asserting the removal fails and the never-admitted legacy file survives. Red before the fix, Green after; reverting the blocker to undefined turns it Red again. Also fixes a standards-P2: reindents the retirement transaction body in project.ts (pure whitespace, no behavior change). Registered, not fixed here (out of PR scope): EffectFlock stale-break can silently lose a cross-process update (P2-B, pre-existing core infra, recorded as a residual for the final audit). Co-Authored-By: Claude --- packages/opencode/src/project/project.ts | 96 +++++++++---------- packages/opencode/src/worktree/index.ts | 9 +- .../test/project/worktree-remove.test.ts | 69 ++++++++++++- 3 files changed, 121 insertions(+), 53 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f114637bb1..e5ae665f94 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -165,57 +165,57 @@ export const layer = Layer.effect( .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) { + 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() - - // 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() + 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() }), diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 1dd75653df..e50b0361de 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -488,9 +488,12 @@ export const layer: Layer.Layer< updated: input.updated, }) .pipe(Effect.catchTag("MemoryAdmission.IdentityRetired", () => Effect.succeed(undefined))) - // The identity was retired concurrently: the legacy migration is moot - // (the Home moved to the successor) — do not block the operation. - if (!memory) return 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")) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index 217920a725..503e6860aa 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -2,9 +2,14 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import * as fs from "fs/promises" import path from "path" -import { Effect, Exit, 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" @@ -12,7 +17,15 @@ import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll(Worktree.defaultLayer, Project.defaultLayer, CrossSpawnSpawner.defaultLayer, MemoryStore.defaultLayer), + 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 @@ -394,4 +407,56 @@ describe("Worktree.remove", () => { }), { 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 }, + ) })