Skip to content

Commit e2f1f4d

Browse files
authored
add scheduler, cleanup module (#9346)
1 parent fc6c9cb commit e2f1f4d

File tree

5 files changed

+186
-4
lines changed

5 files changed

+186
-4
lines changed

packages/opencode/src/project/bootstrap.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { Instance } from "./instance"
1111
import { Vcs } from "./vcs"
1212
import { Log } from "@/util/log"
1313
import { ShareNext } from "@/share/share-next"
14+
import { Snapshot } from "../snapshot"
15+
import { Truncate } from "../tool/truncation"
1416

1517
export async function InstanceBootstrap() {
1618
Log.Default.info("bootstrapping", { directory: Instance.directory })
@@ -22,6 +24,8 @@ export async function InstanceBootstrap() {
2224
FileWatcher.init()
2325
File.init()
2426
Vcs.init()
27+
Snapshot.init()
28+
Truncate.init()
2529

2630
Bus.subscribe(Command.Event.Executed, async (payload) => {
2731
if (payload.properties.name === Command.Default.INIT) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Instance } from "../project/instance"
2+
import { Log } from "../util/log"
3+
4+
export namespace Scheduler {
5+
const log = Log.create({ service: "scheduler" })
6+
7+
export type Task = {
8+
id: string
9+
interval: number
10+
run: () => Promise<void>
11+
scope?: "instance" | "global"
12+
}
13+
14+
type Timer = ReturnType<typeof setInterval>
15+
type Entry = {
16+
tasks: Map<string, Task>
17+
timers: Map<string, Timer>
18+
}
19+
20+
const create = (): Entry => {
21+
const tasks = new Map<string, Task>()
22+
const timers = new Map<string, Timer>()
23+
return { tasks, timers }
24+
}
25+
26+
const shared = create()
27+
28+
const state = Instance.state(
29+
() => create(),
30+
async (entry) => {
31+
for (const timer of entry.timers.values()) {
32+
clearInterval(timer)
33+
}
34+
entry.tasks.clear()
35+
entry.timers.clear()
36+
},
37+
)
38+
39+
export function register(task: Task) {
40+
const scope = task.scope ?? "instance"
41+
const entry = scope === "global" ? shared : state()
42+
const current = entry.timers.get(task.id)
43+
if (current && scope === "global") return
44+
if (current) clearInterval(current)
45+
46+
entry.tasks.set(task.id, task)
47+
void run(task)
48+
const timer = setInterval(() => {
49+
void run(task)
50+
}, task.interval)
51+
timer.unref()
52+
entry.timers.set(task.id, timer)
53+
}
54+
55+
async function run(task: Task) {
56+
log.info("run", { id: task.id })
57+
await task.run().catch((error) => {
58+
log.error("run failed", { id: task.id, error })
59+
})
60+
}
61+
}

packages/opencode/src/snapshot/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,46 @@ import { Global } from "../global"
66
import z from "zod"
77
import { Config } from "../config/config"
88
import { Instance } from "../project/instance"
9+
import { Scheduler } from "../scheduler"
910

1011
export namespace Snapshot {
1112
const log = Log.create({ service: "snapshot" })
13+
const hour = 60 * 60 * 1000
14+
const prune = "7.days"
15+
16+
export function init() {
17+
Scheduler.register({
18+
id: "snapshot.cleanup",
19+
interval: hour,
20+
run: cleanup,
21+
scope: "instance",
22+
})
23+
}
24+
25+
export async function cleanup() {
26+
if (Instance.project.vcs !== "git") return
27+
const cfg = await Config.get()
28+
if (cfg.snapshot === false) return
29+
const git = gitdir()
30+
const exists = await fs
31+
.stat(git)
32+
.then(() => true)
33+
.catch(() => false)
34+
if (!exists) return
35+
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
36+
.quiet()
37+
.cwd(Instance.directory)
38+
.nothrow()
39+
if (result.exitCode !== 0) {
40+
log.warn("cleanup failed", {
41+
exitCode: result.exitCode,
42+
stderr: result.stderr.toString(),
43+
stdout: result.stdout.toString(),
44+
})
45+
return
46+
}
47+
log.info("cleanup", { prune })
48+
}
1249

1350
export async function track() {
1451
if (Instance.project.vcs !== "git") return

packages/opencode/src/tool/truncation.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import fs from "fs/promises"
22
import path from "path"
33
import { Global } from "../global"
44
import { Identifier } from "../id/id"
5-
import { lazy } from "../util/lazy"
65
import { PermissionNext } from "../permission/next"
76
import type { Agent } from "../agent/agent"
7+
import { Scheduler } from "../scheduler"
88

99
export namespace Truncate {
1010
export const MAX_LINES = 2000
1111
export const MAX_BYTES = 50 * 1024
1212
export const DIR = path.join(Global.Path.data, "tool-output")
1313
export const GLOB = path.join(DIR, "*")
1414
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
15+
const HOUR_MS = 60 * 60 * 1000
1516

1617
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
1718

@@ -21,6 +22,15 @@ export namespace Truncate {
2122
direction?: "head" | "tail"
2223
}
2324

25+
export function init() {
26+
Scheduler.register({
27+
id: "tool.truncation.cleanup",
28+
interval: HOUR_MS,
29+
run: cleanup,
30+
scope: "global",
31+
})
32+
}
33+
2434
export async function cleanup() {
2535
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
2636
const glob = new Bun.Glob("tool_*")
@@ -31,8 +41,6 @@ export namespace Truncate {
3141
}
3242
}
3343

34-
const init = lazy(cleanup)
35-
3644
function hasTaskTool(agent?: Agent.Info): boolean {
3745
if (!agent?.permission) return false
3846
const rule = PermissionNext.evaluate("task", "*", agent.permission)
@@ -81,7 +89,6 @@ export namespace Truncate {
8189
const unit = hitBytes ? "bytes" : "lines"
8290
const preview = out.join("\n")
8391

84-
await init()
8592
const id = Identifier.ascending("tool")
8693
const filepath = path.join(DIR, id)
8794
await Bun.write(Bun.file(filepath), text)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { Scheduler } from "../src/scheduler"
3+
import { Instance } from "../src/project/instance"
4+
import { tmpdir } from "./fixture/fixture"
5+
6+
describe("Scheduler.register", () => {
7+
const hour = 60 * 60 * 1000
8+
9+
test("defaults to instance scope per directory", async () => {
10+
await using one = await tmpdir({ git: true })
11+
await using two = await tmpdir({ git: true })
12+
const runs = { count: 0 }
13+
const id = "scheduler.instance." + Math.random().toString(36).slice(2)
14+
const task = {
15+
id,
16+
interval: hour,
17+
run: async () => {
18+
runs.count += 1
19+
},
20+
}
21+
22+
await Instance.provide({
23+
directory: one.path,
24+
fn: async () => {
25+
Scheduler.register(task)
26+
await Instance.dispose()
27+
},
28+
})
29+
expect(runs.count).toBe(1)
30+
31+
await Instance.provide({
32+
directory: two.path,
33+
fn: async () => {
34+
Scheduler.register(task)
35+
await Instance.dispose()
36+
},
37+
})
38+
expect(runs.count).toBe(2)
39+
})
40+
41+
test("global scope runs once across instances", async () => {
42+
await using one = await tmpdir({ git: true })
43+
await using two = await tmpdir({ git: true })
44+
const runs = { count: 0 }
45+
const id = "scheduler.global." + Math.random().toString(36).slice(2)
46+
const task = {
47+
id,
48+
interval: hour,
49+
run: async () => {
50+
runs.count += 1
51+
},
52+
scope: "global" as const,
53+
}
54+
55+
await Instance.provide({
56+
directory: one.path,
57+
fn: async () => {
58+
Scheduler.register(task)
59+
await Instance.dispose()
60+
},
61+
})
62+
expect(runs.count).toBe(1)
63+
64+
await Instance.provide({
65+
directory: two.path,
66+
fn: async () => {
67+
Scheduler.register(task)
68+
await Instance.dispose()
69+
},
70+
})
71+
expect(runs.count).toBe(1)
72+
})
73+
})

0 commit comments

Comments
 (0)