Compare commits

..

7 Commits

Author SHA1 Message Date
Kit Langton 620b946dbf feat(console): expose Go and Zen usage 2026-08-11 19:08:04 +00:00
Aiden Cline a5d6969f6f feat(core): extract plan agent into a plugin (#41665) 2026-08-11 13:49:24 -05:00
opencode-agent[bot] 3954d7979d chore: generate 2026-08-11 18:11:13 +00:00
Kit Langton a69a372f27 fix(util): defer module-scope randomness and import.meta path derivation (#41765) 2026-08-11 14:09:18 -04:00
Kit Langton 7300e7e10f core: write-ahead execution claims replace shutdown-hook suspension (#41800) 2026-08-11 14:09:04 -04:00
James Long eeb870b0cc feat(sdk): configure embedded logging (#41809) 2026-08-11 14:03:55 -04:00
Kit Langton 136bad9f17 feat(core): discover AGENTS.md up to the home directory (#41652) 2026-08-11 13:57:10 -04:00
35 changed files with 773 additions and 281 deletions
@@ -0,0 +1,75 @@
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
export async function GET(input: APIEvent) {
const token = input.request.headers.get("authorization")?.match(/^Bearer (.+)$/)?.[1]
if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 })
const row = await Database.use((tx) =>
tx
.select({
balance: BillingTable.balance,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
useBalance: BillingTable.lite,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
goMonthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeSubscribed: LiteTable.timeCreated,
})
.from(KeyTable)
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
.leftJoin(
LiteTable,
and(
eq(LiteTable.workspaceID, KeyTable.workspaceID),
eq(LiteTable.userID, KeyTable.userID),
isNull(LiteTable.timeDeleted),
),
)
.where(and(eq(KeyTable.key, token), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
if (!row) return Response.json({ error: "Unauthorized" }, { status: 401 })
const limits = row.timeSubscribed ? LiteData.getLimits() : undefined
return Response.json({
go:
limits && row.timeSubscribed
? {
useBalance: row.useBalance?.useBalance ?? false,
rolling: Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
weekly: Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
monthly: Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.goMonthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeSubscribed,
}),
}
: undefined,
zen: {
balance: row.balance / 100_000_000,
monthly: {
usage: (row.monthlyUsage ?? 0) / 100_000_000,
limit: row.monthlyLimit ?? undefined,
},
},
})
}
+12 -2
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "15060ec5-05f7-4b86-b2a5-9108609432b3",
"prevIds": ["1551a157-8959-4ba9-a52b-4ea3b7b28cae"],
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
"ddl": [
{
"name": "account_state",
@@ -1302,6 +1302,16 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "resume_attempts",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
@@ -27,8 +27,10 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const project = discovery.project && FSUtil.contains(stop, start)
const root = yield* fs.resolve(location.project.directory)
const home = yield* fs.resolve(global.home)
const project = discovery.project && FSUtil.contains(root, start)
const stop = FSUtil.contains(home, start) ? home : root
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
+2
View File
@@ -40,6 +40,7 @@ import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
import m41 from "./migration/20260811161259_execution_claim_attempts"
export const migrations = [
m00,
@@ -83,4 +84,5 @@ export const migrations = [
m38,
m39,
m40,
m41,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
id: "20260811161259_execution_claim_attempts",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`resume_attempts\` integer DEFAULT 0 NOT NULL;`)
})
},
}
export default migration
+1
View File
@@ -200,6 +200,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
+7 -2
View File
@@ -4,7 +4,7 @@ export { Event, ID, Info } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
@@ -44,7 +44,12 @@ const layer = Layer.effect(
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
inherit,
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
-10
View File
@@ -101,16 +101,6 @@ export const Plugin = define({
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("plan"), (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
{ action: "question", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
)
})
draft.update(Agent.ID.make("general"), (item) => {
item.name = Agent.Name.make("General")
item.description =
+2
View File
@@ -61,6 +61,7 @@ import { WellKnown } from "../wellknown"
import { WriteTool } from "../tool/plugin/write"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { PlanPlugin } from "./plan"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { WebSearchPlugins } from "./websearch"
@@ -196,6 +197,7 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
+70
View File
@@ -0,0 +1,70 @@
export * as PlanPlugin from "./plan"
import { ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Agent } from "../agent"
import { SessionEvent } from "../session/event"
const plan = Agent.ID.make("plan")
const enter = `<system-reminder>
You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either.
You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents.
</system-reminder>`
const leave = `<system-reminder>
You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.
</system-reminder>`
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
item.mode = "primary"
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
})
yield* ctx.tool.hook("execute.before", (event) => {
if (event.agent !== plan) return Effect.void
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
return new ToolFailure({
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
})
})
yield* ctx.event.subscribe().pipe(
Stream.filter(
(event): event is SessionEvent.Created | SessionEvent.AgentSelected =>
event.type === "session.created" || event.type === "session.agent.selected",
),
Stream.runForEach((event) => {
const text = reminder(event)
if (!text) return Effect.void
return ctx.session
.synthetic({
sessionID: event.data.sessionID,
text,
resume: false,
})
.pipe(Effect.catch(() => Effect.void))
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
if (event.type === "session.created") {
if (event.data.agent !== plan) return
return enter
}
if (event.data.agent === event.data.previous) return
if (event.data.agent === plan) return enter
if (event.data.previous === plan) return leave
}
+20 -8
View File
@@ -56,16 +56,22 @@ export const layer = Layer.effect(
),
Effect.asVoid,
)
// Starting or finishing on its own clears stale suspension; interruption preserves it because
// managed-server teardown suspends active Sessions immediately before interrupting their drains.
const clearSuspensionOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => Effect.asVoid(store.consumeSuspended(sessionID)),
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
// transaction as the started event. Terminals release it — except shutdown interruption, which
// preserves the claim so the next server start resumes the turn. A claim that survives with no
// terminal is the signature of a process that died without teardown (crash, SIGKILL, eviction);
// recovery is a property of the database, never of a shutdown hook that may not run.
const claimOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.claim(sessionID),
})
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.release(sessionID),
})
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, clearSuspensionOnCommit(sessionID)),
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
@@ -86,11 +92,17 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const outcome = terminal(exit, reason)
if (outcome.type === "succeeded") {
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, clearSuspensionOnCommit(sessionID))
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
return
}
if (outcome.type === "interrupted") {
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
// A user cancel (or a superseding execution) releases the claim: the turn must not
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
)
return
}
yield* bus.publish(
@@ -99,7 +111,7 @@ export const layer = Layer.effect(
sessionID,
error: outcome.error,
},
clearSuspensionOnCommit(sessionID),
releaseOnCommit(sessionID),
)
}),
),
+88 -38
View File
@@ -5,61 +5,111 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus"
import { SessionEvent } from "../event"
import { SessionExecution } from "../execution"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
const RESUME_EXHAUSTED = {
type: "aborted",
message: "Execution was interrupted repeatedly and will not be resumed automatically.",
} as const
export interface Options {
/**
* Times a single turn may be resumed before it is terminalized instead.
* The counter is durable and only a terminal event resets it, so a turn
* that keeps dying cannot crash-loop across restarts. Turns that complete
* never accumulate: the budget is per-turn, not per-session.
*/
readonly maxAttempts?: number
}
const DEFAULT_MAX_ATTEMPTS = 10
export interface Interface {
/**
* Marks every execution active in this process for resumption by the next server start.
* Call once new work has stopped arriving and before teardown interrupts the drains.
* Resumes Sessions whose execution claim was never released — turns orphaned
* by a process that died without teardown, or interrupted by a graceful
* shutdown (which preserves the claim on purpose). The claim is never
* cleared here: only a terminal event releases it, so a death anywhere in
* the resume path leaves the same orphaned claim for the next boot.
*/
readonly suspendActiveSessions: Effect.Effect<void>
/** Resumes suspended Sessions. Each suspension is consumed atomically, so a Session resumes at most once. */
readonly resumeSuspendedSessions: Effect.Effect<void>
}
/**
* Restart continuity actions for the managed server. The service is inert until called: only the
* managed server invokes it, so default, embedded, and stdio servers never suspend or auto-resume.
* Recovery for orphaned executions. Claims are written at turn start by
* SessionExecution, so this sweep needs no cooperation from the previous
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
* the same durable signature.
*
* The sweep assumes every orphaned claim's owner is dead. The managed-server
* protocol guarantees this: a successor is only spawned after the previous
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
* registration lock admits one managed server at a time, and unregistered
* servers sharing the database never sweep. The service is inert until called
* — the managed server invokes it at boot; embedders may call it from their
* own start-up.
*/
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRestart") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
return Service.of({
suspendActiveSessions: Effect.gen(function* () {
yield* store.suspend(yield* execution.active)
}),
resumeSuspendedSessions: Effect.gen(function* () {
const sessions = yield* store.listSuspended()
yield* Effect.forEach(
sessions,
(sessionID) =>
Effect.gen(function* () {
if (!(yield* store.consumeSuspended(sessionID))) return
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID))
}),
{ concurrency: "unbounded", discard: true },
)
}),
})
}),
)
export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
const scope = yield* Effect.scope
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
// Durable before the resume runs, so a crash inside the resumed turn is
// counted by the next sweep and the budget cannot be dodged.
const attempts = yield* store.countResume(sessionID)
if (attempts === undefined) return // the Session was deleted since listing
if (attempts > maxAttempts) {
// Terminalize instead: the release hook clears the claim and resets the
// counter atomically with the terminal event.
yield* bus.publish(
SessionEvent.Execution.Failed,
{ sessionID, error: RESUME_EXHAUSTED },
{ commit: () => store.release(sessionID) },
)
return
}
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Forked into the service scope so boot never waits on resumed turns;
// resuming an already-live Session joins its execution. Drain failures
// are logged and durably recorded by the execution layer.
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
})
return Service.of({
resumeSuspendedSessions: Effect.gen(function* () {
// Child claims never drive recovery (children are not resumed), so a
// dead child's claim is noise no terminal will ever release. Clearing
// is safe even against a live child: claims are recovery markers, not
// locks, and children are excluded from that recovery.
yield* store.releaseChildClaims
const active = yield* execution.active
// Sessions already draining in this process keep their claim; resuming
// them would only inject a stray continuation into a live turn.
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
}),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
layer: layer(),
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
+2
View File
@@ -58,7 +58,9 @@ export const SessionTable = sqliteTable(
...Timestamps,
time_compacting: integer(),
time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
time_suspended: integer(),
resume_attempts: integer().notNull().default(0),
},
(table) => [
index("session_v2_project_idx").on(table.project_id),
+61 -22
View File
@@ -1,6 +1,6 @@
export * as SessionStore from "./store"
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -17,10 +17,32 @@ export interface Interface {
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
/**
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
* are excluded: a resumed parent re-runs its tool call and spawns fresh
* children, so resuming orphaned children would duplicate their work.
*/
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
/** Clears suspension, reporting whether this caller consumed it. At most one concurrent caller receives true. */
readonly consumeSuspended: (sessionID: Session.ID) => Effect.Effect<boolean>
readonly suspend: (sessionIDs: Iterable<Session.ID>) => Effect.Effect<void>
/**
* Records the execution claim: the durable write-ahead intent that a turn is
* (or was) in flight. Set when execution starts; a claim that survives to the
* next boot marks a turn that never completed — its process crashed or shut
* down mid-turn.
*/
readonly claim: (sessionID: Session.ID) => Effect.Effect<void>
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
/**
* Clears orphaned child (subagent) claims. Children are never resumed
* independently, so a dead child's claim is noise no terminal will ever
* release.
*/
readonly releaseChildClaims: Effect.Effect<void>
/**
* Durably counts one more resume of an orphaned claim, returning the new
* total — or undefined when the Session no longer exists.
*/
readonly countResume: (sessionID: Session.ID) => Effect.Effect<number | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStore") {}
@@ -57,35 +79,52 @@ const layer = Layer.effect(
return yield* db
.select({ sessionID: SessionTable.id })
.from(SessionTable)
.where(isNotNull(SessionTable.time_suspended))
.where(and(isNotNull(SessionTable.time_suspended), isNull(SessionTable.parent_id)))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => rows.map((row) => row.sessionID)),
)
}),
consumeSuspended: Effect.fn("SessionStore.consumeSuspended")(function* (sessionID) {
return (
(yield* db
.update(SessionTable)
.set({ time_suspended: null })
.where(and(eq(SessionTable.id, sessionID), isNotNull(SessionTable.time_suspended)))
.returning({ sessionID: SessionTable.id })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
suspend: Effect.fn("SessionStore.suspend")(function* (sessionIDs) {
const ids = Array.from(sessionIDs)
if (ids.length === 0) return
// The null guard preserves the original suspension time if a Session is somehow suspended twice.
claim: Effect.fn("SessionStore.claim")(function* (sessionID) {
// The null guard makes re-claiming a still-claimed Session a zero-row
// no-op (a resumed turn re-claims through the same started hook).
// Claim bookkeeping never counts as user activity: time_updated is
// pinned so session ordering only moves on real changes.
yield* db
.update(SessionTable)
.set({ time_suspended: Date.now() })
.where(and(inArray(SessionTable.id, ids), isNull(SessionTable.time_suspended)))
.set({ time_suspended: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.time_suspended)))
.run()
.pipe(Effect.orDie)
}),
release: Effect.fn("SessionStore.release")(function* (sessionID) {
yield* db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}),
releaseChildClaims: db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
.run()
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
const row = yield* db
.update(SessionTable)
.set({
resume_attempts: sql`${SessionTable.resume_attempts} + 1`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.returning({ attempts: SessionTable.resume_attempts })
.get()
.pipe(Effect.orDie)
return row?.attempts
}),
})
}),
)
-1
View File
@@ -165,7 +165,6 @@ describe("Agent", () => {
"compaction",
"explore",
"general",
"plan",
"summary",
"title",
])
@@ -24,6 +24,7 @@ const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config?: string
home?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
@@ -34,7 +35,15 @@ const instructionLayer = (input: {
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
[
Global.node,
input.config || input.home
? Global.layerWith({
...(input.config ? { config: input.config } : {}),
...(input.home ? { home: input.home } : {}),
})
: tempGlobalLayer,
],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
@@ -112,10 +121,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const sharedFile = path.join(shared, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
return Effect.gen(function* () {
@@ -124,6 +136,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(outside, "outside")
await fs.writeFile(globalFile, "global")
await fs.writeFile(sharedFile, "shared")
await fs.writeFile(projectFile, "project")
await fs.writeFile(packageFile, "package")
})
@@ -135,13 +148,20 @@ describe("ConfigInstructionPlugin.Plugin", () => {
{ path: packageFile, type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: projectFile, type: "file" },
{ path: sharedFile, type: "file" },
{ path: path.join(home, "AGENTS.md"), type: "file" },
])
expect(yield* watcher.subscriptions()).not.toContainEqual({
path: path.join(tmp.path, "AGENTS.md"),
type: "file",
})
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${packageFile}\npackage`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
expect(initialized.text).not.toContain("outside")
@@ -159,6 +179,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
`Instructions from: ${projectFile}\nproject`,
`Instructions from: ${sharedFile}\nshared`,
].join("\n\n"),
)
@@ -166,6 +187,8 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "delete", path: globalFile })
yield* Effect.promise(() => fs.rm(projectFile))
yield* emitAndWait({ type: "delete", path: projectFile })
yield* Effect.promise(() => fs.rm(sharedFile))
yield* emitAndWait({ type: "delete", path: sharedFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
"Previously loaded instructions no longer apply.",
)
@@ -173,6 +196,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -215,15 +239,17 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
it.live("discovers a newly created instruction file in an intermediate directory", () =>
it.live("discovers a newly created instruction file above the project root", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const project = path.join(tmp.path, "project")
const intermediate = path.join(project, "packages", "AGENTS.md")
const directory = path.join(project, "packages", "core")
const home = path.join(tmp.path, "home")
const shared = path.join(home, "code")
const project = path.join(shared, "repo")
const intermediate = path.join(shared, "AGENTS.md")
const directory = path.join(project, "core")
const projectFile = path.join(project, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
@@ -235,7 +261,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
yield* emitAndWait({ type: "create", path: intermediate })
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
[`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
"\n\n",
),
)
@@ -243,6 +269,48 @@ describe("ConfigInstructionPlugin.Plugin", () => {
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
it.live("stops instruction candidates at the project root outside home", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const project = path.join(tmp.path, "scratch", "repo")
const directory = path.join(project, "packages", "core")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* start()
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(global, "AGENTS.md"), type: "file" },
{ path: path.join(directory, "AGENTS.md"), type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: path.join(project, "AGENTS.md"), type: "file" },
])
}).pipe(
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
+178 -32
View File
@@ -18,6 +18,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
@@ -49,58 +50,90 @@ describe("SessionExecution lifecycle", () => {
})
})
it.effect("atomically consumes each suspension at most once", () =>
it.effect("the sweep only lists claimed top-level Sessions", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
const first = Session.ID.make("ses_recover_first")
const second = Session.ID.make("ses_recover_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
const parent = Session.ID.make("ses_recover_parent")
const child = Session.ID.make("ses_recover_child")
const idle = Session.ID.make("ses_recover_idle")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedSessions(database, [idle])
// An orphaned child is never resumed: the resumed parent re-runs its
// tool call and spawns a fresh child instead.
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
expect(yield* store.consumeSuspended(first)).toBe(true)
expect(yield* store.consumeSuspended(first)).toBe(false)
expect(yield* store.consumeSuspended(second)).toBe(true)
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
expect(yield* store.listSuspended()).toEqual([parent])
// The sweep clears orphaned child claims outright; parents keep theirs.
yield* store.releaseChildClaims
expect(yield* claims(database)).toEqual({ [parent]: true, [child]: false, [idle]: false })
}),
)
it.effect("suspension survives teardown interruption and clears when a drain finishes on its own", () =>
it.effect("claims at execution start, releases on completion, and preserves through teardown", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const interrupted = Session.ID.make("ses_suspend_interrupted")
const completed = Session.ID.make("ses_suspend_completed")
const interrupted = Session.ID.make("ses_claim_interrupted")
const completed = Session.ID.make("ses_claim_completed")
yield* seedSessions(database, [interrupted, completed])
const draining = yield* Deferred.make<void>()
// Each drain signals once it runs; the claim commits before the drain starts.
const interruptedRunning = yield* Deferred.make<void>()
const completedRunning = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) =>
sessionID === completed
? Deferred.await(release)
: Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
? Deferred.succeed(completedRunning, undefined).pipe(Effect.andThen(Deferred.await(release)))
: Deferred.succeed(interruptedRunning, undefined).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* execution.resume(interrupted).pipe(Effect.forkScoped)
const completing = yield* execution.resume(completed).pipe(Effect.forkIn(scope))
yield* Deferred.await(draining)
yield* Deferred.await(interruptedRunning)
yield* Deferred.await(completedRunning)
yield* restart.suspendActiveSessions
expect(yield* suspensions(database)).toEqual({ [interrupted]: true, [completed]: true })
// The write-ahead claim exists WHILE the turns run — no shutdown hook involved.
expect(yield* claims(database)).toEqual({ [interrupted]: true, [completed]: true })
// A drain that finishes on its own after suspension clears its stale suspension.
// A drain that finishes on its own releases its claim.
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(completing)
yield* execution.awaitIdle(completed)
expect((yield* suspensions(database))[completed]).toBe(false)
expect((yield* claims(database))[completed]).toBe(false)
// Teardown interruption preserves suspension for the next server start.
// Teardown interruption (graceful twin of an unclean death) preserves the claim
// for the next server start.
yield* Scope.close(scope, Exit.void)
expect((yield* suspensions(database))[interrupted]).toBe(true)
expect((yield* claims(database))[interrupted]).toBe(true)
}),
)
it.effect("starts every suspended execution without waiting for earlier drains to finish", () =>
it.effect("a user interrupt releases the claim so the turn never resurrects", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_claim_user_cancel")
yield* seedSessions(database, [sessionID])
const draining = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () =>
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(draining)
expect((yield* claims(database))[sessionID]).toBe(true)
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
expect((yield* claims(database))[sessionID]).toBe(false)
}),
)
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionIDs = Array.from({ length: 5 }, (_, index) => Session.ID.make(`ses_resume_concurrent_${index}`))
@@ -125,7 +158,7 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("resumes each suspended Session at most once", () =>
it.effect("resumes each claimed Session at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
@@ -134,14 +167,22 @@ describe("SessionExecution lifecycle", () => {
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
const drained: string[] = []
const bothDraining = yield* Deferred.make<void>()
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
const context = yield* buildExecution(scope, ({ sessionID }) =>
Effect.sync(() => {
drained.push(sessionID)
if (drained.length === 2) Deferred.doneUnsafe(bothDraining, Effect.void)
}),
)
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
// The sweep forks resumed drains, so completion is observed through the executions.
yield* restart.resumeSuspendedSessions
yield* Deferred.await(bothDraining)
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([first, second])
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
@@ -151,7 +192,9 @@ describe("SessionExecution lifecycle", () => {
description: "Continuing after restart",
})),
)
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
// Drains completed naturally, so claims are released and counters reset.
expect(yield* claims(database)).toEqual({ [first]: false, [second]: false })
expect(yield* attempts(database, first)).toBe(0)
yield* restart.resumeSuspendedSessions
expect(drained.length).toBe(2)
@@ -159,17 +202,104 @@ describe("SessionExecution lifecycle", () => {
yield* Scope.close(scope, Exit.void)
}),
)
it.effect("terminalizes a turn that exhausts its resume budget instead of crash-looping", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const sessionID = Session.ID.make("ses_resume_exhausted")
// A claim from a dead process, already resumed twice without completing.
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
const drained: string[] = []
const failures: SessionEvent.Execution.Failed[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, ({ sessionID: id }) => Effect.sync(() => void drained.push(id)), {
maxAttempts: 2,
})
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Execution.Failed, (event) => Effect.sync(() => void failures.push(event)))
yield* restart.resumeSuspendedSessions
expect(drained).toEqual([])
expect(failures.map((event) => event.data.error.type)).toEqual(["aborted"])
// The terminal released the claim and reset the counter atomically.
expect(yield* claims(database)).toEqual({ [sessionID]: false })
expect(yield* attempts(database, sessionID)).toBe(0)
}),
)
it.effect("counts every resume durably and never consumes the claim it recovers", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_resume_counted")
yield* seedSessions(database, [sessionID], { time_suspended: Date.now() })
const draining = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
// The drain never terminalizes (mirrors a process that will die mid-turn).
const context = yield* buildExecution(scope, () =>
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions.pipe(Effect.forkIn(scope))
yield* Deferred.await(draining)
// The attempt is durable before the drain runs, and the claim is held
// throughout: a crash anywhere in the resume path leaves both intact.
expect(yield* attempts(database, sessionID)).toBe(1)
expect((yield* claims(database))[sessionID]).toBe(true)
// Teardown (a graceful shutdown's interrupt) preserves both, so the next
// boot counts attempt 2 against the same turn.
yield* Scope.close(scope, Exit.void)
expect((yield* claims(database))[sessionID]).toBe(true)
expect(yield* attempts(database, sessionID)).toBe(1)
}),
)
it.effect("the sweep leaves Sessions already draining in this process untouched", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const sessionID = Session.ID.make("ses_resume_local_active")
yield* seedSessions(database, [sessionID])
const draining = yield* Deferred.make<void>()
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () =>
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
// A live local turn holds a claim; the sweep must not count, continue, or terminalize it.
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(draining)
yield* restart.resumeSuspendedSessions
expect(continued).toEqual([])
expect(yield* attempts(database, sessionID)).toBe(0)
expect((yield* claims(database))[sessionID]).toBe(true)
}),
)
})
function seedSessions(
database: Database.Service["Service"],
sessionIDs: ReadonlyArray<Session.ID>,
values: { time_suspended?: number } = {},
values: Partial<Pick<typeof SessionTable.$inferInsert, "time_suspended" | "resume_attempts" | "parent_id">> = {},
) {
return Effect.gen(function* () {
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* database.db
@@ -190,19 +320,35 @@ function seedSessions(
})
}
function suspensions(database: Database.Service["Service"]) {
function claims(database: Database.Service["Service"]) {
return database.db
.select({ id: SessionTable.id, suspended: SessionTable.time_suspended })
.select({ id: SessionTable.id, claimed: SessionTable.time_suspended })
.from(SessionTable)
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.id, row.suspended !== null]))),
Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.id, row.claimed !== null]))),
)
}
function attempts(database: Database.Service["Service"], sessionID: Session.ID) {
return database.db
.select({ attempts: SessionTable.resume_attempts })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row?.attempts),
)
}
/** Builds the local execution layer plus the restart actions against the test harness services. */
function buildExecution(scope: Scope.Closeable, drain: SessionRunner.Interface["drain"]) {
function buildExecution(
scope: Scope.Closeable,
drain: SessionRunner.Interface["drain"],
options?: SessionRestart.Options,
) {
return Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
@@ -218,7 +364,7 @@ function buildExecution(scope: Scope.Closeable, drain: SessionRunner.Interface["
),
)
return yield* Layer.buildWithScope(
SessionRestart.layer.pipe(
SessionRestart.layer(options).pipe(
Layer.provideMerge(SessionExecution.layer),
Layer.provide(Layer.succeed(Database.Service, database)),
Layer.provide(Layer.succeed(Bus.Service, bus)),
+1
View File
@@ -63,6 +63,7 @@ const session = (
time_compacting: 3,
time_archived: null,
time_suspended: null,
resume_attempts: 0,
...overrides,
})
@@ -37,7 +37,6 @@ export async function startBackgroundCli(logger: Logger) {
url: service.url,
username: service.auth.username,
password: service.auth.password,
version,
}
}
+3 -5
View File
@@ -134,10 +134,10 @@ const main = Effect.gen(function* () {
initCrashReporter()
const wslServers = createWslServersController(
CHANNEL === "beta" ? null : app.getVersion(),
async (distro, version, channel) => {
app.getVersion(),
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, version, channel, {
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
@@ -146,7 +146,6 @@ const main = Effect.gen(function* () {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
channel: CHANNEL,
},
)
const stopSidecars = async () => wslServers.stopAll()
@@ -312,7 +311,6 @@ const main = Effect.gen(function* () {
logger.log("starting v2 background service")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
if (CHANNEL === "beta") wslServers.setCliVersion(sidecar.version)
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
+8 -29
View File
@@ -264,22 +264,18 @@ export async function installWslDistro(name: string, opts?: RunWslOptions) {
)
}
export async function installWslOpencode(version: string, channel: string, distro: string, opts?: RunWslOptions) {
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(["bash", "-lc", wslOpencodeInstallCommand(version, channel)], distro),
wslArgs(
["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
distro,
),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export function wslOpencodeInstallCommand(version: string, channel: string) {
if (channel === "beta") {
return `npm install --global --no-audit --no-fund ${shellEscape(`@opencode-ai/cli@${version}`)}`
}
return `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
code: 1,
@@ -311,22 +307,11 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
}
}
export async function resolveWslOpencode(distro: string, channel: string, opts?: RunWslOptions) {
if (channel !== "beta") {
return firstLine(
(
await runWslSh(
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
distro,
opts,
)
).stdout,
)
}
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
return firstLine(
(
await runWslSh(
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//"); export PATH; command -v opencode2 || true',
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
distro,
opts,
)
@@ -336,13 +321,7 @@ export async function resolveWslOpencode(distro: string, channel: string, opts?:
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
return parseWslOpencodeVersion(firstLine(result.stdout))
}
export function parseWslOpencodeVersion(output: string | null) {
if (!output) return null
const marker = output.lastIndexOf(" v")
return marker === -1 ? output : output.slice(marker + 2)
return firstLine(result.stdout)
}
export function openWslTerminal(distro?: string | null) {
+4 -53
View File
@@ -13,10 +13,6 @@ import {
wslServerIdsToStartOnInitialize,
} from "./startup"
import { createWslServersController, type WslServerConfig } from "./servers"
import {
parseWslOpencodeVersion,
wslOpencodeInstallCommand,
} from "./runtime"
let persistedServers: WslServerConfig[] = []
let releaseOpencodeResolve: (() => void) | undefined
@@ -37,51 +33,6 @@ test("rejects an update that did not install the desktop version", () => {
)
})
test("installs the exact V2 CLI package through npm", () => {
const version = "0.0.0-next-17181"
const command = wslOpencodeInstallCommand(version, "beta")
expect(command).toBe("npm install --global --no-audit --no-fund '@opencode-ai/cli@0.0.0-next-17181'")
expect(command).not.toContain("@next")
expect(command).not.toContain("https://opencode.ai/install")
})
test("keeps the curl installer outside the beta channel", () => {
expect(wslOpencodeInstallCommand("1.18.16", "prod")).toBe(
"curl -fsSL https://opencode.ai/install | bash -s -- --version '1.18.16'",
)
expect(wslOpencodeInstallCommand("1.18.16", "dev")).toBe(
"curl -fsSL https://opencode.ai/install | bash -s -- --version '1.18.16'",
)
})
test("reads the version reported by the V2 binary", () => {
expect(parseWslOpencodeVersion("opencode2 v0.0.0-next-17181")).toBe("0.0.0-next-17181")
expect(parseWslOpencodeVersion("1.18.16")).toBe("1.18.16")
})
test("installs the bundled CLI version instead of the Desktop release version", async () => {
persistedServers = []
let requested: { version: string; channel: string; distro: string } | undefined
const controller = createWslServersController(null, async () => new Promise<never>(() => undefined), {
channel: "beta",
readServers: () => persistedServers,
writeServers: () => undefined,
resolveOpencode: async () => "/home/me/.npm/bin/opencode2",
readCommandVersion: async () => "0.0.0-next-17181",
installOpencode: async (version, channel, distro) => {
requested = { version, channel, distro }
return { code: 0, signal: null, stdout: "", stderr: "" }
},
})
controller.setCliVersion("0.0.0-next-17181")
await controller.installOpencode("Debian")
expect(requested).toEqual({ version: "0.0.0-next-17181", channel: "beta", distro: "Debian" })
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
})
test("restarts an existing distro server after updating OpenCode", () => {
expect(
wslServerIdToRestart(
@@ -104,7 +55,7 @@ test("clears cached distro probes when removing a WSL server", () => {
{
Debian: {
distro: "Debian",
resolvedPath: "/home/luke/.local/share/opencode/desktop/beta/1.16.2/opencode2",
resolvedPath: "/home/luke/.opencode/bin/opencode",
version: "1.16.2",
expectedVersion: "1.16.2",
matchesDesktop: true,
@@ -213,7 +164,7 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
},
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
return "/home/me/.opencode/bin/opencode"
},
})
@@ -244,7 +195,7 @@ test("does not check OpenCode in addable distros that cannot execute commands",
}),
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
return "/home/me/.opencode/bin/opencode"
},
})
@@ -274,7 +225,7 @@ function testControllerOptions() {
await new Promise<void>((resolve) => {
releaseOpencodeResolve = resolve
})
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
return "/home/me/.opencode/bin/opencode"
},
}
}
+8 -25
View File
@@ -37,7 +37,7 @@ type RunningSidecar = {
password: string
}
type SpawnSidecar = (distro: string, version: string, channel: string) => Promise<RunningSidecar>
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
type ControllerLogger = {
log: (message: string, meta?: unknown) => void
@@ -51,8 +51,6 @@ type WslServersControllerOptions = {
probeDistro?: typeof probeWslDistro
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
installOpencode?: typeof installWslOpencode
channel?: string
}
export type WslServersController = ReturnType<typeof createWslServersController>
@@ -62,7 +60,7 @@ export function wslServerIdForDistro(distro: string) {
}
export function createWslServersController(
initialCliVersion: string | null,
appVersion: string,
spawnSidecar: SpawnSidecar,
options?: WslServersControllerOptions,
) {
@@ -71,17 +69,10 @@ export function createWslServersController(
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
let cliVersion = initialCliVersion
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const probeDistro = options?.probeDistro ?? probeWslDistro
const channel = options?.channel ?? "dev"
const expectedVersion = () => {
if (cliVersion) return cliVersion
throw new Error(nativeT("desktop.wsl.error.opencodeCannotRun"))
}
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
@@ -141,12 +132,11 @@ export function createWslServersController(
}
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
const version = expectedVersion()
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, channel, opts)
const installed = resolved
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
const version = resolved
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
: null
return opencodeCheck(distro, resolved, installed, version)
return opencodeCheck(distro, resolved, version, appVersion)
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
@@ -239,7 +229,7 @@ export function createWslServersController(
setRuntime(id, { kind: "starting" })
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro, expectedVersion(), channel)
const sidecar = await spawnSidecar(item.config.distro)
if (!isCurrentStartAttempt(id, attempt)) {
try {
sidecar.listener.stop()
@@ -304,10 +294,6 @@ export function createWslServersController(
}
return {
setCliVersion(version: string) {
cliVersion = version
},
getState() {
return state
},
@@ -376,15 +362,12 @@ export function createWslServersController(
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
const version = expectedVersion()
const result = await (options?.installOpencode ?? installWslOpencode)(version, channel, name, {
signal: abort.signal,
})
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode"))
}
await refreshOpencodeCheck(name, { signal: abort.signal })
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, version, name)
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
const id = wslServerIdToRestart(state.servers, name)
if (id) await startServer(id)
})
+2 -14
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { app } from "electron"
import { checkHealth } from "../server"
import { type WslCommandLine, readWslCommandVersion, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { pollWslHealth } from "./startup"
import { nativeT } from "../native-translations"
@@ -16,22 +16,10 @@ export type WslSidecar = {
export async function spawnWslSidecar(
distro: string,
version: string,
channel: string,
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
const opencode = await resolveWslOpencode(distro, channel)
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro }))
const installed = await readWslCommandVersion(opencode, distro)
if (installed !== version) {
throw new Error(
nativeT("desktop.wsl.error.updateVersion", {
distro,
installed: installed ?? nativeT("desktop.wsl.error.noVersion"),
expected: version,
}),
)
}
const port = await allocatePort()
const password = randomUUID()
+14 -1
View File
@@ -13,6 +13,19 @@ const session = yield * opencode.sessions.get({ sessionID })
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
Embedded hosts are silent by default. Set `log` to receive structured log entries at the selected minimum level:
```ts
const opencode =
yield *
OpenCode.create({
log: {
level: "warn",
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
},
})
```
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
The same constructor is available as a service Layer:
@@ -26,4 +39,4 @@ const program = Effect.gen(function* () {
yield * program.pipe(Effect.provide(OpenCode.layer))
```
`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.
`OpenCode.layer` adapts the silent default `OpenCode.create()` for dependency injection; use `OpenCode.layerWith(options)` to configure the host.
+71
View File
@@ -0,0 +1,71 @@
import { Context, Formatter, Layer, Logger, References } from "effect"
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"
export type LogEntry = {
readonly level: LogLevel
readonly message: string
readonly attributes?: Readonly<Record<string, unknown>>
readonly cause?: unknown
}
export type LogWriter = (entry: LogEntry) => void
export type LogOptions = {
readonly level?: LogLevel
readonly emit: LogWriter
}
const levels: Record<LogLevel, Logger.Options<unknown>["logLevel"]> = {
trace: "Trace",
debug: "Debug",
info: "Info",
warn: "Warn",
error: "Error",
fatal: "Fatal",
}
function normalizeLevel(level: Logger.Options<unknown>["logLevel"]): LogLevel | undefined {
const output = Object.fromEntries(Object.entries(levels).map(([name, effect]) => [effect, name]))
return output[level] as LogLevel
}
export function layer(log?: LogOptions) {
const logger = Logger.make((options) => {
if (!log) return
const level = normalizeLevel(options.logLevel)
if (!level) return
const entry = Logger.formatStructured.log(options)
const values = Array.isArray(entry.message) ? entry.message : [entry.message]
const [message, ...data] = values
const details =
data.length === 1 && !Array.isArray(data[0]) ? (data[0] as Readonly<Record<string, unknown>>) : undefined
const { cause: detailCause, ...detailAttributes } = details ?? {}
const attributes = {
...entry.annotations,
...detailAttributes,
...(Object.keys(entry.spans).length > 0 ? { spans: entry.spans } : {}),
...(!details && data.length > 0 ? { data: data.length === 1 ? data[0] : data } : {}),
}
try {
log.emit({
level,
message: typeof message === "string" ? message : Formatter.format(message),
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
...(entry.cause === undefined && detailCause === undefined ? {} : { cause: entry.cause ?? detailCause }),
})
} catch {
// A host logger must not break OpenCode operations.
}
})
return Layer.merge(
Logger.layer([logger], { mergeWithExisting: false }),
Layer.succeed(References.MinimumLogLevel, levels[log?.level ?? "info"]),
)
}
export function context(source: Context.Context<never>) {
return Context.make(Logger.CurrentLoggers, Context.get(source, Logger.CurrentLoggers)).pipe(
Context.add(References.MinimumLogLevel, Context.get(source, References.MinimumLogLevel)),
)
}
+19 -8
View File
@@ -2,18 +2,27 @@ import { OpenCode } from "@opencode-ai/client/effect"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import * as Logging from "./logging"
export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) {
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
import type { LogOptions } from "./logging"
export type CreateOptions = ServerOptions & {
readonly log?: LogOptions
}
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) {
const { log, ...server } = options
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
createEmbeddedRoutes({
...options,
app: { ...options.app, name: options.app?.name ?? "sdk" },
database: { path: ":memory:", ...options.database },
}).pipe(Layer.provide(HttpServer.layerServices)),
...server,
app: { ...server.app, name: server.app?.name ?? "sdk" },
database: { path: ":memory:", ...server.database },
}).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
),
),
(runtime) => runtime.disposeEffect,
@@ -21,7 +30,9 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
const context = yield* runtime.contextEffect
const plugins = Context.get(context, SdkPlugins.Service)
const router = Context.get(context, HttpRouter.HttpRouter)
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
Logging.context(context),
)(router.asHttpEffect())
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
preconnect: () => undefined,
}) satisfies typeof globalThis.fetch
+3 -5
View File
@@ -247,12 +247,10 @@ function unavailable(status: Status.State) {
}
/**
* The managed server owns restart continuity: it resumes Sessions the previous server suspended and
* suspends its own active Sessions on graceful shutdown. Suspension runs while the drains are still
* alive: connections close first, this finalizer runs next, and Session execution teardown follows.
* The managed server owns restart continuity: at boot it resumes Sessions whose execution claim was
* never released. Claims are written when execution starts (see SessionExecution), so recovery covers
* graceful restarts and unclean deaths alike — no shutdown hook participates.
*/
const installRestartContinuity = Effect.fnUntraced(function* (restart: SessionRestart.Interface) {
yield* Effect.forkScoped(restart.resumeSuspendedSessions)
// Registered after the fork so suspension observes still-running resumed drains during teardown.
yield* Effect.addFinalizer(() => restart.suspendActiveSessions)
})
+3 -3
View File
@@ -7,13 +7,13 @@ import Config from "@npmcli/config"
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect } from "effect"
const npmPath = fileURLToPath(new URL("..", import.meta.url))
export const load = (dir: string) =>
Effect.tryPromise({
try: async () => {
const config = new Config({
npmPath,
// Resolved per call: on workerd import.meta.url is undefined and building
// this URL at module scope fails startup validation; npm config never runs there.
npmPath: fileURLToPath(new URL("..", import.meta.url)),
cwd: dir,
env: { ...process.env },
argv: [process.execPath, process.execPath, "--prefix", dir],
+8 -2
View File
@@ -1,6 +1,6 @@
export * as Observability from "./observability.js"
import { NodeFileSystem } from "@effect/platform-node"
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import { LayerNode } from "./effect/layer-node.js"
import { Effect, Layer, Logger, References, Schema } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
@@ -50,4 +50,10 @@ export function layer(
).pipe(Layer.catchCause(() => local))
}
export const node = LayerNode.make({ name: "observability", layer: layer(), deps: [] })
// Layer.suspend: constructing the loggers eagerly at module scope performs
// I/O (file logger, run id) that workerd forbids in global scope.
export const node = LayerNode.make({
name: "observability",
layer: Layer.suspend(() => layer()),
deps: [],
})
+2 -2
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global.js"
import { runID } from "./shared.js"
function formatter(id: string = runID) {
function formatter(id: string = runID()) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
return [
@@ -51,7 +51,7 @@ export function file(local = true, channel = "local") {
return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
}
export function fileLogger(target = file(), id: string = runID) {
export function fileLogger(target = file(), id: string = runID()) {
// Do not set batchWindow to 0; it causes high idle CPU usage.
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
+2 -2
View File
@@ -54,8 +54,8 @@ export function resource(app: App = { client: "opencode", version: "unknown", ch
...resourceAttributes(),
"deployment.environment.name": app.channel,
"opencode.client": app.client,
"opencode.run": runID,
"service.instance.id": runID,
"opencode.run": runID(),
"service.instance.id": runID(),
},
}
}
+8 -1
View File
@@ -1 +1,8 @@
export const runID = crypto.randomUUID().slice(0, 8)
// Lazy: workerd forbids generating random values in global scope, so the id
// materializes on first call (inside a handler) and stays stable afterwards.
let generated: string | undefined
export function runID(): string {
generated ??= crypto.randomUUID().slice(0, 8)
return generated
}
@@ -19,8 +19,9 @@ V2 loads:
1. The global file at `$XDG_CONFIG_HOME/opencode/AGENTS.md`, normally
`~/.config/opencode/AGENTS.md`.
2. Every `AGENTS.md` from the current Location up to and including the project
root.
2. Every `AGENTS.md` from the current Location up to and including the home
directory when the Location is inside it. For Locations outside home, the
scan stops at the project root.
For example, when the Location is `packages/web`, OpenCode can load all three
project files below:
@@ -35,9 +36,9 @@ my-project/
```
The files are combined rather than selecting a single winner. They are rendered
in this order: global, then project files from the Location toward the project
in this order: global, then files from the Location toward home or the project
root. OpenCode does not resolve conflicts between their contents, so keep broad
guidance global and put scoped guidance in the relevant project directory.
guidance global and put scoped guidance in the relevant directory.
If the Location is outside the project root, only the global file is loaded.
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
+2 -2
View File
@@ -504,8 +504,8 @@ require a V2 rewrite. See [Skills](/skills).
### Instruction files
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and project `AGENTS.md`
files from the current directory up to the project root.
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and ambient `AGENTS.md`
files from the current directory up to home. For projects outside home, discovery stops at the project root.
If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the applicable `AGENTS.md`. V2 currently only
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected