From e95ce308b48b4d805d7148d7401c0e331028d498 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 17 Aug 2026 12:56:49 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20age=20truncation=20files=20by=20mtim?= =?UTF-8?q?e=20=E2=80=94=20Identifier=2048-bit=20timestamp=20wrapped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Identifier.create` packs `timestamp * 4096 + counter` into 6 bytes, wrapping every 2^36 ms (~795.4 days). The 26th wrap since epoch landed 2026-08-14T11:19:55Z: post-wrap IDs decode to tiny timestamps, so both truncation cleanups computed a pre-wrap cutoff astronomically larger than every new file's decoded timestamp — every truncated tool output written after Aug 14 was deleted the moment cleanup ran, and the `Truncate > cleanup` test failed on every CI run since Aug 17 on unchanged code. Both cleanups (`tool/truncate.ts` Effect service and `tool/truncation.ts` legacy module, used by bootstrap/bash/prompt) now age files by mtime, which does not wrap; stat failures keep the file (deletion fails safe). Tagged `upstream_fix` — the wrap-prone encoding is upstream OpenCode code. Test updated to set explicit mtimes instead of ID-embedded timestamps. Closes #1112 Co-Authored-By: Claude Fable 5 --- packages/opencode/src/tool/truncate.ts | 21 ++++++++++----- packages/opencode/src/tool/truncation.ts | 27 ++++++++++--------- .../opencode/test/tool/truncation.test.ts | 9 +++++-- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 236f9b8a12..b21a05a0bf 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,27 @@ 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) + const mtimeMs = yield* Effect.tryPromise(() => import("node:fs/promises").then((nfs) => nfs.stat(file))).pipe( + Effect.map((st) => st.mtimeMs), + // Unstat-able file: keep it — deletion must fail safe. + Effect.catch(() => Effect.succeed(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/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 6e65b5f54c..1937f5e8f3 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -251,11 +251,16 @@ 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")) yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(recent, "recent content") + const nfs = yield* Effect.promise(() => import("node:fs/promises")) + yield* Effect.promise(() => nfs.utimes(old, new Date(Date.now() - 10 * DAY_MS), new Date(Date.now() - 10 * DAY_MS))) + yield* Effect.promise(() => nfs.utimes(recent, new Date(Date.now() - 3 * DAY_MS), new Date(Date.now() - 3 * DAY_MS))) yield* svc.cleanup() expect(yield* fs.exists(old)).toBe(false) From c51ce483830540953d0bd1fb0c2d5eaa532a1162 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 17 Aug 2026 13:08:24 -0700 Subject: [PATCH 2/5] fix: stat through the injected filesystem in Truncate.cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review catch on #1113: the Effect-service cleanup statted the host Node filesystem while every other operation used the injected `FSUtil.Service` — files present only in a custom/in-memory provider would hit the fail-safe infinite-mtime branch and never clean. `FSUtil` extends platform `FileSystem`, so `fs.stat` is available on the injected service; `FileInfo.mtime` is an `Option`, and an absent mtime keeps the file (deletion fails safe). Co-Authored-By: Claude Fable 5 --- packages/opencode/src/tool/truncate.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index b21a05a0bf..81fdfa2b3e 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -63,11 +63,12 @@ export const layer = Layer.effect( ) for (const entry of entries) { const file = path.join(TRUNCATION_DIR, entry) - const mtimeMs = yield* Effect.tryPromise(() => import("node:fs/promises").then((nfs) => nfs.stat(file))).pipe( - Effect.map((st) => st.mtimeMs), - // Unstat-able file: keep it — deletion must fail safe. - Effect.catch(() => Effect.succeed(Number.POSITIVE_INFINITY)), - ) + // 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)) } From 439a145f12f72e4f6c8ed6950a12e5a5f8792a24 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 17 Aug 2026 13:22:31 -0700 Subject: [PATCH 3/5] test: cover cleanup's stat-failure fail-safe; hoist duplicated dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dangling symlink is listed by readDirectory but fails stat — the fail-safe branch must keep it rather than delete on uncertainty; asserted via lstat (fs.exists follows links and would miss a surviving dangling link). Duplicated `new Date(Date.now() - …)` constructions hoisted. Co-Authored-By: Claude Fable 5 --- packages/opencode/test/tool/truncation.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 1937f5e8f3..484aeb186f 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -255,16 +255,27 @@ describe("Truncate", () => { // ~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")) - yield* Effect.promise(() => nfs.utimes(old, new Date(Date.now() - 10 * DAY_MS), new Date(Date.now() - 10 * DAY_MS))) - yield* Effect.promise(() => nfs.utimes(recent, new Date(Date.now() - 3 * DAY_MS), new Date(Date.now() - 3 * DAY_MS))) + yield* Effect.promise(() => nfs.symlink(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) + yield* Effect.promise(() => nfs.unlink(dangling).catch(() => {})) }), ) }) From 78a9ddc6aa9f21451b35e07973ef801fb8794f10 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 17 Aug 2026 13:36:57 -0700 Subject: [PATCH 4/5] test: scope the dangling-symlink teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviewers converged: the symlink's unlink ran only on the success path — a failed assertion would leak it into the real data dir, where the fail-safe under test deliberately keeps it forever. New `symlinkScoped` helper (mirroring `writeFileStringScoped`) unlinks via a scope finalizer, which runs regardless of assertion outcome. Co-Authored-By: Claude Fable 5 --- packages/opencode/test/lib/filesystem.ts | 10 ++++++++++ packages/opencode/test/tool/truncation.test.ts | 8 +++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/lib/filesystem.ts b/packages/opencode/test/lib/filesystem.ts index 66f702ec3d..4a4c936aad 100644 --- a/packages/opencode/test/lib/filesystem.ts +++ b/packages/opencode/test/lib/filesystem.ts @@ -8,3 +8,13 @@ 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) { + yield* Effect.promise(() => import("node:fs/promises").then((nfs) => nfs.symlink(target, link))) + yield* Effect.addFinalizer(() => + Effect.promise(() => import("node:fs/promises").then((nfs) => nfs.unlink(link).catch(() => {}))), + ) + return link +}) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 484aeb186f..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") @@ -262,7 +262,10 @@ describe("Truncate", () => { yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(recent, "recent content") const nfs = yield* Effect.promise(() => import("node:fs/promises")) - yield* Effect.promise(() => nfs.symlink(path.join(Truncate.DIR, "nonexistent-target"), dangling)) + // 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)) @@ -275,7 +278,6 @@ describe("Truncate", () => { // dangling link even when the link itself survived. const danglingKept = yield* Effect.promise(() => nfs.lstat(dangling).then(() => true).catch(() => false)) expect(danglingKept).toBe(true) - yield* Effect.promise(() => nfs.unlink(dangling).catch(() => {})) }), ) }) From b88f3255c3516bc44a1abcc46d883a4dff02b38d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 17 Aug 2026 13:54:15 -0700 Subject: [PATCH 5/5] test: symlinkScoped uses the injected FileSystem service Consistent with its sibling `writeFileStringScoped`: single service acquisition, no raw `node:fs/promises` dynamic imports; removal via `fs.remove(force)` in the scope finalizer. Co-Authored-By: Claude Fable 5 --- packages/opencode/test/lib/filesystem.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/lib/filesystem.ts b/packages/opencode/test/lib/filesystem.ts index 4a4c936aad..ba4eac1599 100644 --- a/packages/opencode/test/lib/filesystem.ts +++ b/packages/opencode/test/lib/filesystem.ts @@ -12,9 +12,8 @@ export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(fun /** 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) { - yield* Effect.promise(() => import("node:fs/promises").then((nfs) => nfs.symlink(target, link))) - yield* Effect.addFinalizer(() => - Effect.promise(() => import("node:fs/promises").then((nfs) => nfs.unlink(link).catch(() => {}))), - ) + const fs = yield* FileSystem.FileSystem + yield* fs.symlink(target, link) + yield* Effect.addFinalizer(() => fs.remove(link, { force: true }).pipe(Effect.orDie)) return link })