Skip to content
Open
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
4 changes: 4 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ if (process.versions.bun !== expectedBunVersion) {
}
'
bun typecheck
# altimate_change — #1052 D8: scan pushed content for internal-tracker refs
# (see RULES in the script for the specific patterns). Silent on clean;
# exits 1 on hit. Bypass with SKIP_TRACKER_CHECK=1 for genuine emergencies.
bun script/check-tracker-leaks.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The pre-push hook can approve one ref while pushing another because it never passes the hook's pushed ref/update data to the scanner; the scanner always examines the current HEAD. A command such as git push origin feature from a clean main can therefore publish an unchecked branch. Reading the pre-push stdin tuples and scanning each pushed local OID (or restricting the hook to the checked-out ref) would align the check with the actual push.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .husky/pre-push, line 24:

<comment>The pre-push hook can approve one ref while pushing another because it never passes the hook's pushed ref/update data to the scanner; the scanner always examines the current `HEAD`. A command such as `git push origin feature` from a clean `main` can therefore publish an unchecked branch. Reading the pre-push stdin tuples and scanning each pushed local OID (or restricting the hook to the checked-out ref) would align the check with the actual push.</comment>

<file context>
@@ -18,3 +18,7 @@ if (process.versions.bun !== expectedBunVersion) {
+# altimate_change — #1052 D8: scan pushed content for internal-tracker refs
+# (see RULES in the script for the specific patterns). Silent on clean;
+# exits 1 on hit. Bypass with SKIP_TRACKER_CHECK=1 for genuine emergencies.
+bun script/check-tracker-leaks.ts
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The guard silently disables itself when the local base ref is missing: check-tracker-leaks.ts computes base from a hardcoded local origin/main (the pre-push hook passes no --base), and its shOK() swallows git merge-base failures, so when that ref isn't present on the developer machine the script returns a silent no-op success with no warning that the tracker scan was skipped. That leaves a false sense of security for a guard whose whole purpose is leak prevention. Consider passing the actual base and failing closed (or emitting a clear warning) when the merge-base can't be resolved instead of silently returning 0.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .husky/pre-push, line 24:

<comment>The guard silently disables itself when the local base ref is missing: check-tracker-leaks.ts computes base from a hardcoded local `origin/main` (the pre-push hook passes no `--base`), and its shOK() swallows `git merge-base` failures, so when that ref isn't present on the developer machine the script returns a silent no-op success with no warning that the tracker scan was skipped. That leaves a false sense of security for a guard whose whole purpose is leak prevention. Consider passing the actual base and failing closed (or emitting a clear warning) when the merge-base can't be resolved instead of silently returning 0.</comment>

<file context>
@@ -18,3 +18,7 @@ if (process.versions.bun !== expectedBunVersion) {
+# altimate_change — #1052 D8: scan pushed content for internal-tracker refs
+# (see RULES in the script for the specific patterns). Silent on clean;
+# exits 1 on hit. Bypass with SKIP_TRACKER_CHECK=1 for genuine emergencies.
+bun script/check-tracker-leaks.ts
</file context>

2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ https://github.com/anomalyco/models.dev
bun dev
```

`bun install` sets up the pre-push hook via `husky` — subsequent `git push` runs a bun-version check, `bun typecheck`, and a scan for internal-tracker references. If the tracker scan blocks you legitimately (extremely unlikely on this public repo), bypass with `SKIP_TRACKER_CHECK=1 git push`.

### Running against a different directory

By default, `bun dev` runs Altimate Code in the `packages/opencode` directory. To run it against a different directory or repository:
Expand Down
118 changes: 118 additions & 0 deletions packages/opencode/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import path from "path"
import { fileURLToPath } from "url"
import { createRequire } from "node:module"
import solidPlugin from "@opentui/solid/bun-plugin"
// altimate_change — #1052 D10: sha256 for the per-target build-inputs stamp.
import { createHash } from "node:crypto"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
Expand Down Expand Up @@ -523,6 +525,122 @@ for (const item of targets) {
2,
),
)

// altimate_change start — #1052 D10: emit a build-inputs stamp so the
// smoke-test staleness guard can compare against ALL binary-embedded inputs,
// not just src/ + script/ mtimes.
//
// The previous guard (m5) walked src/ + script/ for the newest mtime — good
// for the common case but blind to changes in CHANGELOG.md, migrations,
// bundled skills, the models.dev snapshot, the parser worker, and the
// per-platform altimate-core prebuild. Editing any of those without touching
// a .ts file would leave the guard silent and the binary silently stale.
//
// Stamp format: JSON with one entry per input, sha256 of file content. Read
// side rehashes each listed path and compares; any mismatch → stale. Paths
// are REPO_ROOT-relative so entries under packages/tui, packages/core, the
// workspace-root package.json, bun.lock, etc. resolve without munging.
const REPO_ROOT = path.resolve(dir, "../..")
const stampInputs: Array<{ path: string; sha256: string }> = []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const addFile = (absPath: string) => {
try {
const buf = fs.readFileSync(absPath)
const rel = path.relative(REPO_ROOT, absPath)
const hash = createHash("sha256").update(buf).digest("hex")
stampInputs.push({ path: rel, sha256: hash })
} catch {
// Missing file: silently skip. The stamp only covers what actually
// shipped; a file the build didn't need doesn't invalidate the guard.
}
}
// CHANGELOG.md
addFile(changelogPath)
// Migrations
for (const m of migrationDirs) addFile(path.join(dir, "migration", m, "migration.sql"))
// Skills bundled via .opencode/skills/
for (const entry of skillEntries) addFile(path.join(skillsRoot, entry.name, "SKILL.md"))
// Generated models snapshot (build.ts rewrote it before we got here)
addFile(path.join(dir, "src/provider/models-snapshot.ts"))
// opentui parser worker
addFile(parserWorker)
// Per-target altimate-core NAPI prebuild
addFile(platformNodeSrc)
// altimate_change — #1052 D10 review-fix (M2): package.json + bun.lock cover
// dependency-version bumps that change what Bun.build embeds. Without these,
// `bun install` bumping a bundled dep would leave the stamp reporting fresh.
// Include per-package package.json in the workspace walk below.
addFile(path.join(REPO_ROOT, "package.json"))
addFile(path.join(REPO_ROOT, "bun.lock"))
// Also include tsconfig files that affect compiled output shape
// (bot review: tsconfig changes can flip target/moduleResolution).
addFile(path.join(dir, "tsconfig.json"))
// src/ + script/ TypeScript tree — hash every file the compiler actually saw
// (same extension filter build.ts globs for embedding).
const IGNORED = new Set(["node_modules", ".turbo", ".cache", "dist", "target"])
const walk = (root: string): void => {
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(root, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
if (IGNORED.has(entry.name)) continue
const full = path.join(root, entry.name)
if (entry.isDirectory()) {
walk(full)
continue
}
if (!/\.(tsx?|json|txt|md)$/.test(entry.name)) continue
addFile(full)
}
}
walk(path.join(dir, "src"))
walk(path.join(dir, "script"))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// altimate_change — #1052 D10 review-fix (M2): also hash every workspace
// package's src/ tree. `packages/opencode/src` imports from
// `@opencode-ai/{core,tui,util,plugin,sdk,server,cli,...}` and
// `@altimateai/{dbt-tools,drivers}` — Bun.build follows these imports and
// bundles them into the binary transitively. The original stamp walked only
// packages/opencode, so edits under any sibling workspace package would leave
// the binary silently stale. Enumerate `packages/*/src` at build time (rather
// than hard-coding names) so new packages get covered automatically.
const packagesRoot = path.resolve(REPO_ROOT, "packages")
try {
for (const pkg of fs.readdirSync(packagesRoot, { withFileTypes: true })) {
if (!pkg.isDirectory() || pkg.name.startsWith(".")) continue
// Skip packages/opencode — already covered by the walks above.
if (pkg.name === "opencode") continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Changes to this package’s imports or other bundler-relevant manifest fields leave the stamp fresh and allow the smoke test to run an outdated binary; stamp packages/opencode/package.json before skipping its source tree.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/script/build.ts, line 616:

<comment>Changes to this package’s `imports` or other bundler-relevant manifest fields leave the stamp fresh and allow the smoke test to run an outdated binary; stamp `packages/opencode/package.json` before skipping its source tree.</comment>

<file context>
@@ -523,6 +525,124 @@ for (const item of targets) {
+    for (const pkg of fs.readdirSync(packagesRoot, { withFileTypes: true })) {
+      if (!pkg.isDirectory() || pkg.name.startsWith(".")) continue
+      // Skip packages/opencode — already covered by the walks above.
+      if (pkg.name === "opencode") continue
+      const pkgSrc = path.join(packagesRoot, pkg.name, "src")
+      if (fs.existsSync(pkgSrc)) walk(pkgSrc)
</file context>
Suggested change
if (pkg.name === "opencode") continue
if (pkg.name === "opencode") {
addFile(path.join(packagesRoot, pkg.name, "package.json"))
continue
}

const pkgSrc = path.join(packagesRoot, pkg.name, "src")
if (fs.existsSync(pkgSrc)) walk(pkgSrc)
// Each workspace package.json influences its resolution/exports and could
// change what ends up in the binary even when its src/ files are unchanged.
const pkgJson = path.join(packagesRoot, pkg.name, "package.json")
if (fs.existsSync(pkgJson)) addFile(pkgJson)
}
} catch {
// packages/ missing (unlikely at build time) — skip; addFile() ignores non-existent paths anyway.
}
// Deterministic order so the aggregate hash is stable across build runs.
stampInputs.sort((a, b) => a.path.localeCompare(b.path))
const aggregate = createHash("sha256")
.update(stampInputs.map((i) => `${i.path}\t${i.sha256}`).join("\n"))
.digest("hex")
await Bun.file(`dist/${name}/bin/build-inputs.json`).write(
JSON.stringify(
{
target: name,
version: Script.version,
aggregate,
inputs: stampInputs,
},
null,
2,
),
)
// altimate_change end

binaries[name] = Script.version
}

Expand Down
77 changes: 60 additions & 17 deletions packages/opencode/src/provider/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,35 @@ export namespace ModelsDev {
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
if (result) return result
const result2 = await fetchApi()
if (result2.ok) {
await Filesystem.write(filepath, result2.text).catch((e) => {
log.error("Failed to write models cache", { error: e })
// altimate_change — #1052 D14 review-fix (M3): fetchApi returning a non-2xx
// (e.g. 5xx with an HTML error body) previously fell through to
// `JSON.parse(<HTML>)` and crashed with SyntaxError. Return an empty
// catalog instead — callers already tolerate empty results (Provider.state
// just yields no models.dev-derived providers, which is the same UX as
// running with OPENCODE_DISABLE_MODELS_FETCH=1). Pre-D14 this rarely
// fired because the eager refresh usually warmed the disk cache; post-D14
// more first-calls fall through to fetch, so more chances to hit the crash.
//
// Bot-review follow-up: a 2xx can still carry HTML or truncated JSON
// (proxies, load-balancer error pages that respond 200, mid-stream
// truncation). Try to parse first; only cache + return on success. On
// parse failure, log and return empty — same graceful-degradation path
// as the non-2xx branch, and we don't poison the disk cache with junk.
if (!result2.ok) return {}
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(result2.text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The added parse guard only verifies that the fetch body is syntactically valid JSON, not that it is actually the models catalog. A 200 response body that is valid JSON but not an object (e.g. null, [], or a scalar — common from misconfigured proxies/load-balancers that set a JSON content-type) passes JSON.parse, is written to the on-disk cache (which then counts as fresh for the TTL), and is returned. ModelsDev.get() casts this to Record<string, Provider> and Provider.state (fromModelsDevProviderprovider.models/provider.id) then iterates/maps it, which can throw or produce a broken provider table. Consider validating that parsed is a plain, non-array object (and ideally matches the Provider catalog record shape) before writing it to the cache, so the "don't poison the disk cache" behavior actually holds for structured-but-wrong bodies too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/provider/models.ts, line 141:

<comment>The added parse guard only verifies that the fetch body is syntactically valid JSON, not that it is actually the models catalog. A 200 response body that is valid JSON but not an object (e.g. `null`, `[]`, or a scalar — common from misconfigured proxies/load-balancers that set a JSON content-type) passes `JSON.parse`, is written to the on-disk cache (which then counts as fresh for the TTL), and is returned. `ModelsDev.get()` casts this to `Record<string, Provider>` and `Provider.state` (`fromModelsDevProvider` → `provider.models`/`provider.id`) then iterates/maps it, which can throw or produce a broken provider table. Consider validating that `parsed` is a plain, non-array object (and ideally matches the `Provider` catalog record shape) before writing it to the cache, so the "don't poison the disk cache" behavior actually holds for structured-but-wrong bodies too.</comment>

<file context>
@@ -129,11 +129,27 @@ export namespace ModelsDev {
       if (!result2.ok) return {}
+      let parsed: Record<string, unknown>
+      try {
+        parsed = JSON.parse(result2.text)
+      } catch (e) {
+        log.error("models.dev returned non-JSON body; not caching", {
</file context>

} catch (e) {
log.error("models.dev returned non-JSON body; not caching", {
error: e,
firstBytes: result2.text.slice(0, 120),
})
return {}
}
return JSON.parse(result2.text)
await Filesystem.write(filepath, result2.text).catch((e) => {
log.error("Failed to write models cache", { error: e })
})
return parsed
})
})

Expand All @@ -152,18 +175,38 @@ export namespace ModelsDev {
}

if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
// altimate_change start — upstream_fix: bridge merge removed the setTimeout(...,0)
// wrapper. Defer the initial refresh past the current microtask so that
// Installation.USER_AGENT (used inside refresh()) is fully initialized — we hit
// a circular-dep issue on cold start without this. See altimate commit 980efaab64.
setTimeout(() => {
ModelsDev.refresh()
setInterval(
async () => {
await ModelsDev.refresh()
},
60 * 1000 * 60,
).unref()
}, 0)
// altimate_change start — #1052 D14: drop the eager import-time ModelsDev.refresh().
//
// The previous `setTimeout(() => ModelsDev.refresh(), 0)` fired a fetch to
// https://models.dev/api.json at module import. Its `AbortSignal.timeout(10000)`
// cannot cancel a synchronous `getaddrinfo()` — under Linux `unshare --net`
// (Verdaccio sanity Phase 3 [10/10] on Ubuntu CI runners) the DNS call blocked
// long enough that the pending fetch held the event loop past command
// completion and SIGTERM landed before any bytes flushed. That blocked the
// v0.9.4 release.
//
// Callers that need model data use `ModelsDev.Data()`, which resolves in this
// priority order: (1) local disk cache, (2) bundled snapshot at
// `models-snapshot.ts` (embedded in release binaries — regenerated at each
// build; dev-mode builds without the snapshot fall through to fetch), (3)
// `Flock.withLock(...) → fetchApi()` only when both are absent. Release
// binaries therefore have release-time model metadata even on a cold-start
// with no network. Long-running processes (TUI, serve) still receive updates
// via the hourly `setInterval` below (`.unref()`'d so it never blocks exit).
//
// Trade-off: without an eager fetch, models added to models.dev between
// releases would not appear until the hourly interval below fires. The
// fire-and-forget refresh() below narrows that window without holding the
// event loop — a microtask can't itself keep Bun alive, and if the fetch it
// schedules is still in flight at process-exit, the snapshot covers callers
// on the next run. The load-bearing part of the D14 fix (removing the
// setTimeout(...,0)-wrapped fetch that kept the loop alive) is preserved.
//
// If this reintroduces the unshare-net hang on CI, drop the Promise.then
// line — the snapshot alone still keeps release binaries functional offline.
Promise.resolve().then(() => ModelsDev.refresh().catch(() => {}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline / size =="
wc -l packages/opencode/src/provider/models.ts
ast-grep outline packages/opencode/src/provider/models.ts || true

echo
echo "== relevant source lines =="
sed -n '120,210p' packages/opencode/src/provider/models.ts

echo
echo "== related ModelsDev/Data/refresh usages =="
rg -n "ModelsDev|refresh\\(|Data\\(|skip\\(|fetchApi\\(|setTimeout|setInterval|no-network|network" packages/opencode/src packages/opencode -g '*.ts' -g '*.tsx' | head -n 200

Repository: AltimateAI/altimate-code

Length of output: 28822


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== models.ts full source =="
sed -n '1,130p' packages/opencode/src/provider/models.ts

echo
echo "== package scripts around release validation =="
for f in package.json packages/opencode/package.json; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    jq '.scripts // {}' "$f"
  fi
done

echo
echo "== no-network / unshare / verification references =="
rg -n "no[- ]?network|unshare|--net|release validation|windows-installer|snapshot|OPENCODE_DISABLE_MODELS_FETCH|models.dev/api.json" . -g '*.sh' -g '*.ts' -g '*.tsx' -g '*.json' -g '*.md' | head -n 200

Repository: AltimateAI/altimate-code

Length of output: 28918


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== models-snapshot references in package opencode =="
git ls-files packages/opencode | rg 'models-snapshot' || true
rg -n "models-snapshot|snapshot =" packages/opencode/src/provider packages/opencode/script packages/opencode/build -g '*.ts' -g '*.js' -g '*.mjs' | head -n 120

echo
echo "== runtime event-loop probe if bun is available =="
if command -v bun >/dev/null 2>&1; then
  case "$OSTYPE" in
    linux*)
      echo "--- unshare --net no network event-loop probe ---"
      unshare --net timeout 3s bun -e 'Promise.resolve().then(() => console.log("microtask-start")).then(async () => { try { await fetch("http://10.0.0.1:12345/nowhere"); console.log("ok"); } catch (e) { console.log("rejected", e.code || e.message) } }).then(() => console.log("microtask-done"))' || true
      ;;
    darwin*)
      echo "--- no network event-loop probe ---"
      bun -e 'Promise.resolve().then(() => console.log("microtask-start")).then(async () => { try { await fetch("http://10.0.0.1:12345/nowhere"); console.log("ok"); } catch (e) { console.log("rejected", e.code || e.message) } }).then(() => console.log("microtask-done"))' || true
      ;;
    *)
      echo "--- no network event-loop probe ---"
      bun -e 'Promise.resolve().then(() => console.log("microtask-start")).then(async () => { try { await fetch("http://10.0.0.1:12345/nowhere"); console.log("ok"); } catch (e) { console.log("rejected", e.code || e.message) } }).then(() => console.log("microtask-done"))' || true
      ;;
  esac
else
  echo "bun not available"
fi

echo
echo "== deterministic refresh skip behavior from source shape =="
python3 - <<'PY'
from pathlib import Path
p = Path("packages/opencode/src/provider/models.ts")
s = p.read_text()
print("has import-time Promise.resolve().then refresh:", 'Promise.resolve().then(() => ModelsDev.refresh().catch(() => {}))' in s)
print("refresh body starts with skip reset:", "function skip(force: boolean) {\n    return !force && fresh()\n  }\n\n  export async function refresh(force = false) {\n    if (skip(force)) return ModelsDev.Data.reset()" in s)
print("Data body imports snapshot without fetchApi:", "const snapshot = await import(\"./models-snapshot.js\")" in s and "export async function refresh" not in s.split("export const Data = lazy(async () => {")[1].split("export async function refresh")[0])
PY

Repository: AltimateAI/altimate-code

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== targeted model metadata for models-snapshot script =="
grep -n "models-snapshot" script/ packages/opencode/script packages/opencode/src/provider packages/opencode/script/build.ts 2>/dev/null || true
sed -n '20,45p' packages/opencode/script/build.ts 2>/dev/null || true

echo
echo "== targeted no-network release sanity references around command phase =="
sed -n '160,230p' test/sanity/phases/resilience.sh 2>/dev/null || true
rg -n "OPENCODE_DISABLE_MODELS_FETCH|no[- ]?network|no network|unshare --net|short-command|SIGTERM|DNS|getaddrinfo|models.dev/api.json" test/sanity packages/opencode/test/release-validation -g '*.sh' -g '*.ts' -g '*.tsx' | head -n 250

Repository: AltimateAI/altimate-code

Length of output: 3864


Do not replace setInterval(...).unref() with the import-time microtask.

Line 191 executes on every import and, on a cold cache, ModelsDev.refresh() skips the bundled snapshot and enters fetchApi(). It can start DNS/fetch work during short commands or CI no-network paths and reintroduce the same blocked event-loop/SIGTERM risk the original timeout was intended to avoid. Start the boot refresh from a confirmed long-running TUI/server lifecycle, or remove it and keep only the unref'd hourly interval.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/provider/models.ts` at line 191, Remove the import-time
Promise.resolve microtask that invokes ModelsDev.refresh(). Preserve the unref’d
hourly setInterval refresh, or move the initial refresh into a confirmed
long-running TUI/server lifecycle so importing the models module never starts
network work.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Boot-time refresh() here can hold the event loop under no-network, re-opening the D14 release-blocker

Promise.resolve().then(...) schedules refresh() on a microtask, but what kept the loop alive in the original setTimeout(...,0) bug was never the timer itself — it was the in-flight fetch()'s referenced I/O handle (and the synchronous getaddrinfo() under unshare --net, which AbortSignal.timeout(10000) can't cancel). The microtask runs refresh()fetchApi()fetch(...) almost immediately, so on a clean-cache cold start with no network the process blocks on DNS until the 10s timeout — exactly the v0.9.4 blocker D14 removed. The "a microtask can't itself keep Bun alive" rationale is true about the microtask but doesn't address the referenced fetch handle that follows it.

Since Bun's fetch exposes no .unref(), the only way to fire this without holding the loop is to not fire it at boot. The PR already documents the fallback ("drop the Promise.then line"); given the Phase 3 [10/10] no-internet sanity check is unchecked in the test plan, I'd drop this line now (or gate it behind confirmed network availability) rather than rely on CI to catch a reintroduced release-blocker.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Cold-cache CLI invocations still start fetchApi() before exiting, so an offline/DNS-blocked request can retain the event loop and recreate the D14 hang. Remove this startup refresh; on-demand loading and the unref'd hourly interval already cover model updates.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/provider/models.ts, line 191:

<comment>Cold-cache CLI invocations still start `fetchApi()` before exiting, so an offline/DNS-blocked request can retain the event loop and recreate the D14 hang. Remove this startup refresh; on-demand loading and the unref'd hourly interval already cover model updates.</comment>

<file context>
@@ -152,18 +159,38 @@ export namespace ModelsDev {
+  //
+  // If this reintroduces the unshare-net hang on CI, drop the Promise.then
+  // line — the snapshot alone still keeps release binaries functional offline.
+  Promise.resolve().then(() => ModelsDev.refresh().catch(() => {}))
+  setInterval(async () => {
+    await ModelsDev.refresh()
</file context>
Suggested change
Promise.resolve().then(() => ModelsDev.refresh().catch(() => {}))
// Refresh occurs on demand and via the unref'd interval below.

setInterval(async () => {
await ModelsDev.refresh()
}, 60 * 60 * 1000).unref()
// altimate_change end
}
66 changes: 64 additions & 2 deletions packages/opencode/test/install/smoke-test-binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { describe, test, expect } from "bun:test"
import { spawnSync, execFileSync } from "child_process"
import path from "path"
import fs from "fs"
// altimate_change — #1052 D10: sha256 for stamp-based staleness check.
import { createHash } from "node:crypto"
import { tmpdir } from "../fixture/fixture"

const PKG_DIR = path.resolve(import.meta.dir, "../..")
Expand Down Expand Up @@ -148,16 +150,76 @@ function isBinaryStale(binaryPath: string): boolean {
}
// altimate_change end

// altimate_change start — #1052 D10: stamp-based staleness check.
// build.ts emits `dist/<target>/bin/build-inputs.json` next to each binary,
// listing every file the binary embedded (CHANGELOG, migrations, skills,
// models-snapshot, parser worker, altimate-core prebuild, src/, script/) with
// sha256. This function rehashes each listed input; any mismatch means the
// binary no longer reflects the current sources. Falls back to the mtime walk
// above when the stamp is missing (older builds, or fallback for `--single`
// runs before the stamp landed).
type BuildStamp = {
target: string
version: string
aggregate: string
inputs: Array<{ path: string; sha256: string }>
}
function readBuildStamp(binaryPath: string): BuildStamp | undefined {
const stampPath = path.join(path.dirname(binaryPath), "build-inputs.json")
try {
if (!fs.existsSync(stampPath)) return undefined
const parsed = JSON.parse(fs.readFileSync(stampPath, "utf-8")) as BuildStamp
if (!parsed?.inputs?.length) return undefined
return parsed
Comment on lines +171 to +173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate the decoded stamp before returning it.

as BuildStamp does not validate JSON at runtime. For example, {"inputs":"x"} passes Line 172. The loop at Line 191 then passes undefined to path.join and aborts test setup instead of using the mtime fallback. Require a non-empty input array with string path and sha256 fields, or return undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/install/smoke-test-binary.test.ts` around lines 171 -
173, Validate the parsed value in the stamp-loading logic before returning it:
require a non-empty inputs array where every entry has string path and sha256
fields. Return undefined for malformed stamps so the caller’s mtime fallback
remains available, rather than relying on the BuildStamp type assertion.

} catch {
return undefined
}
}
function sha256File(absPath: string): string | undefined {
try {
return createHash("sha256").update(fs.readFileSync(absPath)).digest("hex")
} catch {
return undefined
}
}
function isBinaryStaleFromStamp(binaryPath: string): boolean | "no-stamp" {
const stamp = readBuildStamp(binaryPath)
if (!stamp) return "no-stamp"
// altimate_change — #1052 D10 review-fix (M2): stamp paths are now REPO_ROOT-
// relative so entries under packages/tui, packages/core, workspace-root
// package.json, bun.lock, etc. resolve correctly without further munging.
for (const { path: rel, sha256 } of stamp.inputs) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The stamp-based staleness guard only rehashes files that were present at build time, so source files added after the build are never checked. Since build.ts emits the stamp from a fixed input list at build time, a git pull or an edit that only adds new files (new modules, new workspace packages) leaves the binary silently stale while isBinaryStaleFromStamp returns false and the tests run against the outdated binary. The previous mtime walk caught this (a new file has a newer mtime than the binary), but that walk now only runs in the "no-stamp" fallback, so it is effectively dead once a stamp exists.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/test/install/smoke-test-binary.test.ts, line 191:

<comment>The stamp-based staleness guard only rehashes files that were present at build time, so source files added after the build are never checked. Since `build.ts` emits the stamp from a fixed input list at build time, a `git pull` or an edit that only adds new files (new modules, new workspace packages) leaves the binary silently stale while `isBinaryStaleFromStamp` returns `false` and the tests run against the outdated binary. The previous mtime walk caught this (a new file has a newer mtime than the binary), but that walk now only runs in the `"no-stamp"` fallback, so it is effectively dead once a stamp exists.</comment>

<file context>
@@ -148,16 +150,76 @@ function isBinaryStale(binaryPath: string): boolean {
+  // altimate_change — #1052 D10 review-fix (M2): stamp paths are now REPO_ROOT-
+  // relative so entries under packages/tui, packages/core, workspace-root
+  // package.json, bun.lock, etc. resolve correctly without further munging.
+  for (const { path: rel, sha256 } of stamp.inputs) {
+    const abs = path.join(REPO_ROOT, rel)
+    const current = sha256File(abs)
</file context>

const abs = path.join(REPO_ROOT, rel)
const current = sha256File(abs)
if (current === undefined) return true // input vanished → binary can't reflect current tree
if (current !== sha256) return true
}
Comment on lines +191 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Detect inputs added after the binary build.

This loop validates only paths already recorded in stamp.inputs. Adding a new migration directory or bundled skill leaves every old hash unchanged, so the function returns false and runs a binary that lacks the new input. Record and validate dynamic input-set membership, or reconstruct and compare the current expected input set before hashing files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/install/smoke-test-binary.test.ts` around lines 191 -
196, Update the binary freshness check around the stamp.inputs validation loop
to detect newly added dynamic inputs, not only modified or deleted recorded
paths. Reconstruct the current expected input set using the same discovery rules
as the build, compare its membership with stamp.inputs, and return true when
files are added or removed before performing the existing hash checks.

return false
}
// altimate_change end

describe("compiled binary smoke test", () => {
const binary = findLocalBinary()
const stale = binary ? isBinaryStale(binary) : false
// altimate_change — #1052 D10: prefer the stamp-based staleness check; fall
// back to the mtime walk when the stamp is absent (older `bun run build:local`
// runs, or targets built before the stamp landed).
const stampVerdict = binary ? isBinaryStaleFromStamp(binary) : ("no-stamp" as const)
const stale =
binary === undefined
? false
: stampVerdict === "no-stamp"
? isBinaryStale(binary)
: stampVerdict
const skip = !binary || stale
const runTest = skip ? test.skip : test

if (!binary) {
test.skip("no local build found — run `bun run build:local` first", () => {})
} else if (stale) {
test.skip("local binary is older than the newest src/ or script/ file — run `bun run build:local` to refresh", () => {})
test.skip(
"local binary is stale (build-inputs stamp mismatch or newer src/script mtime) — run `bun run build:local` to refresh",
() => {},
)
}

runTest("binary starts and prints version", () => {
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/lib/cli-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import path from "node:path"
// altimate_change — #1052 D11: fsPromises for the pre-retry DB scrub.
import * as fsPromises from "node:fs/promises"
import { TestLLMServer } from "./llm-server"
import { testProviderConfig } from "./test-provider"
import { it } from "./effect"
Expand Down Expand Up @@ -293,6 +295,16 @@ export function withCliFixture<A, E>(
// 60s spawn on top of the first (CodeRabbit v0.9.4 review finding).
// Cap the retry at max(remaining, 15s) — enough for a warm-cache spawn
// + cold-SQLite open without granting an unbounded second window.
//
// Before retrying, clean the SQLite state the first attempt may have
// written before hitting the lock (#1052 D11). `opencode run` writes
// session + tracing state at boot; if the first attempt got as far as
// opening the DB and taking a partial write before the WAL checkpoint
// collision, a naive retry would either see the partial state or
// double-write. Nuke the DB files (they live under this fixture's
// isolated XDG_DATA_HOME) so the second attempt starts from a clean
// slate. Other fixture state (config, home files) is preserved so
// tests that inject setup into `home` still see it.
Comment on lines +298 to +307

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant test file and surrounding code.
git ls-files | rg '(^|/)cli-process\.ts$' || true
if [ -f packages/opencode/test/lib/cli-process.ts ]; then
  echo "--- file outline ---"
  ast-grep outline packages/opencode/test/lib/cli-process.ts --view compact 2>/dev/null | sed -n '1,160p' || true
  echo "--- relevant lines 260-350 ---"
  nl -ba packages/opencode/test/lib/cli-process.ts | sed -n '260,350p'
fi

# Search for related retry/cleanup patterns in this file.
echo "--- related cleanup/retry occurrences ---"
rg -n "readdir|rm|ENAENT|ENOENT|catch|retry|SQLite|sqlite|D11|opencode run" packages/opencode/test/lib/cli-process.ts || true

Repository: AltimateAI/altimate-code

Length of output: 297


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- relevant lines 260-350 ---"
awk 'NR>=260 && NR<=350 { printf "%6d\t%s\n", NR, $0 }' packages/opencode/test/lib/cli-process.ts

echo "--- related cleanup/retry occurrences ---"
rg -n "readdir|rm|ENAENT|ENOENT|catch|retry|SQLite|sqlite|D11|opencode run" packages/opencode/test/lib/cli-process.ts || true

echo "--- package/imports context 1-60 ---"
awk 'NR>=1 && NR<=80 { printf "%6d\t%s\n", NR, $0 }' packages/opencode/test/lib/cli-process.ts

Repository: AltimateAI/altimate-code

Length of output: 13179


🌐 Web query:

Node fsPromises readdir error object code missing directories ENOENT fs rm force option documentation

💡 Result:

In Node.js, fs.promises.readdir does not support a force option to ignore missing directories [1][2]. If you attempt to call fs.promises.readdir on a path that does not exist, the promise will reject with an ENOENT error [1][2]. To handle missing directories without crashing, you must explicitly check for the error or the existence of the path. Regarding the fs.promises.rm function, it does include a force option [3][4]. When the force option is set to true, fs.promises.rm will ignore exceptions if the path does not exist [3][4]. This is the recommended way to achieve behavior similar to the rm -rf command in Unix [3][4]. For fs.promises.readdir, if you want to avoid errors when a directory might be missing, you can use a try-catch block: try { const files = await fs.promises.readdir(path); // process files } catch (err) { if (err.code === 'ENOENT') { // handle the case where the directory does not exist } else { throw err; } } The confusion regarding the force option often stems from its specific availability in the fs.promises.rm method [3][4], which is designed for recursive removal, rather than in the directory reading methods [1][2].

Citations:


Surface SQLite cleanup failures before retrying.

The catch block also swallows readdir failures such as missing directories or permission errors, and any non-ENOENT removal failure. Since this block only suppresses cleanup errors, the retry can still start while SQLite state remains partially written or corrupted. Catch the error and rethrow unless error.code === "ENOENT", and do not proceed to the second spawn if cleanup failed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/lib/cli-process.ts` around lines 298 - 307, Update the
retry cleanup catch block in the test process flow to rethrow any cleanup error
unless its code is ENOENT, including readdir and non-ENOENT removal failures.
Ensure the second spawn occurs only after SQLite cleanup completes successfully
or an ENOENT is explicitly ignored.

return Effect.gen(function* () {
const startedAt = Date.now()
const originalTimeoutMs = opts?.timeoutMs ?? 60_000
Expand All @@ -307,6 +319,21 @@ export function withCliFixture<A, E>(
`[cli-process] child hit \`database is locked\` on first attempt (exit=${first.exitCode}); retrying once. ` +
`If you see this often, the SQLite WAL/checkpoint contention has moved from transient to systematic.`,
)
// Scrub SQLite state so the retry is idempotent (see block comment above).
// The DB path pattern matches the CLI's own file layout under XDG_DATA_HOME.
yield* Effect.promise(async () => {
const dbDir = path.join(home, ".local/share/altimate-code")
try {
const entries = await fsPromises.readdir(dbDir)
await Promise.all(
entries
.filter((e) => /^opencode.*\.db(-wal|-shm)?$/.test(e))
.map((e) => fsPromises.rm(path.join(dbDir, e), { force: true })),
)
} catch {
// Directory absent or unreadable — nothing to clean. Retry proceeds.
}
})
const elapsed = Date.now() - startedAt
const remaining = Math.max(originalTimeoutMs - elapsed, 15_000)
const second = yield* spawn(argv, { ...opts, timeoutMs: remaining })
Expand Down
Loading
Loading