diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 236f9b8a12..81fdfa2b3e 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -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" @@ -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))) + // 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) { diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 34370624a4..fbd92b1d7c 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -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" @@ -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 { diff --git a/packages/opencode/test/lib/filesystem.ts b/packages/opencode/test/lib/filesystem.ts index 66f702ec3d..ba4eac1599 100644 --- a/packages/opencode/test/lib/filesystem.ts +++ b/packages/opencode/test/lib/filesystem.ts @@ -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 +}) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 6e65b5f54c..450559c7d2 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -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") @@ -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) }), ) })