fix(core): re-inject nested instructions after compaction (#43723)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
|
||||
@@ -37,15 +37,17 @@ const layer = Layer.effect(
|
||||
// root so opening a subdirectory still describes paths from the project root.
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
// Same-step parallel reads settle concurrently, so an in-memory claim guards each
|
||||
// Session/path pair before any filesystem work. The durable history check below covers
|
||||
// paths injected in earlier steps after this Location layer was reopened.
|
||||
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
// Session/path pair while a load is in flight. The claim is released once the load
|
||||
// settles: the synthetic message metadata scanned below is the only lasting ledger,
|
||||
// so paths whose synthetics drop out of model-visible history (compaction, revert)
|
||||
// are re-discovered and re-injected instead of staying silently lost.
|
||||
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
const claimed = yield* Ref.modify(injected, (map) => {
|
||||
const claimed = yield* Ref.modify(inFlight, (map) => {
|
||||
const existing = map.get(input.sessionID) ?? new Set<string>()
|
||||
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
||||
if (newlyClaimed.length === 0) return [newlyClaimed, map]
|
||||
@@ -54,30 +56,43 @@ const layer = Layer.effect(
|
||||
return [newlyClaimed, next]
|
||||
})
|
||||
if (claimed.length === 0) return
|
||||
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
||||
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
||||
if (toInject.length === 0) return
|
||||
const files = yield* Effect.forEach(
|
||||
toInject,
|
||||
(path) =>
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
|
||||
{ concurrency: "unbounded" },
|
||||
yield* Effect.gen(function* () {
|
||||
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
||||
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
||||
if (toInject.length === 0) return
|
||||
const files = yield* Effect.forEach(
|
||||
toInject,
|
||||
(path) =>
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||
if (readable.length === 0) return
|
||||
// Publish directly rather than through Session.synthetic: a Location-scoped layer
|
||||
// cannot depend on Session (it routes through LocationServiceMap, forming a type
|
||||
// cycle with this node). The durable publish commits the synthetic and its metadata
|
||||
// ledger atomically, so releasing the claim afterwards cannot readmit the paths.
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
||||
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
|
||||
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Ref.update(inFlight, (map) => {
|
||||
const existing = map.get(input.sessionID)
|
||||
if (!existing) return map
|
||||
const remaining = new Set([...existing].filter((path) => !claimed.includes(path)))
|
||||
const next = new Map(map)
|
||||
if (remaining.size === 0) next.delete(input.sessionID)
|
||||
else next.set(input.sessionID, remaining)
|
||||
return next
|
||||
}),
|
||||
),
|
||||
)
|
||||
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||
if (readable.length === 0) return
|
||||
// Publish directly rather than through Session.synthetic: a Location-scoped layer
|
||||
// cannot depend on Session (it routes through LocationServiceMap, forming a type
|
||||
// cycle with this node). The durable publish is what makes the synthetic visible on
|
||||
// the next projected history reload. The dedup ledger lives on the synthetic message
|
||||
// metadata so it survives across Location layer restarts.
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
||||
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
|
||||
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ load })
|
||||
|
||||
@@ -233,6 +233,37 @@ describe("SessionInstructions", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("re-injects nested instructions dropped from history by compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "sub"))
|
||||
yield* writeAgents(path.resolve(dir, "AGENTS.md"), "root-instructions")
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
|
||||
|
||||
const session = yield* Session.Service
|
||||
const registry = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
yield* executeTool(registry, readCall(sessionID, "call-before", "sub/file.txt"))
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
|
||||
// A completed compaction truncates model-visible history at its boundary, dropping
|
||||
// the synthetic that carried sub's instructions.
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" })
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(0)
|
||||
|
||||
// The model no longer has the rules, so the next read under the subtree must
|
||||
// re-inject them rather than trusting a stale in-memory claim.
|
||||
yield* executeTool(registry, readCall(sessionID, "call-after", "sub/file.txt"))
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("listing the Location root directory injects no instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
|
||||
Reference in New Issue
Block a user