From d2d8aaae220133c89e2bd2626a8482168654b041 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 17 Mar 2026 08:47:53 -0400 Subject: [PATCH] refactor(truncation): effectify TruncateService, delete Scheduler - TruncateService as global Effect service on ManagedRuntime with FileSystem for cleanup (readDirectory + remove) - Hourly cleanup via Effect.forkScoped + Schedule.spaced - Split into truncate-service.ts (Effect) + truncation.ts (facade) to avoid circular import with runtime.ts - Shared TRUNCATION_DIR constant in truncation-dir.ts - Delete Scheduler module (no remaining consumers) - Remove Truncate.init() from bootstrap (cleanup starts automatically when runtime is created) --- packages/opencode/src/effect/runtime.ts | 5 +- packages/opencode/src/project/bootstrap.ts | 2 - packages/opencode/src/scheduler/index.ts | 61 ---------------- .../opencode/src/tool/truncate-service.ts | 52 +++++++++++++ packages/opencode/src/tool/truncation-dir.ts | 4 + packages/opencode/src/tool/truncation.ts | 31 ++------ packages/opencode/test/scheduler.test.ts | 73 ------------------- 7 files changed, 67 insertions(+), 161 deletions(-) delete mode 100644 packages/opencode/src/scheduler/index.ts create mode 100644 packages/opencode/src/tool/truncate-service.ts create mode 100644 packages/opencode/src/tool/truncation-dir.ts delete mode 100644 packages/opencode/test/scheduler.test.ts diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 02a7391d44..5a9e68d1e5 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -3,10 +3,13 @@ import { AccountService } from "@/account/service" import { AuthService } from "@/auth/service" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" +import { TruncateService } from "@/tool/truncate-service" import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( - Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)), + Layer.mergeAll(AccountService.defaultLayer, Instances.layer, TruncateService.layer).pipe( + Layer.provideMerge(AuthService.defaultLayer), + ), ) export function runPromiseInstance(effect: Effect.Effect) { diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 00ced358d7..206cfcd0ea 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -11,7 +11,6 @@ import { VcsService } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" -import { Truncate } from "../tool/truncation" import { runPromiseInstance } from "@/effect/runtime" export async function InstanceBootstrap() { @@ -24,7 +23,6 @@ export async function InstanceBootstrap() { File.init() await runPromiseInstance(VcsService.use((s) => s.init())) Snapshot.init() - Truncate.init() Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { diff --git a/packages/opencode/src/scheduler/index.ts b/packages/opencode/src/scheduler/index.ts deleted file mode 100644 index cfafa7b9ce..0000000000 --- a/packages/opencode/src/scheduler/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Instance } from "../project/instance" -import { Log } from "../util/log" - -export namespace Scheduler { - const log = Log.create({ service: "scheduler" }) - - export type Task = { - id: string - interval: number - run: () => Promise - scope?: "instance" | "global" - } - - type Timer = ReturnType - type Entry = { - tasks: Map - timers: Map - } - - const create = (): Entry => { - const tasks = new Map() - const timers = new Map() - return { tasks, timers } - } - - const shared = create() - - const state = Instance.state( - () => create(), - async (entry) => { - for (const timer of entry.timers.values()) { - clearInterval(timer) - } - entry.tasks.clear() - entry.timers.clear() - }, - ) - - export function register(task: Task) { - const scope = task.scope ?? "instance" - const entry = scope === "global" ? shared : state() - const current = entry.timers.get(task.id) - if (current && scope === "global") return - if (current) clearInterval(current) - - entry.tasks.set(task.id, task) - void run(task) - const timer = setInterval(() => { - void run(task) - }, task.interval) - timer.unref() - entry.timers.set(task.id, timer) - } - - async function run(task: Task) { - log.info("run", { id: task.id }) - await task.run().catch((error) => { - log.error("run failed", { id: task.id, error }) - }) - } -} diff --git a/packages/opencode/src/tool/truncate-service.ts b/packages/opencode/src/tool/truncate-service.ts new file mode 100644 index 0000000000..cec562eea5 --- /dev/null +++ b/packages/opencode/src/tool/truncate-service.ts @@ -0,0 +1,52 @@ +import path from "path" +import { Log } from "../util/log" +import { TRUNCATION_DIR } from "./truncation-dir" +import { Identifier } from "../id/id" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect" + +const log = Log.create({ service: "truncation" }) +const RETENTION = Duration.days(7) + +export namespace TruncateService { + export interface Service { + readonly cleanup: () => Effect.Effect + } +} + +export class TruncateService extends ServiceMap.Service()( + "@opencode/Truncate", +) { + static readonly layer = Layer.effect( + TruncateService, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + const cleanup = Effect.fn("TruncateService.cleanup")(function* () { + const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION))) + const entries = yield* fs + .readDirectory(TRUNCATION_DIR) + .pipe( + Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), + Effect.catch(() => Effect.succeed([])), + ) + for (const entry of entries) { + if (Identifier.timestamp(entry) >= cutoff) continue + yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + } + }) + + // Start hourly cleanup — scoped to runtime lifetime + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.forkScoped, + ) + + return TruncateService.of({ cleanup }) + }), + ).pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) +} diff --git a/packages/opencode/src/tool/truncation-dir.ts b/packages/opencode/src/tool/truncation-dir.ts new file mode 100644 index 0000000000..d6d5d013d7 --- /dev/null +++ b/packages/opencode/src/tool/truncation-dir.ts @@ -0,0 +1,4 @@ +import path from "path" +import { Global } from "../global" + +export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output") diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 7c6a362a37..9e8f3abb82 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -1,21 +1,18 @@ -import fs from "fs/promises" import path from "path" -import { Global } from "../global" -import { Identifier } from "../id/id" import { PermissionNext } from "../permission/next" +import { TRUNCATION_DIR } from "./truncation-dir" import type { Agent } from "../agent/agent" -import { Scheduler } from "../scheduler" import { Filesystem } from "../util/filesystem" -import { Glob } from "../util/glob" import { ToolID } from "./schema" +import { TruncateService } from "./truncate-service" +import { runtime } from "@/effect/runtime" + export namespace Truncate { export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 - export const DIR = path.join(Global.Path.data, "tool-output") - export const GLOB = path.join(DIR, "*") - const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days - const HOUR_MS = 60 * 60 * 1000 + export const DIR = TRUNCATION_DIR + export const GLOB = path.join(TRUNCATION_DIR, "*") export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } @@ -25,22 +22,8 @@ export namespace Truncate { direction?: "head" | "tail" } - export function init() { - Scheduler.register({ - id: "tool.truncation.cleanup", - interval: HOUR_MS, - run: cleanup, - scope: "global", - }) - } - export async function cleanup() { - const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS)) - const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[]) - for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - await fs.unlink(path.join(DIR, entry)).catch(() => {}) - } + return runtime.runPromise(TruncateService.use((s) => s.cleanup())) } function hasTaskTool(agent?: Agent.Info): boolean { diff --git a/packages/opencode/test/scheduler.test.ts b/packages/opencode/test/scheduler.test.ts deleted file mode 100644 index 328daad9b8..0000000000 --- a/packages/opencode/test/scheduler.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Scheduler } from "../src/scheduler" -import { Instance } from "../src/project/instance" -import { tmpdir } from "./fixture/fixture" - -describe("Scheduler.register", () => { - const hour = 60 * 60 * 1000 - - test("defaults to instance scope per directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - const runs = { count: 0 } - const id = "scheduler.instance." + Math.random().toString(36).slice(2) - const task = { - id, - interval: hour, - run: async () => { - runs.count += 1 - }, - } - - await Instance.provide({ - directory: one.path, - fn: async () => { - Scheduler.register(task) - await Instance.dispose() - }, - }) - expect(runs.count).toBe(1) - - await Instance.provide({ - directory: two.path, - fn: async () => { - Scheduler.register(task) - await Instance.dispose() - }, - }) - expect(runs.count).toBe(2) - }) - - test("global scope runs once across instances", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - const runs = { count: 0 } - const id = "scheduler.global." + Math.random().toString(36).slice(2) - const task = { - id, - interval: hour, - run: async () => { - runs.count += 1 - }, - scope: "global" as const, - } - - await Instance.provide({ - directory: one.path, - fn: async () => { - Scheduler.register(task) - await Instance.dispose() - }, - }) - expect(runs.count).toBe(1) - - await Instance.provide({ - directory: two.path, - fn: async () => { - Scheduler.register(task) - await Instance.dispose() - }, - }) - expect(runs.count).toBe(1) - }) -})