fix(auto-update): break successful install restart loop

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-25 11:20:58 +09:00
parent 9fd52cb609
commit a5c759b5ff
2 changed files with 86 additions and 3 deletions
@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync } from "node:fs"
import { join } from "node:path"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { runBunInstallWithDetails } from "../../../cli/config-manager"
import { log } from "../../../shared/logger"
import { getOpenCodeCacheDir, getOpenCodeConfigPaths } from "../../../shared"
@@ -12,6 +12,10 @@ import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
type BackgroundUpdateCheckDeps = {
existsSync: typeof existsSync
mkdirSync: typeof mkdirSync
readFileSync: typeof readFileSync
writeFileSync: typeof writeFileSync
dirname: typeof dirname
join: typeof join
runBunInstallWithDetails: typeof runBunInstallWithDetails
log: typeof log
@@ -25,6 +29,12 @@ type BackgroundUpdateCheckDeps = {
syncCachePackageJsonToIntent: typeof syncCachePackageJsonToIntent
showUpdateAvailableToast: typeof showUpdateAvailableToast
showAutoUpdatedToast: typeof showAutoUpdatedToast
now: () => number
}
type LastUpdatedMarker = {
readonly version: string
readonly updatedAt: number
}
type BackgroundUpdateCheckRunner = (
@@ -39,6 +49,10 @@ function getCacheWorkspaceDir(deps: BackgroundUpdateCheckDeps): string {
const defaultDeps: BackgroundUpdateCheckDeps = {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
dirname,
join,
runBunInstallWithDetails,
log,
@@ -52,12 +66,54 @@ const defaultDeps: BackgroundUpdateCheckDeps = {
syncCachePackageJsonToIntent,
showUpdateAvailableToast,
showAutoUpdatedToast,
now: () => Date.now(),
}
const LAST_UPDATED_MARKER_FILE = ".last-updated"
const RECENT_INSTALL_WINDOW_MS = 5 * 60 * 1000
function getPinnedVersionToastMessage(latestVersion: string): string {
return `Update available: ${latestVersion} (version pinned, update manually)`
}
function getLastUpdatedMarkerPath(deps: BackgroundUpdateCheckDeps): string {
return deps.join(deps.getOpenCodeCacheDir(), LAST_UPDATED_MARKER_FILE)
}
function parseLastUpdatedMarker(rawMarker: string): LastUpdatedMarker | null {
const marker: unknown = JSON.parse(rawMarker)
if (typeof marker !== "object" || marker === null) return null
if (!("version" in marker) || typeof marker.version !== "string") return null
if (!("updatedAt" in marker) || typeof marker.updatedAt !== "number") return null
return { version: marker.version, updatedAt: marker.updatedAt }
}
function wasRecentlyInstalled(version: string, deps: BackgroundUpdateCheckDeps): boolean {
try {
const markerPath = getLastUpdatedMarkerPath(deps)
if (!deps.existsSync(markerPath)) return false
const marker = parseLastUpdatedMarker(deps.readFileSync(markerPath, "utf-8"))
if (!marker) return false
if (marker.version !== version) return false
return deps.now() - marker.updatedAt < RECENT_INSTALL_WINDOW_MS
} catch (err) {
deps.log("[auto-update-checker] Failed to read last update marker:", err)
return false
}
}
function writeLastUpdatedMarker(version: string, deps: BackgroundUpdateCheckDeps): void {
try {
const markerPath = getLastUpdatedMarkerPath(deps)
deps.mkdirSync(deps.dirname(markerPath), { recursive: true })
deps.writeFileSync(markerPath, JSON.stringify({ version, updatedAt: deps.now() }, null, 2))
} catch (err) {
deps.log("[auto-update-checker] Failed to write last update marker:", err)
}
}
/**
* Resolves the active install workspace.
* Same logic as doctor check: prefer config-dir if installed, fall back to cache-dir.
@@ -153,6 +209,11 @@ export function createBackgroundUpdateCheckRunner(
return
}
if (wasRecentlyInstalled(latestVersion, deps)) {
deps.log(`[auto-update-checker] Skipping update check; ${latestVersion} was installed recently`)
return
}
deps.log(`[auto-update-checker] Update available (${channel}): ${currentVersion}${latestVersion}`)
if (!autoUpdate) {
@@ -186,6 +247,7 @@ export function createBackgroundUpdateCheckRunner(
return
}
writeLastUpdatedMarker(latestVersion, deps)
await deps.showAutoUpdatedToast(ctx, currentVersion, latestVersion)
deps.log(`[auto-update-checker] Update installed: ${currentVersion}${latestVersion}`)
return
@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginEntryInfo } from "../auto-update-checker/checker"
@@ -191,4 +191,25 @@ describe("workspace resolution", () => {
expect(mockRunBunInstallWithDetails.mock.calls[0]?.[0]?.workspaceDir).toBe(TEST_CONFIG_DIR)
expect(mockRunBunInstallWithDetails.mock.calls[1]?.[0]?.workspaceDir).toBe(TEST_CACHE_WORKSPACE_DIR)
})
it("#given same version was just installed #when next startup checks again #then it skips reinstall loop", async () => {
// #given
const firstRun = await createRunner()
mkdirSync(TEST_CACHE_WORKSPACE_DIR, { recursive: true })
writeFileSync(join(TEST_CACHE_WORKSPACE_DIR, "package.json"), JSON.stringify({ dependencies: { [PACKAGE_NAME]: "3.4.0" } }, null, 2))
// #when
await firstRun(mockCtx, true, getToastMessage)
const markerPath = join(TEST_CACHE_DIR, ".last-updated")
const secondRun = await createRunner()
await secondRun(mockCtx, true, getToastMessage)
// #then
expect(readFileSync(markerPath, "utf-8")).toContain('"version": "3.5.0"')
expect(mockRunBunInstallWithDetails).toHaveBeenCalledTimes(1)
expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1)
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockShowAutoUpdatedToast).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
})
})