Skip to content
9 changes: 8 additions & 1 deletion packages/opencode/src/actor/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,14 @@ export const layer = Layer.effect(
concurrency: "unbounded",
discard: true,
})
yield* state.cancelActor(sessionID, actorID)
// Bound the work-fiber interrupt. cancelActor interrupts the actor's runner
// and awaits its full unwind; a fiber stuck unwinding (e.g. blocked on a
// hung LLM response stream) would otherwise hang the entire cancel sweep
// indefinitely. The interrupt signal is delivered regardless — we just stop
// awaiting completion after a grace period and let it finish detached. The
// registry status below still records the cancellation, which is the
// observable contract callers (and the cancel cascade) rely on.
yield* state.cancelActor(sessionID, actorID).pipe(Effect.timeout("5 seconds"), Effect.ignore)
yield* actorReg
.updateStatus(sessionID, actorID, { status: "idle", lastOutcome: "cancelled" })
.pipe(Effect.ignore)
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ export const layer = Layer.effect(
const cfg = yield* cfgSvc.get()
const bridge = yield* EffectBridge.make()
const config = cfg.mcp ?? {}
const origins = cfg.mcp_origins ?? {}
const s: State = {
status: {},
clients: {},
Expand All @@ -524,6 +525,14 @@ export const layer = Layer.effect(
return
}

// Claude Code (.claude.json) servers register lazily: they stay
// "pending" until explicitly connected, so importing a Claude
// config does not auto-spawn every local MCP server on startup.
if (origins[key]?.type === "claude") {
s.status[key] = { status: "pending" }
return
}

const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
if (!result) return

Expand Down
9 changes: 7 additions & 2 deletions packages/opencode/src/session/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MemoryFtsTable } from "@/memory/fts.sql"
import { TaskRegistry } from "@/task/registry"
import { ActorRegistry } from "@/actor/registry"
import type { AgentOutcome, ForkContext } from "@/actor/spawn"
import { Actor } from "@/actor/spawn"
import { spawnRef } from "@/actor/spawn-ref"
import { prefixCaptureRef } from "./prefix-capture-ref"
import { Database, and, eq, or } from "@/storage"
Expand All @@ -18,7 +19,7 @@ import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema"
import { Log, Token } from "../util"
import { Effect, Layer, Deferred, Context, Scope } from "effect"
import { Effect, Layer, Deferred, Context, Scope, Option } from "effect"
import { makeRuntime } from "@/effect/run-service"
import type { ActorPromptOps } from "@/tool/actor"
import type { ProviderID, ModelID } from "../provider/schema"
Expand Down Expand Up @@ -655,7 +656,11 @@ export const layer: Layer.Layer<
//
// Resolved via spawnRef rather than `yield* Actor.Service` to break the
// (Actor → SessionPrompt → SessionCheckpoint → Actor) layer cycle.
const actor = spawnRef.current
// Prefer the Actor service from the live Effect context (always present
// when running under AppRuntime), falling back to the late-bound spawnRef.
// The context lookup is robust against spawnRef going stale when a memoized
// Actor layer is torn down by instance disposal elsewhere in the process.
const actor = Option.getOrElse(yield* Effect.serviceOption(Actor.Service), () => spawnRef.current)
if (!actor) {
log.warn("tryStartCheckpointWriter skipping — Actor service unavailable", { sessionID: input.sessionID })
return "skipped" as const
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/tool/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,11 @@ export const ActorTool = Tool.define(
// under the parent. Actor.spawn handles registry registration, forking
// the agent loop, and sending inbox notifications on terminal — replacing
// the legacy session.create + manual fork path that lived here pre-Task-29.
// Seed sessionId/model onto the tool part up-front. If the spawn fails
// before the actor signals `onReady` (e.g. the actor service is
// unavailable or the model is unresolvable), this metadata is still
// preserved on the resulting error tool state.
yield* ctx.metadata({ title: op.description, metadata: { sessionId: ctx.sessionID, model } })
const actor = yield* requireActor()
const spawnResult = yield* actor.spawn({
mode: "subagent",
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/workflow/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ interface RunEntry {
runID: string
sessionID: SessionID
status: RunStatus
// Set true the moment cancellation begins (before reclaim). Spawn sites check
// this so a queued agent that acquires a freed permit mid-cancel bails instead
// of spawning a new orphan actor.
cancelling: boolean
deferred: Deferred.Deferred<RunOutcome>
fiber: Fiber.Fiber<void> | undefined
childActorIDs: Set<string>
Expand Down Expand Up @@ -367,6 +371,11 @@ export const layer = Layer.effect(
const cancelEntry = (entry: RunEntry): Effect.Effect<void> =>
Effect.gen(function* () {
if (entry.status !== "running") return
// Mark cancelling BEFORE reclaim. Reclaiming a started child frees its
// semaphore permit, which lets a queued sibling acquire it; the cancelling
// flag makes that sibling bail (see globalSemLocal.run) instead of spawning
// a new orphan actor that would race the reclaim (and could hang cancel).
entry.cancelling = true
yield* reclaim(entry)
yield* flushNow(entry)
yield* WorkflowPersistence.recordTerminal({ runID: entry.runID, status: "cancelled" }).pipe(Effect.ignore)
Expand Down Expand Up @@ -401,6 +410,7 @@ export const layer = Layer.effect(
runID,
sessionID: input.sessionID,
status: "running",
cancelling: false,
deferred,
fiber: undefined,
childActorIDs: new Set<string>(),
Expand Down Expand Up @@ -843,6 +853,10 @@ export const layer = Layer.effect(
// happens AFTER the slot is released, so file IO never holds a slot.
const result = await sem.run(async () =>
globalSemLocal.run(async () => {
// The run is being cancelled and this agent only just acquired a
// permit (freed by a sibling's reclaim). Bail before spawning so we
// don't create an orphan actor that escapes the cancel sweep.
if (entry.cancelling) return null
if (entry.agentCount >= lifecycleCap) {
warnCapOnce()
publishAgentFailed(o, "over-cap")
Expand Down Expand Up @@ -876,6 +890,9 @@ export const layer = Layer.effect(
}
return sem.run(async () =>
globalSemLocal.run(async () => {
// See the shared-tree path: bail if the run is cancelling so a
// late-acquiring queued agent doesn't spawn an orphan.
if (entry.cancelling) return null
if (entry.agentCount >= lifecycleCap) {
warnCapOnce()
publishAgentFailed(o, "over-cap")
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/test/file/path-traversal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ describe("Instance.containsPath", () => {

describe("Instance.provide directory safety", () => {
test("rejects system paths containing secrets", async () => {
const systemPaths = ["/etc", "/etc/nginx", "/etc/shadow", "/proc", "/sys", "/dev", "/root", "/boot"]
const systemPaths = ["/etc", "/etc/nginx", "/etc/shadow", "/proc", "/sys", "/dev", "/boot"]
for (const dir of systemPaths) {
await expect(
Instance.provide({ directory: dir, fn: () => {} }),
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/test/history/backfill.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect } from "bun:test"
import { afterEach, beforeEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "../../src/storage"
import { HistoryFtsTable } from "../../src/history/fts.sql"
Expand All @@ -11,14 +11,22 @@ import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"

afterEach(async () => {
const clearDb = () =>
Database.use((db) => {
db.delete(HistoryFtsTable).run()
db.delete(PartTable).run()
db.delete(MessageTable).run()
db.delete(SessionTable).run()
db.delete(ProjectTable).run()
})

// The DB is a process-global shared across test files. These suites inspect ALL
// FTS rows, so clear before each test too — otherwise rows left by earlier files
// in the same shard leak into the assertions.
beforeEach(clearDb)

afterEach(async () => {
clearDb()
await Instance.disposeAll()
})

Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,7 @@ describe("session.llm.stream", () => {
expect(body.messages).toStrictEqual([
{
role: "user",
content: [{ cache_control: { type: "ephemeral" }, type: "text", text: "Can you check whether there are any PDF files in my home directory?" }],
content: [{ type: "text", text: "Can you check whether there are any PDF files in my home directory?" }],
},
{
role: "assistant",
Expand All @@ -1139,6 +1139,7 @@ describe("session.llm.stream", () => {
input: { filePath: "/root" },
},
{
cache_control: { type: "ephemeral" },
type: "tool_use",
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
name: "glob",
Expand All @@ -1155,6 +1156,7 @@ describe("session.llm.stream", () => {
content: "<path>/root</path>",
},
{
cache_control: { type: "ephemeral" },
type: "tool_result",
tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
content: "No files found",
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/test/workflow/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,12 @@ describe("WorkflowRuntime cancel cascade", () => {
// on each. An orphan (never reclaimed) would have lastOutcome unset here.
expect(children.filter((a) => a.lastOutcome !== "cancelled")).toEqual([])
}),
{ git: true, config: providerCfg },
// Pin the concurrency ceiling BELOW the 8-way fan-out so some children run
// (in-flight at cancel) while the rest are semaphore-queued — deterministic
// on any host (the default is min(16, 2×cores), which varies). This exercises
// the cancel path that frees a started child's permit, lets a queued sibling
// acquire it, and must NOT spawn a new orphan (it bails on entry.cancelling).
{ git: true, config: (url: string) => ({ ...providerCfg(url), workflow: { maxConcurrentAgents: 2 } }) },
),
20000,
)
Expand Down