summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-01-18 21:33:23 -0800
committerGitHub <[email protected]>2026-01-18 23:33:23 -0600
commite2f1f4d81e152f19f6f9d2f8ed873f310296eba4 (patch)
treeb1ca9d782d45b8789db132db082ccd03b0311e53
parentfc6c9cbbd262daa0f98338ed3c79270fbfa086ad (diff)
downloadopencode-e2f1f4d81e152f19f6f9d2f8ed873f310296eba4.tar.gz
opencode-e2f1f4d81e152f19f6f9d2f8ed873f310296eba4.zip
add scheduler, cleanup module (#9346)
-rw-r--r--packages/opencode/src/project/bootstrap.ts4
-rw-r--r--packages/opencode/src/scheduler/index.ts61
-rw-r--r--packages/opencode/src/snapshot/index.ts37
-rw-r--r--packages/opencode/src/tool/truncation.ts15
-rw-r--r--packages/opencode/test/scheduler.test.ts73
5 files changed, 186 insertions, 4 deletions
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index 56fe4d13e..efdcaba99 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -11,6 +11,8 @@ import { Instance } from "./instance"
import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
+import { Snapshot } from "../snapshot"
+import { Truncate } from "../tool/truncation"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
@@ -22,6 +24,8 @@ export async function InstanceBootstrap() {
FileWatcher.init()
File.init()
Vcs.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
new file mode 100644
index 000000000..cfafa7b9c
--- /dev/null
+++ b/packages/opencode/src/scheduler/index.ts
@@ -0,0 +1,61 @@
+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<void>
+ scope?: "instance" | "global"
+ }
+
+ type Timer = ReturnType<typeof setInterval>
+ type Entry = {
+ tasks: Map<string, Task>
+ timers: Map<string, Timer>
+ }
+
+ const create = (): Entry => {
+ const tasks = new Map<string, Task>()
+ const timers = new Map<string, Timer>()
+ 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/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index 69f2abc79..46c97cf8d 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -6,9 +6,46 @@ import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
+import { Scheduler } from "../scheduler"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
+ const hour = 60 * 60 * 1000
+ const prune = "7.days"
+
+ export function init() {
+ Scheduler.register({
+ id: "snapshot.cleanup",
+ interval: hour,
+ run: cleanup,
+ scope: "instance",
+ })
+ }
+
+ export async function cleanup() {
+ if (Instance.project.vcs !== "git") return
+ const cfg = await Config.get()
+ if (cfg.snapshot === false) return
+ const git = gitdir()
+ const exists = await fs
+ .stat(git)
+ .then(() => true)
+ .catch(() => false)
+ if (!exists) return
+ const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
+ .quiet()
+ .cwd(Instance.directory)
+ .nothrow()
+ if (result.exitCode !== 0) {
+ log.warn("cleanup failed", {
+ exitCode: result.exitCode,
+ stderr: result.stderr.toString(),
+ stdout: result.stdout.toString(),
+ })
+ return
+ }
+ log.info("cleanup", { prune })
+ }
export async function track() {
if (Instance.project.vcs !== "git") return
diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts
index 4172b6447..84e799c13 100644
--- a/packages/opencode/src/tool/truncation.ts
+++ b/packages/opencode/src/tool/truncation.ts
@@ -2,9 +2,9 @@ import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
-import { lazy } from "../util/lazy"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
+import { Scheduler } from "../scheduler"
export namespace Truncate {
export const MAX_LINES = 2000
@@ -12,6 +12,7 @@ export namespace Truncate {
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 type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
@@ -21,6 +22,15 @@ 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 glob = new Bun.Glob("tool_*")
@@ -31,8 +41,6 @@ export namespace Truncate {
}
}
- const init = lazy(cleanup)
-
function hasTaskTool(agent?: Agent.Info): boolean {
if (!agent?.permission) return false
const rule = PermissionNext.evaluate("task", "*", agent.permission)
@@ -81,7 +89,6 @@ export namespace Truncate {
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
- await init()
const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)
diff --git a/packages/opencode/test/scheduler.test.ts b/packages/opencode/test/scheduler.test.ts
new file mode 100644
index 000000000..328daad9b
--- /dev/null
+++ b/packages/opencode/test/scheduler.test.ts
@@ -0,0 +1,73 @@
+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)
+ })
+})