diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 96cc78f8def6..146d1980e245 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -84,9 +84,12 @@ jobs:
path: ~/.cache/opencode/bin
key: opencode-bin-${{ runner.os }}-${{ hashFiles('packages/core/src/ripgrep/binary.ts') }}
- name: Run unit tests
- # opencode#test alone takes ~20 minutes on GitHub-hosted windows runners
+ # opencode#test alone takes ~20 minutes on GitHub-hosted windows runners.
+ # --log-order=stream keeps per-task output visible if the step is ever
+ # killed; turbo's CI default (grouped) buffers a task's output until it
+ # finishes, so a timeout eats the entire log (LAC-2717).
timeout-minutes: 35
- run: GITHUB_ACTIONS=false bun turbo test
+ run: GITHUB_ACTIONS=false bun turbo test --log-order=stream
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
diff --git a/packages/core/test/lib/effect.ts b/packages/core/test/lib/effect.ts
index 131ec5cc6bc2..c7df26384e86 100644
--- a/packages/core/test/lib/effect.ts
+++ b/packages/core/test/lib/effect.ts
@@ -19,24 +19,26 @@ const run = (value: Body, layer: Layer.Layer
return yield* exit
}).pipe(Effect.runPromise)
-const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => {
+const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, defaults?: number | TestOptions) => {
+ const options = (opts?: number | TestOptions) => opts ?? defaults
+
const effect = (name: string, value: Body, opts?: number | TestOptions) =>
- test(name, () => run(value, testLayer), opts)
+ test(name, () => run(value, testLayer), options(opts))
effect.only = (name: string, value: Body, opts?: number | TestOptions) =>
- test.only(name, () => run(value, testLayer), opts)
+ test.only(name, () => run(value, testLayer), options(opts))
effect.skip = (name: string, value: Body, opts?: number | TestOptions) =>
- test.skip(name, () => run(value, testLayer), opts)
+ test.skip(name, () => run(value, testLayer), options(opts))
const live = (name: string, value: Body, opts?: number | TestOptions) =>
- test(name, () => run(value, liveLayer), opts)
+ test(name, () => run(value, liveLayer), options(opts))
live.only = (name: string, value: Body, opts?: number | TestOptions) =>
- test.only(name, () => run(value, liveLayer), opts)
+ test.only(name, () => run(value, liveLayer), options(opts))
live.skip = (name: string, value: Body, opts?: number | TestOptions) =>
- test.skip(name, () => run(value, liveLayer), opts)
+ test.skip(name, () => run(value, liveLayer), options(opts))
return { effect, live }
}
@@ -49,5 +51,5 @@ const liveEnv = TestConsole.layer
export const it = make(testEnv, liveEnv)
-export const testEffect = (layer: Layer.Layer) =>
- make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
+export const testEffect = (layer: Layer.Layer, defaults?: number | TestOptions) =>
+ make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), defaults)
diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts
index d37f6302074a..15e24f4ffd13 100644
--- a/packages/core/test/project-copy.test.ts
+++ b/packages/core/test/project-copy.test.ts
@@ -19,6 +19,10 @@ import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])),
+ // These tests chain many git subprocess ops; on GitHub-hosted Windows
+ // runners each op costs 100ms-2.8s under full-suite load, exceeding bun's
+ // 5s default per-test timeout (LAC-2717).
+ { timeout: 30_000 },
)
function abs(input: string) {
diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts
index 2dd0ce250288..198ad81bbc49 100644
--- a/packages/core/test/repository-cache.test.ts
+++ b/packages/core/test/repository-cache.test.ts
@@ -12,7 +12,10 @@ import { git, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
-const it = testEffect(Layer.empty)
+// These tests chain many git subprocess ops; on GitHub-hosted Windows runners
+// each op costs 100ms-2.8s under full-suite load, exceeding bun's 5s default
+// per-test timeout (LAC-2717).
+const it = testEffect(Layer.empty, { timeout: 30_000 })
describe("RepositoryCache", () => {
it.live("replaces a stale cache directory before cloning", () =>
diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts
index 55e4ead7ed2e..7913361daa86 100644
--- a/packages/core/test/snapshot.test.ts
+++ b/packages/core/test/snapshot.test.ts
@@ -15,8 +15,13 @@ import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
+// These tests chain many git subprocess ops; on GitHub-hosted Windows
+// runners each op costs 100ms-2.8s under full-suite load, exceeding bun's
+// 5s default per-test timeout (LAC-2717).
+const it = testEffect(Layer.empty, { timeout: 30_000 })
+
describe("Snapshot", () => {
- testEffect(Layer.empty).live("captures and restores Location-scoped changes", () =>
+ it.live("captures and restores Location-scoped changes", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -70,7 +75,7 @@ describe("Snapshot", () => {
),
)
- testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
+ it.live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -86,7 +91,7 @@ describe("Snapshot", () => {
),
)
- testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
+ it.live("isolates snapshot indexes by canonical Git worktree", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -134,7 +139,7 @@ describe("Snapshot", () => {
),
)
- testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
+ it.live("checks out a legacy revert snapshot without removing unrelated files", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts
index ba7d2e25a964..91011122196d 100644
--- a/packages/opencode/src/skill/discovery.ts
+++ b/packages/opencode/src/skill/discovery.ts
@@ -1,7 +1,7 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient, path } from "@opencode-ai/core/effect/app-node-platform"
import { NodePath } from "@effect/platform-node"
-import { Effect, Layer, Path, Schema, Context } from "effect"
+import { Effect, Layer, Path, Schedule, Schema, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -34,6 +34,14 @@ export const layer: Layer.Layer
+ process.platform === "win32"
+ ? fs.rename(from, to).pipe(Effect.retry({ times: 5, schedule: Schedule.spaced(100) }))
+ : fs.rename(from, to)
+
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
@@ -106,11 +114,11 @@ export const layer: Layer.Layer
Effect.gen(function* () {
- if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
+ if (cached) yield* rename(backup, root).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
diff --git a/turbo.json b/turbo.json
index 5e93640b1fad..9d0696ea4b3e 100644
--- a/turbo.json
+++ b/turbo.json
@@ -15,7 +15,8 @@
},
"@opencode-ai/core#test": {
"dependsOn": ["^build"],
- "outputs": []
+ "outputs": [],
+ "passThroughEnv": ["*"]
},
"@opencode-ai/app#test": {
"dependsOn": ["^build"],