diff --git a/.specgit.yaml b/.specgit.yaml index 55ee941d9..bb65f8745 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ 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 +pr: 416 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..19df6842c --- /dev/null +++ b/packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts @@ -0,0 +1,129 @@ +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" +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" + +// 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 +// 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 }, + ) +})