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
68 changes: 68 additions & 0 deletions packages/opencode/src/cli/cmd/serve-upgrade-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// altimate_change start — self-update trigger for headless serve
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
import { upgrade } from "../upgrade"
import { Log } from "../../util/log"

const log = Log.create({ service: "serve" })

/** Delay before the one-shot startup check, letting the listener settle first. */
export const STARTUP_UPGRADE_DELAY_MS = 1000

/**
* Collaborators for {@link runStartupUpgradeCheck}, injectable for tests.
*
* Injected rather than module-mocked on purpose: bun's `mock.module` is
* process-global, so mocking `../upgrade` / `../../project/instance` here would
* clobber those modules for every other test in the run (e.g. blow away
* `cli/upgrade`'s `compareVersions`/`isValidVersion` exports).
*/
export interface StartupUpgradeDeps {
/**
* Runs `fn` inside an ambient `Instance` for `directory` and, like the TUI
* worker, does NOT dispose it (see note on the default below).
*/
provide: (directory: string, fn: () => Promise<unknown>) => Promise<unknown>
/** The upgrade check itself. */
run: () => Promise<unknown>
}

const defaultDeps: StartupUpgradeDeps = {
// Mirror the TUI worker (cli/cmd/tui/worker.ts → checkUpgrade): provide an
// Instance context for upgrade() — Bus.publish needs one — but never dispose.
//
// We use the same process.cwd() key the server's default-directory requests
// use (server/server.ts:196), so we reuse/seed that shared cached instance and
// Bus notifications still reach default-directory SSE subscribers. Crucially we
// do NOT dispose: an earlier version wrapped this in bootstrap(), whose
// finally → Instance.dispose() tears down the entire process.cwd() bucket —
// including state created by concurrent server requests that defaulted to that
// directory (use-after-dispose / needless churn). The worker avoids this by
// running in a separate thread; in-process we avoid it by not disposing.
provide: (directory, fn) => Instance.provide({ directory, init: InstanceBootstrap, fn }),
run: upgrade,
}

/**
* Runs a single best-effort upgrade check. Resolves, never rejects, via two
* layers: the inner `.catch` swallows any error from `run()` (`upgrade()` can
* throw, e.g. from `Config.global()` / `Installation.method()`), and the outer
* try/catch guards an `Instance.provide` (bootstrap) failure. A flaky
* network/registry therefore can't take the server down. Errors are passed as
* `Error` objects so `Log` formats the message + `cause` chain.
*/
export async function runStartupUpgradeCheck(deps: StartupUpgradeDeps = defaultDeps): Promise<void> {
try {
await deps.provide(process.cwd(), () =>
deps.run().catch((err) => log.error("startup upgrade check failed", { error: err })),
)
} catch (err) {
log.error("startup upgrade instance failed", { error: err })
}
}

/** Schedules {@link runStartupUpgradeCheck} after a short settle delay; non-blocking. */
export function scheduleStartupUpgradeCheck(): void {
setTimeout(() => void runStartupUpgradeCheck(), STARTUP_UPGRADE_DELAY_MS).unref?.()
}
// altimate_change end
17 changes: 14 additions & 3 deletions packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import { Workspace } from "../../control-plane/workspace"
import { Project } from "../../project/project"
import { Installation } from "../../installation"
// altimate_change start — trace: session tracing in headless serve
import { subscribeTraceConsumer } from "../../altimate/observability/trace-consumer"
// altimate_change end
// altimate_change start — self-update on headless serve startup
import { scheduleStartupUpgradeCheck } from "./serve-upgrade-check"
// altimate_change end
Comment on lines +8 to +10

export const ServeCommand = cmd({
command: "serve",
Expand Down Expand Up @@ -35,6 +35,17 @@ export const ServeCommand = cmd({
// (`tracing.dir`, default ~/.local/share/altimate-code/traces/).
const traceSub = subscribeTraceConsumer({ directory: process.cwd() })

// altimate_change start — self-update on startup
// A headless `serve` is how the VS Code / Cursor extension runs
// altimate-code, and it is the ONLY long-running entrypoint that never
// checked for updates: auto-update was wired solely into the TUI bootstrap
// (cli/cmd/tui/thread.ts → worker.checkUpgrade → upgrade()). As a result the
// extension fleet froze at whatever version was installed at onboarding.
// Fire the missing trigger here; see serve-upgrade-check.ts for why it runs
// in (but never disposes) the process.cwd() instance.
scheduleStartupUpgradeCheck()
// altimate_change end

// Finalize traces on shutdown. `serve` blocks forever on the promise below
// and otherwise dies abruptly on signal, so without these handlers the
// consumer's stop()/flush()/endTrace() never runs and serve traces are
Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/test/cli/serve-upgrade-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { Log } from "../../src/util/log"
import { runStartupUpgradeCheck, STARTUP_UPGRADE_DELAY_MS, type StartupUpgradeDeps } from "../../src/cli/cmd/serve-upgrade-check"

Log.init({ print: false })

// No mock.module here: it is process-global in bun and would clobber
// ../upgrade / ../../project/instance for every other test in the run. The
// collaborators are injected instead.
describe("serve-upgrade-check", () => {
let runCalls: number
let runShouldThrow: boolean
let provideCalls: number
let provideDirectory: string | undefined

function makeDeps(): StartupUpgradeDeps {
return {
provide: async (directory, fn) => {
provideCalls++
provideDirectory = directory
return fn()
},
run: async () => {
runCalls++
if (runShouldThrow) throw new Error("boom")
},
}
}

beforeEach(() => {
runCalls = 0
runShouldThrow = false
provideCalls = 0
provideDirectory = undefined
})

test("runs upgrade() once inside the process.cwd() instance", async () => {
await runStartupUpgradeCheck(makeDeps())
expect(runCalls).toBe(1)
expect(provideCalls).toBe(1)
expect(provideDirectory).toBe(process.cwd())
})

test("resolves without throwing when upgrade() rejects", async () => {
runShouldThrow = true
// Must not reject — a flaky network/registry can't take the server down.
await expect(runStartupUpgradeCheck(makeDeps())).resolves.toBeUndefined()
expect(runCalls).toBe(1)
})

test("resolves without throwing when provide() itself rejects", async () => {
const deps: StartupUpgradeDeps = {
provide: async () => {
throw new Error("instance boom")
},
run: async () => {
runCalls++
},
}
await expect(runStartupUpgradeCheck(deps)).resolves.toBeUndefined()
expect(runCalls).toBe(0)
})

test("uses a short, sane settle delay", () => {
expect(STARTUP_UPGRADE_DELAY_MS).toBeGreaterThan(0)
expect(STARTUP_UPGRADE_DELAY_MS).toBeLessThanOrEqual(5000)
})
})
Loading