From 1836247c45223b8f2b8f6fe80fde66137b597dc3 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 11:59:08 +0800 Subject: [PATCH 1/4] chore(specgit): bind delivery to issue 415 --- .specgit.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 55ee941d9..2abcf5499 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,7 @@ version: 1 -delivery: dagid-schema-pattern +delivery: init-stamp-is context: kind: branch - branch: fix/412-dagid-schema-pattern + branch: fix/415-init-stamp-is issues: - - 412 -pr: 413 + - 415 From 0e390e143026ab258a863431a79a5d2d1363e984 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 11:59:29 +0800 Subject: [PATCH 2/4] chore: record delivery binding for init-stamp-is --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 2abcf5499..bb65f8745 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,3 +5,4 @@ context: branch: fix/415-init-stamp-is issues: - 415 +pr: 416 From 077a365348691fb431445f8987f955d60c8ac9d3 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 12:09:27 +0800 Subject: [PATCH 3/4] fix(memory): self-heal the /init stamp from AGENTS.md and add gate diagnostics The stamp listener (Command.Event.Executed) can lose the race with /init, leaving time_initialized NULL forever; re-running /init hits the same race. statusReason now heals the row when a non-empty AGENTS.md (the durable /init artifact) exists, and the no-artifact blocker carries the DB row state so stale identities are visible. --- packages/opencode/src/memory/memory.ts | 30 ++++- .../memory/memory-init-stamp-selfheal.test.ts | 111 ++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index f5d767746..eddb46639 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -1,9 +1,11 @@ export * as Memory from "./memory" 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 { SessionV1 } from "@opencode-ai/core/v1/session" import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope, Semaphore } from "effect" +import path from "node:path" import { stringify } from "yaml" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" @@ -788,8 +790,32 @@ export const layer: Layer.Layer< if (current.id === ProjectV2.ID.global) return "Memory is unavailable until this repository has a real identity: commit once or add a remote, then run /init." if (current.vcs !== "git") return "Memory requires a git repository." - if (!current.time.initialized) - return "Memory is unavailable until the project is initialized — run /init first, then /memory on." + if (!current.time.initialized) { + // #415: the stamp is written by a Command.Event.Executed listener that + // can lose the race with /init itself. The artifact /init leaves behind + // (a non-empty AGENTS.md) is durable evidence the project WAS + // initialized, so heal the row instead of sending the user to re-run + // /init into the same race. + const agentsMd = path.join(current.worktree, "AGENTS.md") + // Non-empty AGENTS.md is the durable artifact /init leaves behind. + // FSUtil rides MemoryConfig's layer (optional access keeps this layer + // lightweight — a missing wire degrades to no-heal, not a crash). + const healed = yield* Effect + .serviceOption(FSUtil.Service) + .pipe( + Effect.flatMap((option) => + Option.isSome(option) + ? option.value.readFileStringSafe(agentsMd).pipe(Effect.map((content) => (content?.trim().length ?? 0) > 0)) + : Effect.succeed(false), + ), + Effect.catch(() => Effect.succeed(false)), + ) + if (healed) { + yield* project.setInitialized(current.id) + } else { + return `Memory is unavailable until the project is initialized — run /init first, then /memory on. (db: time_initialized=NULL, worktree=${current.worktree}, sandboxes=${current.sandboxes.join(", ") || "none"})` + } + } // An unreadable config/store answers "cannot determine" rather than // failing the status surface. const optioned = yield* Effect.option(configuration()) diff --git a/packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts b/packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts new file mode 100644 index 000000000..a29e9ff4c --- /dev/null +++ b/packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts @@ -0,0 +1,111 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import fs from "node:fs" +import path from "node:path" +import { Config } from "@/config/config" +import { Project } from "@/project/project" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Database } from "@opencode-ai/core/database/database" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Git } from "@/git" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Memory } from "@/memory/memory" +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 { MemoryStore } from "@/memory/store" +import { MemoryModel } from "@/memory/model" +import { ProviderTest } from "../fake/provider" +import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +// #415: the /init stamp is written by a Command.Event.Executed listener that can +// lose the race with the command itself, leaving time_initialized NULL forever. +// The activation gate must self-heal: a project that already has the /init +// artifact (non-empty AGENTS.md) passes without re-running /init, and a project +// without it still reports the blocker — now with the DB row state attached. + +const emptyConfigLayer = Layer.mock(Config.Service, { + get: () => Effect.succeed({}), +}) + +const base = Layer.mergeAll( + emptyConfigLayer, + ProviderTest.fake().layer, + Project.defaultLayer, + Database.defaultLayer, + Git.defaultLayer, + FSUtil.defaultLayer, + EffectFlock.defaultLayer, + MemoryAdmission.defaultLayer, + MemoryConfig.defaultLayer, + MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, + MemoryLock.defaultLayer, + MemoryStore.defaultLayer, + Layer.mock(MemoryModel.Service, { + generate: () => Effect.die(new Error("model calls are not expected in self-heal tests")), + }), +) + +// provideMerge builds `base` once, provides it to Memory.layer AND re-exposes its +// services (Project/MemoryConfig/MemoryStore/...) to the test body. +const layer = Layer.mergeAll(Memory.layer.pipe(Layer.provideMerge(base)), CrossSpawnSpawner.defaultLayer) + +const it = testEffect(layer) + +describe("memory /init stamp self-heal", () => { + it.live( + "statusReason stamps the project when AGENTS.md already exists", + () => + 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 { project: info } = yield* project.fromDirectory(dir) + expect(info.id).not.toBe(ProjectV2.ID.global) + // The artifact /init produces: a non-empty AGENTS.md in the worktree. + fs.writeFileSync(path.join(info.worktree, "AGENTS.md"), "# project guide\n") + + // Gate self-heals instead of reporting the blocker... + expect(yield* memory.statusReason()).toBeUndefined() + // ...and the stamp actually landed in the DB row. + const stamped = yield* project.get(info.id) + expect(stamped?.time.initialized).toBeDefined() + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) + + it.live( + "statusReason keeps blocking without AGENTS.md and reports the DB state", + () => + 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 { project: info } = yield* project.fromDirectory(dir) + expect(info.id).not.toBe(ProjectV2.ID.global) + + const reason = yield* memory.statusReason() + expect(reason).toContain("/init") + // #415 diagnostics: the blocker carries the actual row state so a + // stale identity (worktree pointing at a deleted clone) is visible. + expect(reason).toContain("time_initialized") + expect(reason).toContain(info.worktree) + }), + ).pipe(Effect.provide(testInstanceStoreLayer)) + }), + { timeout: 30_000 }, + ) +}) From 625d5984ccaa2ed2082ddf9565c0c461247fe222 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 21 Aug 2026 12:36:22 +0800 Subject: [PATCH 4/4] test(memory): pin private config dir in self-heal tests Shared XDG_CONFIG_HOME across bun test files let an earlier file's enabled global memory.jsonc turn the post-heal statusReason into a model-unavailable blocker on CI. --- .../memory/memory-init-stamp-selfheal.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts b/packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts index a29e9ff4c..19df6842c 100644 --- a/packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts +++ b/packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts @@ -1,6 +1,7 @@ -import { describe, expect } from "bun:test" +import { describe, expect, afterAll, beforeAll } from "bun:test" import { Effect, Layer } from "effect" import fs from "node:fs" +import os from "node:os" import path from "node:path" import { Config } from "@/config/config" import { Project } from "@/project/project" @@ -22,6 +23,23 @@ import { ProviderTest } from "../fake/provider" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +// bun test runs all files in one process, sequentially, sharing one +// XDG_CONFIG_HOME — a test file that runs before this one can leave an +// enabled global memory.jsonc whose model this file's fake provider does not +// know, turning the post-heal statusReason into a model-unavailable blocker. +// Pin a private config dir per file so the global file can never be +// contaminated by earlier files. +const pinnedConfigDir = path.join(os.tmpdir(), `opencode-memory-selfheal-${process.pid}`) +const previousConfigDir = process.env.OPENCODE_CONFIG_DIR +beforeAll(() => { + fs.mkdirSync(pinnedConfigDir, { recursive: true }) + process.env.OPENCODE_CONFIG_DIR = pinnedConfigDir +}) +afterAll(() => { + if (previousConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = previousConfigDir +}) + // #415: the /init stamp is written by a Command.Event.Executed listener that can // lose the race with the command itself, leaving time_initialized NULL forever. // The activation gate must self-heal: a project that already has the /init