Skip to content
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ jobs:
# pressure). The robust answer is the dedicated bounded subprocess pass below: `bun run src` (no
# such hang) at --max-concurrency=2, which stays green even under heavy load (~43s locally at
# load 21). Do not reintroduce OPENCODE_TEST_CLI for these tests without fixing that binary hang.
# altimate_change start — deliberate exception: the cold-start-regression job below DOES set
# OPENCODE_TEST_CLI, for exactly one test (test/cli/serve/fresh-start.test.ts). That is safe
# despite the warning above because it is a single bounded cold-start smoke check with its own
# --timeout, not the general run+mock subprocess suite this NOTE is about — the load-triggered
# hang needs sustained CPU pressure across many concurrent mock round-trips to manifest.
# altimate_change end

- name: SDK codegen is reproducible
# The v2 gen tree is committed AND regenerated on every release build
Expand Down Expand Up @@ -650,6 +656,55 @@ jobs:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

# altimate_change start — cold-start config regression, split out of sanity-verdaccio so it also
# runs on PRs. sanity-verdaccio stays push-only by design (Docker Compose, too slow for PRs — see
# its header above); this check only needs the compiled binary, so it gets its own job gated like
# `typescript` above: on PRs that touch TS, and unconditionally on push (safety net).
# ---------------------------------------------------------------------------
cold-start-regression:
name: Cold-start Config Regression
needs: changes
if: needs.changes.outputs.typescript == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2
with:
bun-version: "1.3.14"

- name: Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
bun-${{ runner.os }}-

- name: Install dependencies
run: bun install

- name: Build CLI binary
# target-index=1 = linux-x64 (see release.yml matrix)
run: bun run packages/opencode/script/build.ts --target-index=1
env:
OPENCODE_VERSION: 0.0.0-sanity-${{ github.sha }}
OPENCODE_RELEASE: "1"
ALTIMATE_BASE_GATEWAY_URL: https://gateway.test
MODELS_DEV_API_JSON: test/tool/fixtures/models-api.json

# --version and PURE-mode tests never exercise ordinary config installs. This is the
# deliberate exception to the "no OPENCODE_TEST_CLI" NOTE in the `typescript` job above: a
# single bounded cold-start check with its own --timeout, not the general run+mock subprocess
# suite, so the compiled-binary load hang that NOTE warns about cannot stall CI here.
- name: Cold-start config regression (compiled, non-PURE)
working-directory: packages/opencode
env:
OPENCODE_TEST_CLI: ${{ github.workspace }}/packages/opencode/dist/@altimateai/altimate-code-linux-x64/bin/altimate-code
run: bun test test/cli/serve/fresh-start.test.ts --timeout 90000
# altimate_change end

marker-guard:
name: Marker Guard
runs-on: ubuntu-latest
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ jobs:
# that compile fine but crash at runtime.
- name: Smoke test binary
if: matrix.name == 'linux-x64'
id: smoke-test
run: |
# Resolve to an absolute path before we cd away from the workspace.
# Test `altimate-code` — the binary the platform package actually ships
Expand All @@ -124,6 +125,9 @@ jobs:
exit 1
fi
chmod +x "$BINARY"
# altimate_change — share the resolved path with the cold-start step below so a dist-layout
# change can't make this step pass while the other fails on a stale hardcoded path.
echo "binary=$BINARY" >> "$GITHUB_OUTPUT"

# Run with NO pre-set NODE_PATH AND from a directory with no
# node_modules anywhere upward. Bun's compiled binary would
Expand All @@ -134,6 +138,17 @@ jobs:
env -u NODE_PATH "$BINARY" --version
echo "Smoke test passed: standalone binary starts hermetically"

# altimate_change start — --version and PURE-mode tests never exercise ordinary config installs.
- name: Cold-start config regression (compiled, non-PURE)
if: matrix.name == 'linux-x64'
working-directory: packages/opencode
env:
# Reuse the path the smoke test just resolved via `find` instead of a second hardcoded
# copy of it — a dist-layout change would otherwise break one of these two steps silently.
OPENCODE_TEST_CLI: ${{ steps.smoke-test.outputs.binary }}
run: bun test test/cli/serve/fresh-start.test.ts --timeout 90000
# altimate_change end

- name: Upload build artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
Expand Down
61 changes: 30 additions & 31 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,8 +543,6 @@ export const layer = Layer.effect(
yield* Effect.logDebug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}

const deps: Fiber.Fiber<void>[] = []

for (const dir of directories) {
// altimate_change start - support both .altimate-code and .opencode config dirs
if (dir.endsWith(".altimate-code") || dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
Expand All @@ -570,35 +568,6 @@ export const layer = Layer.effect(

yield* ensureGitignore(dir).pipe(Effect.orDie)

// altimate_change start — upstream_fix: skip the background @opencode-ai/plugin install in
// PURE mode. The compiled CLI in an isolated HOME (subprocess tests / OPENCODE_PURE) has no
// workspace or package cache, so this install fails+retries against the sandbox network and
// waitForDependencies() (Fiber.join) then HANGS the process on exit — every subprocess test
// that runs a prompt times out. PURE already means "no external plugin discovery + install".
if (!Flag.OPENCODE_PURE) {
const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) })
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
}
// altimate_change end

result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
Expand Down Expand Up @@ -702,6 +671,36 @@ export const layer = Layer.effect(
// altimate_change end
}

// altimate_change start — upstream_fix: decide installs only after every config source has
// merged. Inline, account, managed, and later-directory configs can declare a file plugin
// under any earlier directory. Keep the PURE skip and retain fibers for waitForDependencies.
const deps: Fiber.Fiber<void>[] = []
for (const dir of directories) {
if (ConfigPlugin.shouldInstallDependencies(dir, result.plugin)) {
const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) })
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
}
}
// altimate_change end

for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
Expand Down
53 changes: 52 additions & 1 deletion packages/opencode/src/config/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Glob } from "@opencode-ai/core/util/glob"
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { pathToFileURL } from "url"
// altimate_change start — upstream_fix: needsDependencies (below)
import { fileURLToPath, pathToFileURL } from "url"
import { existsSync, realpathSync } from "fs"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Flag } from "@opencode-ai/core/flag/flag"
// altimate_change end
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import path from "path"

Expand Down Expand Up @@ -37,6 +42,52 @@ export function pluginOptions(plugin: ConfigPluginV1.Spec): ConfigPluginV1.Optio
return Array.isArray(plugin) ? plugin[1] : undefined
}

// altimate_change start — upstream_fix: only install @opencode-ai/plugin where something can import it.
// Upstream reifies a ~60-package @npmcli/arborist tree into EVERY config dir on every start, in-process
// (Config and TuiConfig both do it). On a fresh v0.11.0 install (2026-09-09) that saturated Bun's event
// loop: `serve` accepted no HTTP request for 5 minutes and `run` froze for ~2.5 minutes until the install
// finished; the starved EffectFlock heartbeat made the lock look stale, a second waiter stole it, and the
// holder's release died with "metadata missing". The package is only importable by local tool/plugin
// sources and file:// plugins under the dir, so install only for those, or to keep an existing
// node_modules current.
const SOURCE_GLOB = "{tool,tools,plugin,plugins}/*.{js,ts}"

export function needsDependencies(dir: string, plugins: readonly ConfigPluginV1.Spec[] | undefined): boolean {
if (existsSync(path.join(dir, "node_modules"))) return true
try {
if (Glob.scanSync(SOURCE_GLOB, { cwd: dir, dot: true, symlink: true }).length > 0) return true
} catch {
// An unreadable dir cannot hold importable sources; fall through to the declared specs.
}
return (plugins ?? []).some((plugin) => {
const spec = pluginSpecifier(plugin)
if (!spec.startsWith("file://")) return false
try {
const file = fileURLToPath(spec)
try {
// Bun resolves imports through symlinks; compare the locations that will use node_modules.
return FSUtil.contains(realpathSync(dir), realpathSync(file))
} catch {
// Preserve lexical detection for paths that cannot yet be resolved on disk.
return FSUtil.contains(dir, file)
}
} catch {
return false
}
})
}

// PURE mode skips dependency installs entirely: isolated-HOME environments and subprocess tests run
// with no package cache, so the install attempt fails, npm retries, and the process hangs past exit.
// Fold that check in here so both call sites (Config and TuiConfig) stay in sync.
export function shouldInstallDependencies(
dir: string,
plugins: readonly ConfigPluginV1.Spec[] | undefined,
): boolean {
return !Flag.OPENCODE_PURE && needsDependencies(dir, plugins)
}
// altimate_change end

// Path-like specs are resolved relative to the config file that declared them so merges later on do not
// accidentally reinterpret `./plugin.ts` relative to some other directory.
export async function resolvePluginSpec(
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/config/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ export const layer = Layer.effect(
const data = yield* loadState({ directory, worktree })
// altimate_change end
const deps = yield* Effect.forEach(
data.dirs,
// altimate_change start — upstream_fix: same lazy gate as Config; the unconditional in-process
// arborist install froze fresh installs for minutes (see ConfigPlugin.needsDependencies).
data.dirs.filter((dir) => ConfigPlugin.shouldInstallDependencies(dir, data.config.plugin)),
// altimate_change end
(dir) =>
npm
.install(dir, {
Expand Down
84 changes: 84 additions & 0 deletions packages/opencode/test/cli/serve/fresh-start.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// altimate_change start — exercise ordinary cold startup, which the PURE subprocess defaults skip.
import { expect } from "bun:test"
import { existsSync } from "node:fs"
import { mkdir, writeFile } from "node:fs/promises"
import { pathToFileURL } from "node:url"
import path from "node:path"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { cliIt } from "../../lib/cli-process"

cliIt.live(
"fresh non-PURE startup serves config without installing plugin dependencies",
({ home, opencode }) =>
Effect.gen(function* () {
const configDir = path.join(home, ".opencode")
yield* Effect.promise(() => mkdir(configDir))
// An outside, dependency-free plugin makes /provider/auth await Config.waitForDependencies.
// Without this barrier, assertions can race a detached install that has not reached npm yet.
const plugin = path.join(home, "startup-probe.ts")
const loaded = path.join(home, "plugin-loaded")
yield* Effect.promise(() =>
writeFile(
plugin,
`export default async () => { await Bun.write(${JSON.stringify(loaded)}, "ready"); return {} }`,
),
)
const requests: string[] = []
const registry = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
requests.push(new URL(request.url).pathname)
return new Response("Unexpected package installation during fresh startup", { status: 503 })
},
}),
),
(server) => Effect.sync(() => server.stop(true)),
)
const server = yield* opencode.serve({
hostname: "127.0.0.1",
readyTimeoutMs: 30_000,
extraArgs: ["--print-logs"],
env: {
OPENCODE_PURE: "0",
OPENCODE_CONFIG_DIR: configDir,
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [pathToFileURL(plugin).href] }),
OPENCODE_DISABLE_DEFAULT_PLUGINS: "1",
ALTIMATE_TELEMETRY_DISABLED: "1",
npm_config_registry: registry.url.href,
npm_config_fetch_retries: "0",
npm_config_fetch_timeout: "1000",
},
})
const client = yield* HttpClient.HttpClient
for (const route of ["/config", "/provider/auth", "/provider", "/global/health"]) {
yield* Effect.gen(function* () {
const response = yield* client.get(`${server.url}${route}`)
expect(response.status).toBe(200)
yield* response.json
}).pipe(Effect.timeout("15 seconds"))
}
expect(existsSync(loaded)).toBe(true)
// Prove config loading ran, then reject installation even if it failed quickly instead of hanging.
expect(existsSync(path.join(configDir, ".gitignore"))).toBe(true)
// Stop first: stderr is only complete once the child exited and the drain fiber joined.
yield* server.stop()
const stderr = yield* server.stderr().pipe(Effect.timeout("10 seconds"))
expect(requests).toEqual([])
expect(stderr).not.toContain("background dependency install failed")
for (const dir of [
configDir,
path.join(home, ".config", "altimate-code"),
path.join(home, ".config", "opencode"),
]) {
for (const artifact of ["node_modules", "package.json", "package-lock.json"]) {
expect(existsSync(path.join(dir, artifact))).toBe(false)
}
}
}),
90_000,
)
// altimate_change end
Loading
Loading