From 70291fbe4b6c953dc2adf6cfbc0ca932ca9ca7e0 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:20:23 +0800 Subject: [PATCH 1/7] fix(memory): matcher calls run outside the fence, coalescing via in-flight deferreds (MEM-01/MEM-02 acceptance) --- docs/findings/memory-batch-findings.md | 31 +++ packages/opencode/src/memory/CONTEXT.md | 2 +- packages/opencode/src/memory/memory.ts | 223 +++++++++++-------- packages/opencode/test/memory/memory.test.ts | 89 ++++++++ 4 files changed, 256 insertions(+), 89 deletions(-) create mode 100644 docs/findings/memory-batch-findings.md diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md new file mode 100644 index 000000000..218564bae --- /dev/null +++ b/docs/findings/memory-batch-findings.md @@ -0,0 +1,31 @@ +# MEMORY 批次(验收遗留)Findings Register + +- 验收 primary source:`docs/audit-dag-memory-goal-2026-08-18.md`(MEMORY 章节)+ 产品→代码验收(f1c2c8c33→11cfafe9c)Spec 轴三项遗留 +- 分支:`fix/memory-fence-scope` → PR `dev` +- 收敛判据:连续两轮独立审阅(Spec 镜 + Standards 镜)零 findings + 模块门禁全绿 +- 规格:`workflows/audit-fix-loop.md` + +## 处置项 + +| ID | 来源 | 处置 | 状态 | 提交 | +|---|---|---|---|---| +| MEM-01 后半(P1) | 审计 + 验收 (a)2 | prepareUnsafe `shouldMatch` 分支的 select matcher 移出 fence/lock:matcher 无锁跑,markMatched 经 `applyUpdate`(fence+lock 只包提交) | 完成(红-绿-变异通过) | 待提交 | +| MEM-02(P2) | 审计 + 验收 (a)1 | `search` 的 identity fence 缩到 markMatched 提交;同查询合并从「持锁阻塞后来者」改为 per-key in-flight coalescing(进程内 Deferred),语义等价(后来者 reused:true、不耗 slot)且不再跨模型调用持 fence/lock | 完成(红-绿-变异通过;旧 coalescing 回归保持绿) | 待提交 | +| MEM-03(P3) | 验收 (a)3 | 处置记录:已被 PR #333 的 MEM-01 重构结构性抵消——维护恒为后台(kickMaintenance),失败折入其 catchCause,维护前渲染是已声明设计(CONTEXT.md「render the pre-maintenance snapshot」)。旧的「维护前快照渲染注入」窗口随 inline maintain 一起消失。本行即处置记录。 | 已记录 | — | + +## 设计要点 + +- `applyUpdate` 泛型化:`Update` 透传结果,select 的 markMatched 提交经它返回 matched topics(供 render)。 +- `select` 拆两半:`match()`(无 fence/lock)+ markMatched 提交(`applyUpdate`)。 +- `search`:短临界区(进程内 lock)只做缓存 re-read/limit/queryCount++ 与 in-flight 登记;matcher 在任何 fence/lock 之外;同 key 后来者 await in-flight Deferred(进程内合并,语义与旧「锁内阻塞」等价);提交走 `applyUpdate`。 +- `prepareUnsafe` shouldMatch 分支:matcher 出 fence/lock;identity retired(applyUpdate 返回 None)时仍 clearSession(fail-closed 不变)。 +- CONTEXT.md 最后一条 invariant 改写:fence 只包提交;同查询合并显式声明为进程内 in-flight coalescing。 +- 锁序不变量:全程不出现 lock 内嵌 fence(KeyedMutex 不可重入 + 与 checkpoint 的 fence>lock 序相反会死锁)。 + +## 模块门禁 +- 未开始 + +## 审阅轮次 + +### Round 1 +- 未开始 diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index 450cf6a30..7a628344b 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -52,7 +52,7 @@ The domain runs on the existing seams; the elaborate `ProjectMemoryAuthority` re - 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. - Maintenance model calls never run under the identity fence or the project lock: prepare and checkpoint render the pre-maintenance snapshot, then kick maintenance in the background, gated on identity liveness so a retired identity never starts a job (one job in flight per project, the commit-only write back under the fence). -- Bounded matcher calls deliberately hold the fence across one model call: the search matcher to coalesce concurrent identical queries, the prepare and checkpoint matchers because their match result feeds an atomic read-match-write under the project lock. Only unbounded-class work (maintenance) is excluded from the fence; a bounded matcher is at most one call per fence acquisition. +- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence/lock, and only the markMatched commit acquires them (`applyUpdate`). Concurrent identical search queries coalesce through a process-local per-(session,key) in-flight Deferred — the second caller awaits the first caller's result (reused, no extra query slot) instead of blocking a lock across the model call. ## Boundaries diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 564a5de77..691adfd4e 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -3,7 +3,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, Effect, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" +import { Context, Deferred, Effect, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" import { stringify } from "yaml" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" @@ -93,6 +93,16 @@ export const layer: Layer.Layer< const state = yield* InstanceState.make(() => Effect.succeed({ sessions: new Map() })) const scope = yield* Scope.Scope const maintenanceInFlight = yield* Ref.make(new Set()) + // MEM-02: per-(session,key) in-flight matcher registrations. Replaces the + // old "hold the fence/lock across the model call so the second caller + // blocks and re-reads the cache" coalescing: the second identical query + // now awaits the first one's Deferred instead — same observable semantics + // (reused: true, no extra query slot) without a model call under the + // fence/lock. Process-local by design (the fence is the cross-process + // seam, and it now covers only the markMatched commit). + const matchInFlight = yield* Ref.make( + new Map>(), + ) const availableModels = Effect.fn("Memory.availableModels")(function* () { const providers = yield* provider.list() @@ -283,7 +293,12 @@ export const layer: Layer.Layer< // Serialize the identity-liveness recheck and the per-project lock around // the store write only; the model calls that produce the update run // outside the fence/lock so a long reasoning call cannot wedge or leak it. - const applyUpdate = (projectID: ProjectV2.ID, update: (topics: MemorySchema.Topic[]) => MemoryStore.Update) => + // The update callback's result is passed through, so a commit like + // markMatched can hand the caller the post-commit topics to render. + const applyUpdate = ( + projectID: ProjectV2.ID, + update: (topics: MemorySchema.Topic[]) => MemoryStore.Update, + ) => fence.withLiveIdentity( projectID, lock.withProject(projectID)(store.updateTopics(projectID, update)), @@ -328,6 +343,9 @@ export const layer: Layer.Layer< return decoded.value.actions }) + // MEM-01/02: the matcher model call runs OUTSIDE the fence/lock; only the + // markMatched commit acquires them (applyUpdate). The matched topics come + // back from the commit for rendering. const select = Effect.fn("Memory.select")(function* (input: { model: Provider.Model config: MemorySchema.Config @@ -336,11 +354,20 @@ export const layer: Layer.Layer< projectID: Project.Info["id"] }) { const topicIDs = yield* match(input) - 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 committed = yield* applyUpdate(input.projectID, (topics) => { + // Re-filter against the post-read topics: the matcher filtered on the + // snapshot it saw; a topic deleted since then must not resurrect. + const live = new Set(topics.map((topic) => topic.id)) + return { + applied: MemoryStore.markMatched(topics, topicIDs.filter((id) => live.has(id))), + result: undefined as void, + } + }) + // Identity retired between the model call and the commit: nothing was + // written; there is no matched set to render. + if (Option.isNone(committed)) return undefined + const matched = committed.value.topics + const byID = new Map(matched.map((topic) => [topic.id, topic])) const selected = topicIDs.flatMap((id) => { const topic = byID.get(id) return topic ? [topic] : [] @@ -470,33 +497,30 @@ export const layer: Layer.Layer< return } - // The fence and the project lock cover the topic read plus the bounded - // first-turn matcher (declared tradeoff, see CONTEXT.md). Due maintenance - // is kicked AFTER the fence releases, so a long reasoning call never - // holds it: this turn renders the pre-maintenance topics and the - // committed update surfaces on a later prepare. - const live = yield* fence.withLiveIdentity( - current.project.id, - lock.withProject(current.project.id)( - Effect.gen(function* () { - const topics = yield* store.readTopics(current.project.id) - const rendered = (yield* select({ - model: current.model, - config: current.loaded.config, - topics, - text: user.text, - projectID: current.project.id, - })).rendered - const entry = data.sessions.get(input.sessionID) - if (entry?.turn.messageID !== user.info.id) return - entry.turn = { ...entry.turn, completedTurns: turns, rendered } - }), - ), - ) - if (Option.isNone(live)) { + // MEM-01: the first-turn matcher runs OUTSIDE the fence/lock; only its + // markMatched commit acquires them (inside select → applyUpdate). An + // identity retired mid-call surfaces as select === undefined — fail + // closed by dropping the cached session state. Due maintenance is + // kicked AFTERwards, so a long reasoning call never holds the fence: + // this turn renders the pre-maintenance topics and the committed update + // surfaces on a later prepare. + const rendered = yield* select({ + model: current.model, + config: current.loaded.config, + topics: yield* store.readTopics(current.project.id), + text: user.text, + projectID: current.project.id, + }) + if (!rendered) { yield* clearSession(input.sessionID) return } + { + const entry = data.sessions.get(input.sessionID) + if (entry?.turn.messageID === user.info.id) { + entry.turn = { ...entry.turn, completedTurns: turns, rendered: rendered.rendered } + } + } if (!due) return if (Option.isNone(yield* kickMaintenance(maintenance))) yield* clearSession(input.sessionID) }) @@ -559,51 +583,78 @@ export const layer: Layer.Layer< } const origin = user.info.id - // Declared tradeoff (issue #324, see CONTEXT.md): unlike maintenance, the - // matcher model call runs INSIDE the fence/lock. That serialization is - // what coalesces concurrent identical queries — the second caller blocks, - // re-reads `queries` under the lock, and reuses the first result instead - // of spending another model call. The lock also covers markMatched. - const live = yield* fence.withLiveIdentity( - current.project.id, + // MEM-02 (issue #324 acceptance): the matcher model call runs OUTSIDE + // the fence/lock. Concurrent identical queries coalesce through the + // per-(session,key) in-flight Deferred instead of lock-blocking: the + // second caller re-checks the cache under a SHORT project-lock + // critical section, awaits the first caller's result, and reports + // reused without spending another model call or query slot. Only the + // markMatched commit (inside select → applyUpdate) acquires the + // fence/lock. + const inFlightKey = `${input.sessionID}\0${key}` + const deferred = yield* Deferred.make<{ count: number; rendered: string[] }>() + + const outcome = yield* lock.withProject(current.project.id)( Effect.gen(function* () { - 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 } - }), + 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 } + const running = yield* Ref.modify(matchInFlight, (map) => + map.has(inFlightKey) + ? ([map.get(inFlightKey)!, map] as const) + : ([deferred, new Map(map).set(inFlightKey, deferred)] as const), ) + if (running !== deferred) return { kind: "await-first" as const, first: running } + activeTurn.queryCount++ + return { kind: "run" as const } }), ) - if (Option.isNone(live)) { - yield* clearSession(input.sessionID) - return { status: "unavailable" as const } + if ("kind" in outcome) { + if (outcome.kind === "await-first") { + const first = yield* Deferred.await(outcome.first) + const latest = data.sessions.get(input.sessionID)?.turn + if (latest?.messageID !== origin) return { status: "stale" as const } + return first.count > 0 + ? { status: "attached" as const, count: first.count, reused: true } + : { status: "empty" as const, reused: true } + } + // This caller owns the matcher run: model call outside every + // fence/lock, then the fenced markMatched commit, then publish. + const selected = yield* select({ + model: current.model, + config: current.loaded.config, + topics: yield* store.readTopics(current.project.id), + text: query, + projectID: current.project.id, + }) + yield* Ref.update(matchInFlight, (map) => { + if (map.get(inFlightKey) !== deferred) return map + const next = new Map(map) + next.delete(inFlightKey) + return next + }) + // Identity retired between model call and commit — fail closed. + if (!selected) { + yield* clearSession(input.sessionID) + return { status: "unavailable" as const } + } + yield* Deferred.succeed(deferred, selected) + 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 live.value + return outcome }) const search: Interface["search"] = Effect.fn("Memory.search")((input) => @@ -627,22 +678,18 @@ export const layer: Layer.Layer< return [] } const user = latestRealUser(input.messages) - const live = yield* fence.withLiveIdentity( - current.project.id, - lock.withProject(current.project.id)( - Effect.gen(function* () { - const topics = yield* store.readTopics(current.project.id) - return (yield* select({ - model: current.model, - config: current.loaded.config, - topics, - text: user?.text ?? "", - projectID: current.project.id, - })).rendered - }), - ), - ) - if (Option.isNone(live)) { + // MEM-01: the render matcher runs OUTSIDE the fence/lock; its + // markMatched commit is fenced inside select → applyUpdate. An identity + // retired mid-call (select === undefined) fails closed to an empty + // render, same as the retired-fence outcome before. + const selected = yield* select({ + model: current.model, + config: current.loaded.config, + topics: yield* store.readTopics(current.project.id), + text: user?.text ?? "", + projectID: current.project.id, + }) + if (!selected) { yield* clearSession(input.sessionID) return [] } @@ -657,7 +704,7 @@ export const layer: Layer.Layer< projectID: current.project.id, }) if (Option.isNone(kicked)) yield* clearSession(input.sessionID) - return live.value + return selected.rendered }) const checkpoint: Interface["checkpoint"] = Effect.fn("Memory.checkpoint")((input) => diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 182503e44..3689dd192 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -1382,6 +1382,95 @@ describe("memory turn-scoped retrieval", () => { }), { git: true }, ) + + // MEM-02 follow-up (acceptance): the cross-process identity fence must not + // be held across the SEARCH matcher model call — only the markMatched + // commit is fenced. While the matcher is parked mid-call, a concurrent + // checkpoint (whose render select needs the same identity fence) must not + // starve. Under the old shape the fence wrapped the whole lock block, so + // the checkpoint waited on the streaming matcher. + recall.it.instance( + "keeps the identity fence free while the search matcher streams", + () => + Effect.gen(function* () { + recall.reset() + const started = yield* Deferred.make() + const release = yield* Deferred.make() + // Only the SLOW search query parks; the checkpoint's own render match + // must sail through — that is the assertion. + recall.state.matcher = (query) => + query === "慢架构查询" + ? Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + return { topic_ids: [recall.state.topics[0]?.id ?? ""] } + }) + : Effect.succeed({ topic_ids: [recall.state.topics[0]?.id ?? ""] }) + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_search_fence_free") + const messages = [ + user(MessageID.ascending(), sessionID, "先处理当前问题"), + user(MessageID.ascending(), sessionID, "召回相关历史"), + ] + + const pending = yield* memory.search({ sessionID, messages, query: "慢架构查询" }).pipe(Effect.forkChild) + yield* Deferred.await(started) + + const rendered = yield* awaitWithTimeout( + memory.checkpoint({ sessionID, messages }), + "checkpoint starved by the search matcher — fence held across the model call (MEM-02)", + ) + expect(rendered.length).toBeGreaterThan(0) + + yield* Deferred.succeed(release, undefined) + expect(yield* Fiber.join(pending)).toEqual({ status: "attached", count: 1, reused: false }) + }), + { git: true }, + ) + + // MEM-01 follow-up (acceptance): same discipline for the prepare + // first-turn (shouldMatch) branch — the bounded matcher moved out of the + // fence/lock, so a parked first-turn matcher cannot starve the fence. + recall.it.instance( + "keeps the identity fence free while the first-turn prepare matcher streams", + () => + Effect.gen(function* () { + recall.reset() + const started = yield* Deferred.make() + const release = yield* Deferred.make() + // Only the FIRST matcher call (the first-turn prepare) parks; the + // concurrent checkpoint's render match — same text, so query-based + // discrimination is impossible — must sail through on its own call. + let matcherCalls = 0 + recall.state.matcher = () => + Effect.gen(function* () { + matcherCalls++ + if (matcherCalls > 1) return { topic_ids: [recall.state.topics[0]?.id ?? ""] } + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + return { topic_ids: [recall.state.topics[0]?.id ?? ""] } + }) + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_prepare_fence_free") + const messages = [user(MessageID.ascending(), sessionID, "首次真实用户输入关于架构")] + + const pending = yield* memory.prepare({ sessionID, messages }).pipe(Effect.forkChild) + yield* Deferred.await(started) + + const rendered = yield* awaitWithTimeout( + memory.checkpoint({ sessionID, messages }), + "checkpoint starved by the first-turn prepare matcher — fence held across the model call (MEM-01)", + ) + expect(rendered.length).toBeGreaterThan(0) + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(pending) + // Both the first-turn prepare and the concurrent checkpoint render + // matched the same text; each recorded exactly its own call. + expect(recall.state.queries).toEqual(["首次真实用户输入关于架构", "首次真实用户输入关于架构"]) + }), + { git: true }, + ) }) describe("memory project config Git exclusions", () => { From f08548d6d169bb634628d4649918ceb6e83a0fb5 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:35:24 +0800 Subject: [PATCH 2/7] fix(memory): in-flight matcher registration is exit-safe and publishes before release (review R1) --- docs/findings/memory-batch-findings.md | 9 ++ packages/opencode/src/memory/CONTEXT.md | 2 +- packages/opencode/src/memory/memory.ts | 111 +++++++++++++------ packages/opencode/test/memory/memory.test.ts | 48 ++++++++ 4 files changed, 133 insertions(+), 37 deletions(-) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index 218564bae..d23174f32 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -28,4 +28,13 @@ ## 审阅轮次 ### Round 1 +- Spec 镜:**BLOCKING**(R-1 P1 + R-2/R-3 INFO);Standards 镜:**BLOCKING**(F1 P1 与 R-1 同源 + F2-F5 INFO)。处置: + - R-1/F1(P1):in-flight 泄漏——失败/中断/retired 路径 deferred 永不完成 → coalesced awaiter 永久挂起、(session,key) 进程级 wedge。→ **已修**:runner 分支 `Effect.onExit`(对齐 kickMaintenance 槽位纪律)——每个退出路径先 `releaseIfOwner` 再把真实 Exit 打包进 deferred(deferred 永不失败,Exit 载荷即全部消息);awaiter 按 Exit 分支:失败→`failed`、interrupt→failCause 传播、retired→`unavailable`。新增红绿测试「a failed first query never wedges the session key or its coalesced awaiter」(有界等待断言无挂起、无 wedge)+ 变异验证(去掉 onExit → 新测试与旧 coalescing 测试双红)。 + - R-2(INFO):dereg→缓存写入窗口内第三个同查询会多耗一次调用。→ 已修:缓存写入提前到 `Effect.tap`(releaseIfOwner 之前),窗口闭合。 + - R-3(INFO):等价声明只覆盖 happy path。→ CONTEXT.md 措辞已含失败路径降级语义。 + - F2(INFO):CONTEXT.md「outside every fence/lock」对短注册临界区不真。→ 已改为「outside every fence + SHORT project-lock critical section (registration)」。 + - F3/F4/F5(INFO):裸块、`!` 断言、命名不一致。→ 已修(裸块展开、get-then-check、统一 `selected`)。 +- 结论:修复后进入 Round 2。 + +### Round 2 - 未开始 diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index 7a628344b..4625e4ebb 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -52,7 +52,7 @@ The domain runs on the existing seams; the elaborate `ProjectMemoryAuthority` re - 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. - Maintenance model calls never run under the identity fence or the project lock: prepare and checkpoint render the pre-maintenance snapshot, then kick maintenance in the background, gated on identity liveness so a retired identity never starts a job (one job in flight per project, the commit-only write back under the fence). -- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence/lock, and only the markMatched commit acquires them (`applyUpdate`). Concurrent identical search queries coalesce through a process-local per-(session,key) in-flight Deferred — the second caller awaits the first caller's result (reused, no extra query slot) instead of blocking a lock across the model call. +- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence, and only the markMatched commit acquires the fence (`applyUpdate`). Search keeps a SHORT project-lock critical section (stale/cache/limit check + in-flight registration); concurrent identical queries coalesce through a process-local per-(session,key) in-flight Deferred — the second caller awaits the first caller's exit (reused, no extra query slot) and degrades to `failed`/`unavailable` when the first call fails or its identity retires, instead of blocking a lock across the model call. ## Boundaries diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 691adfd4e..09693f643 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -3,7 +3,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, Deferred, Effect, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" +import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" import { stringify } from "yaml" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" @@ -98,12 +98,15 @@ export const layer: Layer.Layer< // blocks and re-reads the cache" coalescing: the second identical query // now awaits the first one's Deferred instead — same observable semantics // (reused: true, no extra query slot) without a model call under the - // fence/lock. Process-local by design (the fence is the cross-process - // seam, and it now covers only the markMatched commit). + // fence/lock. The runner completes the deferred on EVERY exit (onExit), + // so a failed or interrupted first call wakes its coalesced awaiter + // (degraded to "failed") instead of parking it forever. Process-local by + // design (the fence is the cross-process seam, and it now covers only + // the markMatched commit). + type MatchRun = { count: number; rendered: string[] } const matchInFlight = yield* Ref.make( - new Map>(), + new Map>>(), ) - const availableModels = Effect.fn("Memory.availableModels")(function* () { const providers = yield* provider.list() return new Set( @@ -504,22 +507,20 @@ export const layer: Layer.Layer< // kicked AFTERwards, so a long reasoning call never holds the fence: // this turn renders the pre-maintenance topics and the committed update // surfaces on a later prepare. - const rendered = yield* select({ + const selected = yield* select({ model: current.model, config: current.loaded.config, topics: yield* store.readTopics(current.project.id), text: user.text, projectID: current.project.id, }) - if (!rendered) { + if (!selected) { yield* clearSession(input.sessionID) return } - { - const entry = data.sessions.get(input.sessionID) - if (entry?.turn.messageID === user.info.id) { - entry.turn = { ...entry.turn, completedTurns: turns, rendered: rendered.rendered } - } + const entry = data.sessions.get(input.sessionID) + if (entry?.turn.messageID === user.info.id) { + entry.turn = { ...entry.turn, completedTurns: turns, rendered: selected.rendered } } if (!due) return if (Option.isNone(yield* kickMaintenance(maintenance))) yield* clearSession(input.sessionID) @@ -587,12 +588,21 @@ export const layer: Layer.Layer< // the fence/lock. Concurrent identical queries coalesce through the // per-(session,key) in-flight Deferred instead of lock-blocking: the // second caller re-checks the cache under a SHORT project-lock - // critical section, awaits the first caller's result, and reports - // reused without spending another model call or query slot. Only the - // markMatched commit (inside select → applyUpdate) acquires the - // fence/lock. + // critical section (stale/cache/limit check + registration), awaits + // the first caller's result, and reports reused without spending + // another model call or query slot. Only the markMatched commit + // (inside select → applyUpdate) acquires the fence. const inFlightKey = `${input.sessionID}\0${key}` - const deferred = yield* Deferred.make<{ count: number; rendered: string[] }>() + const deferred = yield* Deferred.make>() + + const releaseIfOwner = Effect.fnUntraced(function* () { + yield* Ref.update(matchInFlight, (map) => { + if (map.get(inFlightKey) !== deferred) return map + const next = new Map(map) + next.delete(inFlightKey) + return next + }) + }) const outcome = yield* lock.withProject(current.project.id)( Effect.gen(function* () { @@ -606,11 +616,11 @@ export const layer: Layer.Layer< : { status: "empty" as const, reused: true } } if (activeTurn.queryCount >= 2) return { status: "limit" as const } - const running = yield* Ref.modify(matchInFlight, (map) => - map.has(inFlightKey) - ? ([map.get(inFlightKey)!, map] as const) - : ([deferred, new Map(map).set(inFlightKey, deferred)] as const), - ) + const running = yield* Ref.modify(matchInFlight, (map) => { + const existing = map.get(inFlightKey) + if (existing) return [existing, map] as const + return [deferred, new Map(map).set(inFlightKey, deferred)] as const + }) if (running !== deferred) return { kind: "await-first" as const, first: running } activeTurn.queryCount++ return { kind: "run" as const } @@ -618,38 +628,67 @@ export const layer: Layer.Layer< ) if ("kind" in outcome) { if (outcome.kind === "await-first") { + // The awaiter rides the runner's exit: the runner completes the + // deferred on EVERY exit (onExit below packs the exit — success, + // failure, interrupt, retired — into the payload), so the await + // always wakes. Failure/interrupt degrades to "failed", never a + // permanent park on a doomed deferred. const first = yield* Deferred.await(outcome.first) + if (Exit.isFailure(first)) { + if (Cause.hasInterrupts(first.cause)) return yield* Effect.failCause(first.cause) + return { status: "failed" as const } + } + const selected = first.value + if (!selected) return { status: "unavailable" as const } const latest = data.sessions.get(input.sessionID)?.turn if (latest?.messageID !== origin) return { status: "stale" as const } - return first.count > 0 - ? { status: "attached" as const, count: first.count, reused: true } + return selected.count > 0 + ? { status: "attached" as const, count: selected.count, reused: true } : { status: "empty" as const, reused: true } } // This caller owns the matcher run: model call outside every // fence/lock, then the fenced markMatched commit, then publish. - const selected = yield* select({ + // onExit mirrors kickMaintenance's slot discipline (its comment: "an + // interruption between the two would leak the in-flight slot"): + // every exit deregisters the map entry AND packs the real outcome + // into the deferred, so a coalesced awaiter wakes instead of parking + // forever. The deferred itself never fails — the Exit payload is the + // whole message. + const runExit = yield* select({ model: current.model, config: current.loaded.config, topics: yield* store.readTopics(current.project.id), text: query, projectID: current.project.id, - }) - yield* Ref.update(matchInFlight, (map) => { - if (map.get(inFlightKey) !== deferred) return map - const next = new Map(map) - next.delete(inFlightKey) - return next - }) - // Identity retired between model call and commit — fail closed. + }).pipe( + Effect.tap((selected) => { + // Publish the cache entry BEFORE the in-flight deregistration in + // onExit: a third identical caller entering between the two + // would otherwise miss both the cache and the in-flight entry + // and burn a second model call + query slot where the old + // lock-blocking design guaranteed reuse. Stale-origin runs skip + // the write; the stale check below still governs the response. + if (!selected) return Effect.void + const latest = data.sessions.get(input.sessionID)?.turn + if (latest?.messageID !== origin) return Effect.void + latest.queries.set(key, selected) + latest.rendered = selected.rendered + return Effect.void + }), + Effect.onExit((exit) => releaseIfOwner().pipe(Effect.andThen(Deferred.succeed(deferred, exit)))), + Effect.exit, + ) + if (Exit.isFailure(runExit)) return yield* Effect.failCause(runExit.cause) + const selected = runExit.value + // Identity retired between model call and commit — fail closed. The + // deferred already carries the same (succeeded-undefined) exit, so a + // coalesced awaiter degrades to "unavailable" rather than hanging. if (!selected) { yield* clearSession(input.sessionID) return { status: "unavailable" as const } } - yield* Deferred.succeed(deferred, selected) 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 } diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 3689dd192..9ae80b482 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -1471,6 +1471,54 @@ describe("memory turn-scoped retrieval", () => { }), { git: true }, ) + + // Review R-1: the runner must complete the in-flight deferred on EVERY + // exit. A failed first matcher call must not wedge the (session,key): a + // coalesced awaiter wakes (degraded) and a later identical query re-runs + // the matcher instead of parking on a leaked in-flight entry. + recall.it.instance( + "a failed first query never wedges the session key or its coalesced awaiter", + () => + Effect.gen(function* () { + recall.reset() + const started = yield* Deferred.make() + let matcherCalls = 0 + recall.state.matcher = () => + Effect.gen(function* () { + matcherCalls++ + if (matcherCalls === 1) { + yield* Deferred.succeed(started, undefined) + throw new Error("matcher exploded") + } + return { topic_ids: [recall.state.topics[0]?.id ?? ""] } + }) + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_failed_first") + const messages = [ + user(MessageID.ascending(), sessionID, "先处理当前问题"), + user(MessageID.ascending(), sessionID, "召回相关历史"), + ] + + const failing = yield* memory.search({ sessionID, messages, query: "易碎查询" }).pipe(Effect.forkChild) + yield* Deferred.await(started) + // Concurrent identical query — either it coalesced onto the failing + // run (wakes degraded to "failed") or it raced past the in-flight + // window and re-runs (attached). Both are non-hang outcomes; a + // parked-forever awaiter or a wedged key fails the bounded waits. + const coalesced = yield* memory.search({ sessionID, messages, query: "易碎查询" }).pipe(Effect.forkChild) + const failingResult = yield* awaitWithTimeout(Fiber.join(failing), "failing query hung") + const coalescedResult = yield* awaitWithTimeout(Fiber.join(coalesced), "coalesced awaiter hung on the failing run") + expect(failingResult).toEqual({ status: "failed" }) + expect(["failed", "attached", "empty"]).toContain(coalescedResult.status) + // The (session,key) is NOT wedged: the next identical query resolves + // within the bounded window (fresh run or cached reuse from the + // coalesced fiber's successful re-run — either proves liveness). + expect(yield* awaitWithTimeout(memory.search({ sessionID, messages, query: "易碎查询" }), "later identical query wedged on the leaked in-flight entry")).toMatchObject({ + status: "attached", + }) + }), + { git: true }, + ) }) describe("memory project config Git exclusions", () => { From ef8d6b8d1745ec7114087e8084c723b38a78fe30 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:45:54 +0800 Subject: [PATCH 3/7] fix(memory): bracket the whole runner tail from registration, turn-scoped in-flight key (review R2) --- packages/opencode/src/memory/memory.ts | 72 +++++++++++--------- packages/opencode/test/memory/memory.test.ts | 45 +++++++++++- 2 files changed, 85 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 09693f643..e4d9fdd2e 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -93,16 +93,19 @@ export const layer: Layer.Layer< const state = yield* InstanceState.make(() => Effect.succeed({ sessions: new Map() })) const scope = yield* Scope.Scope const maintenanceInFlight = yield* Ref.make(new Set()) - // MEM-02: per-(session,key) in-flight matcher registrations. Replaces the - // old "hold the fence/lock across the model call so the second caller - // blocks and re-reads the cache" coalescing: the second identical query - // now awaits the first one's Deferred instead — same observable semantics - // (reused: true, no extra query slot) without a model call under the - // fence/lock. The runner completes the deferred on EVERY exit (onExit), - // so a failed or interrupted first call wakes its coalesced awaiter - // (degraded to "failed") instead of parking it forever. Process-local by - // design (the fence is the cross-process seam, and it now covers only - // the markMatched commit). + // MEM-02: per-(session,turn,key) in-flight matcher registrations — the + // turn origin (messageID) in the key keeps coalescing turn-scoped, so a + // new turn's identical query re-runs instead of riding a previous turn's + // result that could never populate its cache. Replaces the old "hold the + // fence/lock across the model call so the second caller blocks and + // re-reads the cache" coalescing: the second identical query now awaits + // the first one's Deferred instead — same observable semantics (reused: + // true, no extra query slot) without a model call under the fence/lock. + // The runner brackets EVERYTHING after registration in an exit guard + // (onExit), so a failed or interrupted first call wakes its coalesced + // awaiter (degraded to "failed") instead of parking it forever. + // Process-local by design (the fence is the cross-process seam, and it + // now covers only the markMatched commit). type MatchRun = { count: number; rendered: string[] } const matchInFlight = yield* Ref.make( new Map>>(), @@ -363,7 +366,7 @@ export const layer: Layer.Layer< const live = new Set(topics.map((topic) => topic.id)) return { applied: MemoryStore.markMatched(topics, topicIDs.filter((id) => live.has(id))), - result: undefined as void, + result: undefined, } }) // Identity retired between the model call and the commit: nothing was @@ -592,7 +595,7 @@ export const layer: Layer.Layer< // the first caller's result, and reports reused without spending // another model call or query slot. Only the markMatched commit // (inside select → applyUpdate) acquires the fence. - const inFlightKey = `${input.sessionID}\0${key}` + const inFlightKey = `${input.sessionID}\0${origin}\0${key}` const deferred = yield* Deferred.make>() const releaseIfOwner = Effect.fnUntraced(function* () { @@ -628,11 +631,13 @@ export const layer: Layer.Layer< ) if ("kind" in outcome) { if (outcome.kind === "await-first") { - // The awaiter rides the runner's exit: the runner completes the - // deferred on EVERY exit (onExit below packs the exit — success, - // failure, interrupt, retired — into the payload), so the await - // always wakes. Failure/interrupt degrades to "failed", never a - // permanent park on a doomed deferred. + // The awaiter rides the runner's exit: the runner's exit bracket + // packs every outcome — success, failure, interrupt, retired — + // into the deferred payload, so the await always wakes. Failures + // surface as this caller's "failed" (mapped by the search + // wrapper's catchCause); the runner's interrupt is re-raised here + // via failCause (the awaiter shares the cancellation); a retired + // identity degrades to "unavailable". Never a permanent park. const first = yield* Deferred.await(outcome.first) if (Exit.isFailure(first)) { if (Cause.hasInterrupts(first.cause)) return yield* Effect.failCause(first.cause) @@ -646,20 +651,25 @@ export const layer: Layer.Layer< ? { status: "attached" as const, count: selected.count, reused: true } : { status: "empty" as const, reused: true } } - // This caller owns the matcher run: model call outside every - // fence/lock, then the fenced markMatched commit, then publish. - // onExit mirrors kickMaintenance's slot discipline (its comment: "an - // interruption between the two would leak the in-flight slot"): - // every exit deregisters the map entry AND packs the real outcome - // into the deferred, so a coalesced awaiter wakes instead of parking - // forever. The deferred itself never fails — the Exit payload is the - // whole message. - const runExit = yield* select({ - model: current.model, - config: current.loaded.config, - topics: yield* store.readTopics(current.project.id), - text: query, - projectID: current.project.id, + // This caller owns the matcher run. EVERYTHING after registration — + // the topics read and the select pipeline (model call, fenced + // markMatched commit) — runs inside one exit bracket: onExit fires + // on success, failure, interrupt, and identity-retired alike, + // deregistering the map entry and packing the real exit into the + // deferred so a coalesced awaiter wakes instead of parking forever + // (kickMaintenance's slot discipline, applied from the moment the + // entry exists — an interrupt during the topics read would otherwise + // unwind before the bracket attaches and wedge the (turn,key) + // forever). The deferred itself never fails — the Exit payload is + // the whole message. + const runExit = yield* Effect.gen(function* () { + return yield* select({ + model: current.model, + config: current.loaded.config, + topics: yield* store.readTopics(current.project.id), + text: query, + projectID: current.project.id, + }) }).pipe( Effect.tap((selected) => { // Publish the cache entry BEFORE the in-flight deregistration in diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 9ae80b482..a1adfed29 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -318,6 +318,8 @@ function recallFixture() { projectInitialized: number matcher?: (query: string) => Effect.Effect maintenanceHook?: () => Effect.Effect + /** Parks the runner's pre-select topics read (interrupt-window probe). */ + parkReads?: { started: Deferred.Deferred; release: Deferred.Deferred } } = { queries: [], reads: 0, @@ -376,8 +378,12 @@ function recallFixture() { MemoryLock.defaultLayer, Layer.mock(MemoryStore.Service, { readTopics: () => - Effect.sync(() => { + Effect.gen(function* () { state.reads++ + if (state.parkReads) { + yield* Deferred.succeed(state.parkReads.started, undefined) + yield* Deferred.await(state.parkReads.release) + } return state.topics }), updateTopics: (_projectID, update) => @@ -413,6 +419,7 @@ function recallFixture() { state.projectInitialized = 1 state.matcher = undefined state.maintenanceHook = undefined + state.parkReads = undefined }, it: testEffect(layer), systemIt: testEffect(systemLayer), @@ -1519,6 +1526,42 @@ describe("memory turn-scoped retrieval", () => { }), { git: true }, ) + + // Review R2 issue 1: the exit bracket must start at REGISTRATION, not at + // the select pipeline — an interrupt during the runner's pre-select topics + // read (a real suspension in production, flock'd disk I/O) previously + // unwound before onExit attached, leaking the in-flight entry and wedging + // the (turn,key) forever. + recall.it.instance( + "an interrupted topics read releases the in-flight entry and never wedges the key", + () => + Effect.gen(function* () { + recall.reset() + const memory = yield* Memory.Service + const sessionID = SessionID.make("ses_memory_interrupted_read") + const messages = [ + user(MessageID.ascending(), sessionID, "先处理当前问题"), + user(MessageID.ascending(), sessionID, "召回相关历史"), + ] + const started = yield* Deferred.make() + const release = yield* Deferred.make() + recall.state.parkReads = { started, release } + + const runner = yield* memory.search({ sessionID, messages, query: "可中断查询" }).pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* Fiber.interrupt(runner).pipe(Effect.ignore) + + // The entry must be released despite the interrupt landing before + // the select pipeline attached its bracket: a later identical query + // resolves within the bounded window instead of parking forever + // (empty: the query text matches no topic — liveness is the point). + yield* Deferred.succeed(release, undefined) + expect(yield* awaitWithTimeout(memory.search({ sessionID, messages, query: "可中断查询" }), "later identical query wedged on the entry leaked by the interrupted read")).toMatchObject({ + status: "empty", + }) + }), + { git: true }, + ) }) describe("memory project config Git exclusions", () => { From 9094abcaa8e030a7fc6a602ee0d953bed0cf60dc Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:47:09 +0800 Subject: [PATCH 4/7] docs(memory): record review round-2 closures --- docs/findings/memory-batch-findings.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index d23174f32..d9beaa138 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -37,4 +37,13 @@ - 结论:修复后进入 Round 2。 ### Round 2 +- Spec 镜:**BLOCKING**(Issue 1 P1 + Issue 2 INFO);Standards 镜:**BLOCKING**(Issue 1 P1 同源 + 2/3/4 INFO)。处置: + - Issue 1(P1):onExit 括号只覆盖 select 管道——注册后的 `store.readTopics` 挂起(生产为 flock 磁盘 IO)期间被中断/失败会在括号附着前 unwind → 泄漏条目 + wedge。→ **已修**:整个尾部(readTopics + select 管道)包进同一 `Effect.gen(...).pipe(tap, onExit, exit)` 括号;mock 的 `readTopics` 加 `parkReads` 挂起钩子;新增红绿测试「an interrupted topics read releases the in-flight entry and never wedges the key」+ 变异验证(readTopics 挪出括号 → 翻红)。 + - Spec Issue 2(INFO):in-flight key 无 turn 分量 → 新轮次的同文查询可能骑上一轮的 deferred,attached 但自身缓存不填充。→ **已修**:key 加入 turn origin(messageID)——合并严格 turn 内,跨轮重跑。 + - Standards 2(INFO):awaiter 注释的 interrupt 机制描述不准(failCause 重抛在 awaiter,failed 映射在 search wrapper)。→ 已改写。 + - Standards 3(INFO):测试注释宣称 interrupted 覆盖但原先无此测试。→ R2 新增的中断测试已补齐该覆盖。 + - Standards 4(INFO):`undefined as void` 多余 cast。→ 已删。 +- 结论:修复后进入 Round 3。 + +### Round 3 - 未开始 From 04385af798d7c7bcd7dca0b139dafdd26a2a0399 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 20:55:58 +0800 Subject: [PATCH 5/7] docs(memory): sync in-flight key wording and awaiter interrupt comment (review R3) --- docs/findings/memory-batch-findings.md | 11 +++++++++-- packages/opencode/src/memory/CONTEXT.md | 2 +- packages/opencode/src/memory/memory.ts | 10 +++++----- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index d9beaa138..b7195b9a2 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -9,8 +9,8 @@ | ID | 来源 | 处置 | 状态 | 提交 | |---|---|---|---|---| -| MEM-01 后半(P1) | 审计 + 验收 (a)2 | prepareUnsafe `shouldMatch` 分支的 select matcher 移出 fence/lock:matcher 无锁跑,markMatched 经 `applyUpdate`(fence+lock 只包提交) | 完成(红-绿-变异通过) | 待提交 | -| MEM-02(P2) | 审计 + 验收 (a)1 | `search` 的 identity fence 缩到 markMatched 提交;同查询合并从「持锁阻塞后来者」改为 per-key in-flight coalescing(进程内 Deferred),语义等价(后来者 reused:true、不耗 slot)且不再跨模型调用持 fence/lock | 完成(红-绿-变异通过;旧 coalescing 回归保持绿) | 待提交 | +| MEM-01 后半(P1) | 审计 + 验收 (a)2 | prepareUnsafe `shouldMatch` 分支的 select matcher 移出 fence/lock:matcher 无锁跑,markMatched 经 `applyUpdate`(fence+lock 只包提交) | 完成(红-绿-变异通过) | 70291fbe4 | +| MEM-02(P2) | 审计 + 验收 (a)1 | `search` 的 identity fence 缩到 markMatched 提交;同查询合并从「持锁阻塞后来者」改为 turn 内 per-(session,turn,key) in-flight coalescing(进程内 Deferred),语义等价(后来者 reused:true、不耗 slot;失败/中断/retired 时唤醒降级而非挂起)且不再跨模型调用持 fence/lock | 完成(红-绿-变异通过;旧 coalescing 回归保持绿) | 70291fbe4 + f08548d6d + ef8d6b8d1 | | MEM-03(P3) | 验收 (a)3 | 处置记录:已被 PR #333 的 MEM-01 重构结构性抵消——维护恒为后台(kickMaintenance),失败折入其 catchCause,维护前渲染是已声明设计(CONTEXT.md「render the pre-maintenance snapshot」)。旧的「维护前快照渲染注入」窗口随 inline maintain 一起消失。本行即处置记录。 | 已记录 | — | ## 设计要点 @@ -46,4 +46,11 @@ - 结论:修复后进入 Round 3。 ### Round 3 +- Spec 镜:**PASS**,1 条 INFO;Standards 镜:**PASS**,2 条 INFO。处置: + - R3-1(INFO):CONTEXT.md 与 searchUnsafe 注释仍写 per-(session,key),key 已 turn-scoped。→ 两处已改为 per-(session,turn,key)。 + - R3-2(INFO):awaiter 注释「shares the cancellation」不符合 v4 语义(failCause 重抛由 wrapper catchCause 吸收,awaiter 仍以 failed 完成)。→ 已改写为准确机制。 + - register「待提交」歧义。→ 已改为实际提交哈希。 +- 结论:非干净轮(INFO)。修复后进入 Round 4。 + +### Round 4 - 未开始 diff --git a/packages/opencode/src/memory/CONTEXT.md b/packages/opencode/src/memory/CONTEXT.md index 4625e4ebb..140467d01 100644 --- a/packages/opencode/src/memory/CONTEXT.md +++ b/packages/opencode/src/memory/CONTEXT.md @@ -52,7 +52,7 @@ The domain runs on the existing seams; the elaborate `ProjectMemoryAuthority` re - 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. - Maintenance model calls never run under the identity fence or the project lock: prepare and checkpoint render the pre-maintenance snapshot, then kick maintenance in the background, gated on identity liveness so a retired identity never starts a job (one job in flight per project, the commit-only write back under the fence). -- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence, and only the markMatched commit acquires the fence (`applyUpdate`). Search keeps a SHORT project-lock critical section (stale/cache/limit check + in-flight registration); concurrent identical queries coalesce through a process-local per-(session,key) in-flight Deferred — the second caller awaits the first caller's exit (reused, no extra query slot) and degrades to `failed`/`unavailable` when the first call fails or its identity retires, instead of blocking a lock across the model call. +- Matcher model calls never run under the identity fence either (issue #324 acceptance): search, prepare, and checkpoint run their matcher outside every fence, and only the markMatched commit acquires the fence (`applyUpdate`). Search keeps a SHORT project-lock critical section (stale/cache/limit check + in-flight registration); concurrent identical queries within one turn coalesce through a process-local per-(session,turn,key) in-flight Deferred — the second caller awaits the first caller's exit (reused, no extra query slot) and degrades to `failed`/`unavailable` when the first call fails or its identity retires, instead of blocking a lock across the model call. ## Boundaries diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index e4d9fdd2e..d15e8e5ed 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -589,8 +589,8 @@ export const layer: Layer.Layer< // MEM-02 (issue #324 acceptance): the matcher model call runs OUTSIDE // the fence/lock. Concurrent identical queries coalesce through the - // per-(session,key) in-flight Deferred instead of lock-blocking: the - // second caller re-checks the cache under a SHORT project-lock + // per-(session,turn,key) in-flight Deferred instead of lock-blocking: + // the second caller re-checks the cache under a SHORT project-lock // critical section (stale/cache/limit check + registration), awaits // the first caller's result, and reports reused without spending // another model call or query slot. Only the markMatched commit @@ -635,9 +635,9 @@ export const layer: Layer.Layer< // packs every outcome — success, failure, interrupt, retired — // into the deferred payload, so the await always wakes. Failures // surface as this caller's "failed" (mapped by the search - // wrapper's catchCause); the runner's interrupt is re-raised here - // via failCause (the awaiter shares the cancellation); a retired - // identity degrades to "unavailable". Never a permanent park. + // wrapper's catchCause); the runner's interrupt cause is re-raised + // via failCause so the wrapper's log carries it — the awaiter + // itself still completes with "failed". Never a permanent park. const first = yield* Deferred.await(outcome.first) if (Exit.isFailure(first)) { if (Cause.hasInterrupts(first.cause)) return yield* Effect.failCause(first.cause) From 0c19194659c6ff82c157d43911404dde8b33d5f6 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 21:04:56 +0800 Subject: [PATCH 6/7] docs(memory): precise awaiter failure mapping and topics-read suspension wording (review R4) --- docs/findings/memory-batch-findings.md | 5 +++++ packages/opencode/src/memory/memory.ts | 8 ++++---- packages/opencode/test/memory/memory.test.ts | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index b7195b9a2..fd5d73f19 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -53,4 +53,9 @@ - 结论:非干净轮(INFO)。修复后进入 Round 4。 ### Round 4 +- Spec 镜:**PASS,no findings**(干净轮候选)。 +- Standards 镜:**PASS**,2 条 INFO:R4-1 awaiter 注释把非中断失败的映射错归 wrapper catchCause(实际直接返回 failed);R4-2 测试注释「flock'd disk I/O」不准(readTopics 无 flock,是 async fs)。→ 均已改写。 +- 结论:非干净轮。修复后进入 Round 5。 + +### Round 5 - 未开始 diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index d15e8e5ed..89b2f99da 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -634,10 +634,10 @@ export const layer: Layer.Layer< // The awaiter rides the runner's exit: the runner's exit bracket // packs every outcome — success, failure, interrupt, retired — // into the deferred payload, so the await always wakes. Failures - // surface as this caller's "failed" (mapped by the search - // wrapper's catchCause); the runner's interrupt cause is re-raised - // via failCause so the wrapper's log carries it — the awaiter - // itself still completes with "failed". Never a permanent park. + // surface as this caller's "failed" (returned directly here); + // the runner's interrupt cause is re-raised via failCause so the + // wrapper's log carries it — the awaiter itself still completes + // with "failed". Never a permanent park. const first = yield* Deferred.await(outcome.first) if (Exit.isFailure(first)) { if (Cause.hasInterrupts(first.cause)) return yield* Effect.failCause(first.cause) diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index a1adfed29..9b711d389 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -1529,9 +1529,9 @@ describe("memory turn-scoped retrieval", () => { // Review R2 issue 1: the exit bracket must start at REGISTRATION, not at // the select pipeline — an interrupt during the runner's pre-select topics - // read (a real suspension in production, flock'd disk I/O) previously - // unwound before onExit attached, leaking the in-flight entry and wedging - // the (turn,key) forever. + // read (a real async fs suspension in production) previously unwound + // before onExit attached, leaking the in-flight entry and wedging the + // (turn,key) forever. recall.it.instance( "an interrupted topics read releases the in-flight entry and never wedges the key", () => From 57a1a81f30c299bc075242d0ecffbf4da4783888 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 18 Aug 2026 21:10:11 +0800 Subject: [PATCH 7/7] docs(memory): declare acceptance-leftover batch convergence --- docs/findings/memory-batch-findings.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/findings/memory-batch-findings.md b/docs/findings/memory-batch-findings.md index fd5d73f19..426b1beb0 100644 --- a/docs/findings/memory-batch-findings.md +++ b/docs/findings/memory-batch-findings.md @@ -58,4 +58,22 @@ - 结论:非干净轮。修复后进入 Round 5。 ### Round 5 -- 未开始 +- Spec 镜:**PASS,no findings**;Standards 镜:**PASS,no findings**(R4 两处措辞独立复核为准确)。 +- 结论:**干净轮 2/2**。连续两轮零 findings → **MEMORY 验收遗留批次收敛**。 + +## 收敛结论 + +R1(双镜 BLOCKING:in-flight 泄漏 P1)→ R2(双镜 BLOCKING:括号窗口 P1 + turn 作用域)→ R3(双 PASS,3 INFO 措辞)→ R4(Spec 干净 + Standards 2 INFO 措辞)→ **R4+R5 连续两轮零 findings**。findings 轨迹:两轮 P1 并发缺陷(实装修复+回归测试)→ 纯措辞 → 零。 + +## 模块门禁(终态) + +- memory 测试簇 95/95 绿(新增 5 条回归:fence-free ×2、failed-wedge、interrupted-read-wedge、原 coalescing 保持) +- `bun typecheck` + pre-push turbo 29/29 绿 +- 全量套件 4164 tests:仅 2 失败为历次批次已在干净基线证实的 darwin 环境既有失败(help-snapshots、project-copy),与本批无因果 +- 变异验证:fence 回置翻红 ×2(search/prepare)、onExit 移除翻红 ×2(wedge + coalescing)、readTopics 出括号翻红 ×1 + +## 交付 + +- 分支:`fix/memory-fence-scope`(基于 origin/dev 11cfafe9c) +- 提交链:70291fbe4(MEM-01/02 主体)→ f08548d6d(R1 exit-safe)→ ef8d6b8d1(R2 整尾括号 + turn key)→ 9094abcaa/04385af79/0c1919465(记账与措辞) +- PR → dev(Typecheck 门禁)