Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -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
30 changes: 28 additions & 2 deletions packages/opencode/src/memory/memory.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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())
Expand Down
129 changes: 129 additions & 0 deletions packages/opencode/test/memory/memory-init-stamp-selfheal.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
})
Loading