Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions packages/opencode/src/tool/truncate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { Agent } from "../agent/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { evaluate } from "@/permission/evaluate"
import { Config } from "@/config/config"
import { Identifier } from "../id/id"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"

Expand Down Expand Up @@ -52,17 +51,28 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service

const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
)
// altimate_change start — upstream_fix: age files by mtime, not decoded ID timestamps.
// Identifier packs `timestamp * 4096` into 48 bits, which wraps every
// 2^36 ms (~795 days) — the 26th wrap landed 2026-08-14T11:19:55Z, after
// which every new ID decoded as "ancient" and cleanup deleted files the
// moment they were written. File mtime has no wrap.
const cutoffMs = 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))
const file = path.join(TRUNCATION_DIR, entry)
// Stat through the INJECTED filesystem (FSUtil extends FileSystem) so
// custom/in-memory providers behave identically to the host FS.
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
Comment thread
anandgupta42 marked this conversation as resolved.
// Unstat-able file or absent mtime: keep it — deletion must fail safe.
const mtimeMs =
info && Option.isSome(info.mtime) ? info.mtime.value.getTime() : Number.POSITIVE_INFINITY
if (mtimeMs >= cutoffMs) continue
yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
}
// altimate_change end
})

const write = Effect.fn("Truncate.write")(function* (text: string) {
Expand Down
27 changes: 15 additions & 12 deletions packages/opencode/src/tool/truncation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
Expand Down Expand Up @@ -35,20 +34,24 @@ export namespace Truncate {
}

export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", "ascending", Date.now() - RETENTION_MS))
// altimate_change start — upstream_fix: age files by mtime, not decoded ID
// timestamps. Identifier packs `timestamp * 4096` into 48 bits, wrapping
// every 2^36 ms (~795 days; the 26th wrap: 2026-08-14T11:19:55Z), after
// which every new ID decoded as "ancient" and cleanup deleted files the
// moment they were written. File mtime has no wrap. Stat failures keep
// the file — deletion must fail safe.
const cutoffMs = Date.now() - RETENTION_MS
const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
for (const entry of entries) {
// altimate_change start - tolerate stale/malformed tool-output files from older builds.
let timestamp: number
try {
timestamp = Identifier.timestamp(entry)
} catch {
continue
}
if (timestamp >= cutoff) continue
// altimate_change end
await fs.unlink(path.join(DIR, entry)).catch(() => {})
const file = path.join(DIR, entry)
const mtimeMs = await fs
.stat(file)
.then((st) => st.mtimeMs)
.catch(() => Number.POSITIVE_INFINITY)
if (mtimeMs >= cutoffMs) continue
await fs.unlink(file).catch(() => {})
}
// altimate_change end
}

function hasTaskTool(agent?: Agent.Info): boolean {
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/test/lib/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,12 @@ export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(fun
yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
return file
})

/** Create a symlink whose removal is guaranteed by the test scope, even when
* an assertion fails mid-test (finalizers run on scope close regardless). */
export const symlinkScoped = Effect.fn("test.symlinkScoped")(function* (target: string, link: string) {
const fs = yield* FileSystem.FileSystem
yield* fs.symlink(target, link)
yield* Effect.addFinalizer(() => fs.remove(link, { force: true }).pipe(Effect.orDie))
return link
})
24 changes: 21 additions & 3 deletions packages/opencode/test/tool/truncation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Identifier } from "../../src/id/id"
import { Process } from "@/util/process"
import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
import { symlinkScoped, writeFileStringScoped } from "../lib/filesystem"
import { TestConfig } from "../fixture/config"

const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
Expand Down Expand Up @@ -251,15 +251,33 @@ describe("Truncate", () => {

yield* fs.makeDirectory(Truncate.DIR, { recursive: true })

const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 10 * DAY_MS))
const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 3 * DAY_MS))
// Age is judged by file mtime (ID-embedded timestamps wrap every
// ~795 days — see Truncate.cleanup), so set mtimes explicitly.
const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending"))
const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending"))
// Dangling symlink: listed by readDirectory, but stat fails — the
// fail-safe branch must KEEP it rather than delete on uncertainty.
const dangling = path.join(Truncate.DIR, Identifier.create("tool", "ascending"))

yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content")
const nfs = yield* Effect.promise(() => import("node:fs/promises"))
// Scoped: the finalizer unlinks it even when an assertion fails —
// otherwise a failed expect would leak the link into the real data
// dir, where the fail-safe under test deliberately keeps it forever.
yield* symlinkScoped(path.join(Truncate.DIR, "nonexistent-target"), dangling)
const oldTime = new Date(Date.now() - 10 * DAY_MS)
const recentTime = new Date(Date.now() - 3 * DAY_MS)
yield* Effect.promise(() => nfs.utimes(old, oldTime, oldTime))
yield* Effect.promise(() => nfs.utimes(recent, recentTime, recentTime))
yield* svc.cleanup()

expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)
// lstat: fs.exists follows symlinks and would report false for a
// dangling link even when the link itself survived.
const danglingKept = yield* Effect.promise(() => nfs.lstat(dangling).then(() => true).catch(() => false))
expect(danglingKept).toBe(true)
}),
)
})
Expand Down
Loading