Compare commits

...
Author SHA1 Message Date
Kit Langton 7b73f20fcc fix(core): preserve approvals across location cleanup 2026-08-31 20:45:18 -04:00
5 changed files with 188 additions and 13 deletions
+15 -13
View File
@@ -1,6 +1,6 @@
export * as LocationActivity from "./location-activity.js"
import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect"
import { Clock, Context, Duration, Effect, Layer, MutableHashMap, RcMap, References, Schema } from "effect"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
@@ -39,11 +39,7 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
const cached = new Set(refs.map(key))
yield* Effect.forEach(
refs,
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
{ discard: true },
)
yield* Effect.forEach(refs, (ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)), { discard: true })
for (const id of entries.keys()) {
if (!cached.has(id)) entries.delete(id)
}
@@ -51,13 +47,19 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
yield* Effect.forEach(
expired,
(entry) => {
entries.delete(key(entry.ref))
return Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
},
// A new borrower must not arrive between the ref-count check and cache removal.
(entry) =>
Effect.suspend(() => {
if (locations.rcMap.state._tag === "Closed") return Effect.void
const cached = MutableHashMap.get(locations.rcMap.state.map, entry.ref)
// Invalidation detaches even borrowed entries, hiding live permissions and forms from later readers.
if (cached._tag === "Some" && cached.value.refCount > 0) return Effect.void
entries.delete(key(entry.ref))
return Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
}).pipe(Effect.provideService(References.PreventSchedulerYield, true)),
{ discard: true },
)
}).pipe(Effect.forever, Effect.forkScoped)
+24
View File
@@ -5,10 +5,12 @@ import { Config } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import {
DateTime,
Context,
Deferred,
Duration,
Effect,
Equal,
Exit,
Fiber,
Hash,
Layer,
@@ -16,6 +18,7 @@ import {
Option,
RcMap,
Schema,
Scope,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
@@ -82,6 +85,27 @@ const itWithActivity = testEffect(
)
describe("LocationServiceMap", () => {
itWithActivity.effect("keeps borrowed locations discoverable after the activity timeout", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const scope = yield* Scope.make()
yield* Effect.addFinalizer((exit) => Scope.close(scope, exit))
const context = yield* locations.contextEffect(ref).pipe(Scope.provide(scope))
// A permission wait owns the Location without producing new Session events.
yield* TestClock.adjust("61 minutes")
const cached = yield* locations.contextEffectOption(ref).pipe(Effect.scoped)
expect(Option.getOrUndefined(Option.map(cached, (value) => Context.get(value, Location.Service)))).toBe(
Context.get(context, Location.Service),
)
yield* Scope.close(scope, Exit.void)
yield* TestClock.adjust("1 minute")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
}),
)
itWithActivity.effect("does not refresh lifetime from inferred Session routing", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
+19
View File
@@ -0,0 +1,19 @@
import { plugin } from "bun"
// Filming only: accelerate the existing cleanup policy without modifying either checkout.
plugin({
name: "hidden-approval-clock",
setup(build) {
build.onLoad({ filter: /[/\\]location-activity\.ts$/ }, async (args) => {
const source = await Bun.file(args.path).text()
if (!source.includes("layer: layer(),")) throw new Error("LocationActivity timing seam changed")
return {
loader: "ts",
contents: source.replace(
"layer: layer(),",
'layer: layer({ timeToLive: "8 seconds", sweepInterval: "1 second" }),',
),
}
})
},
})
+42
View File
@@ -0,0 +1,42 @@
# Hidden Approval Reproducer
Requires Bun, FFmpeg, and `opencode-drive` v2.0.0. Run from the fix checkout after
`bun install --frozen-lockfile`. The target base checkout also needs its dependencies installed.
```sh
export OPENCODE_DRIVE_MEDIA_DIR="$PWD/.cache/hidden-approval"
# Unmodified base revision in a separate worktree.
OPENCODE_REPRO_BEFORE=1 OPENCODE_DEV=/path/to/base \
opencode-drive run script/repro/hidden-approval.ts
# Fixed revision.
OPENCODE_DEV="$PWD" opencode-drive run script/repro/hidden-approval.ts
```
Both runs use the same isolated project, simulated model, real `glob` tool, real
permission service, and production TUI. The preload changes only the automatic
cleanup timing: eight seconds instead of one hour, swept every second instead
of every minute. It does not edit either checkout or manually invalidate the cache.
The scenario requests a search, leaves its permission unanswered through cleanup,
closes the TUI, opens the same session in a fresh TUI, and presses Enter. It asserts
the server's permission count, retained request ID, and execution status:
| Checkpoint | Before | After |
| ------------------ | -------------------- | ---------------------- |
| Initial permission | One request | One request |
| After cleanup | No request | Same request |
| Reopened TUI | Spinner, no approval | Allow once available |
| After Enter | Still active | Search completed, idle |
Each run prints its annotated MP4 path and retains screenshots. The fixture
interrupts its own session afterward; it never connects to the user's live server.
The deterministic unit regression advances 61 virtual minutes and also verifies
that cleanup still occurs after the final borrower releases the location:
```sh
cd packages/core
bun run test test/location-layer.test.ts -t 'keeps borrowed locations discoverable'
```
+88
View File
@@ -0,0 +1,88 @@
import { Effect, Stream } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"
import path from "node:path"
// Run from this checkout; OPENCODE_DEV selects the unmodified base or the fix checkout.
// OPENCODE_REPRO_BEFORE=1 OPENCODE_DEV=/path/to/base opencode-drive run script/repro/hidden-approval.ts
// OPENCODE_DEV=$PWD opencode-drive run script/repro/hidden-approval.ts
const before = process.env.OPENCODE_REPRO_BEFORE === "1"
const label = before ? "BEFORE" : "AFTER"
const viewport = { cols: 100, rows: 30 }
export default OpenCodeDriver.use(
{
opencode: {
dev: process.env.OPENCODE_DEV ?? process.cwd(),
env: { BUN_OPTIONS: `--preload=${path.resolve("script/repro/hidden-approval-preload.ts")}` },
},
project: { git: true, files: { "src/example.ts": "export const example = true\n" } },
config: {
autoupdate: false,
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "ask" },
],
},
tui: { viewport },
keepArtifacts: true,
},
({ ui, tui, tuis, llm, opencode }) =>
Effect.gen(function* () {
yield* llm.title(() => Effect.succeed("Waiting for search approval"))
yield* llm.serve((_request, index) =>
index === 0
? Stream.make(
Llm.text("I will find the TypeScript files once you approve the search."),
Llm.toolCall({
index: 0,
id: "call_search",
name: "glob",
input: { pattern: "**/*.ts", path: ".", limit: 10 },
}),
Llm.finish("tool-calls"),
)
: Stream.make(Llm.text("Search complete. Found src/example.ts.")),
)
yield* ui.submit("Find the TypeScript files.")
yield* ui.waitFor("Allow once", { timeout: 20_000 })
const session = (yield* opencode.session.list({ limit: 1 })).data[0]
if (!session) return yield* Effect.die("No session created")
const initial = yield* opencode.permission.list({ sessionID: session.id })
if (initial.length !== 1) return yield* Effect.die("Expected one pending approval")
yield* ui.screenshot(`${label.toLowerCase()}-initial-approval`)
// This is the real automatic sweep, not a manual cache invalidation.
yield* Effect.sleep("10 seconds")
const pending = yield* opencode.permission.list({ sessionID: session.id })
if (pending.length !== (before ? 0 : 1))
return yield* Effect.die(`Unexpected pending approval count: ${pending.length}`)
if (pending[0] && pending[0].id !== initial[0]?.id) return yield* Effect.die("Approval identity changed")
yield* tui.close()
const reopened = yield* tuis.launch({ viewport, recording: true })
if (!reopened.recording) return yield* Effect.die("Recording unavailable")
yield* reopened.ui.submit("/sessions")
yield* reopened.ui.waitFor("Waiting for search approval")
yield* reopened.ui.enter()
yield* reopened.ui.waitFor("Find the TypeScript files.", { timeout: 20_000 })
if (before && (yield* reopened.ui.matches("Allow once")))
return yield* Effect.die("Baseline unexpectedly retained its approval")
if (!before) yield* reopened.ui.waitFor("Allow once")
yield* reopened.recording.mark(
`${label}: reopened after cleanup (${before ? "approval lost; still active" : "same approval retained"})`,
)
yield* reopened.ui.screenshot(`${label.toLowerCase()}-reopened`)
yield* Effect.sleep("3 seconds")
yield* reopened.recording.mark(`${label}: press Enter to approve`)
yield* reopened.ui.enter()
if (!before) yield* reopened.ui.waitFor("Search complete.", { timeout: 15_000 })
yield* Effect.sleep("3 seconds")
yield* reopened.ui.screenshot(`${label.toLowerCase()}-result`)
const active = yield* opencode.session.active()
if (Boolean(active[session.id]) !== before) return yield* Effect.die("Unexpected execution status")
console.log(JSON.stringify({ label, pending: pending.length, active: Boolean(active[session.id]) }))
console.log("video:", yield* reopened.recording.finish())
yield* opencode.session.interrupt({ sessionID: session.id })
yield* opencode.session.wait({ sessionID: session.id })
}),
)