Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton 221aa8c19e test(opencode): clear otlp url in preload 2026-04-07 16:50:55 -04:00
Kit Langton 544d012515 fix(opencode): make leto export opt-in by url 2026-04-07 16:49:45 -04:00
Kit Langton ba41b6928f wip: wire local leto observability 2026-04-06 11:00:42 -04:00
30 changed files with 262 additions and 151 deletions

View File

@ -78,7 +78,7 @@ export namespace Agent {
const provider = yield* Provider.Service const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const cfg = yield* config.get() const cfg = yield* config.get()
const skillDirs = yield* skill.dirs() const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]

View File

@ -47,7 +47,7 @@ export namespace Bus {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>() const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>() const typed = new Map<string, PubSub.PubSub<Payload>>()

View File

@ -79,7 +79,7 @@ export namespace Command {
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const init = Effect.fn("Command.state")(function* (ctx) { const init = Effect.fnUntraced(function* (ctx) {
const cfg = yield* config.get() const cfg = yield* config.get()
const commands: Record<string, Info> = {} const commands: Record<string, Info> = {}

View File

@ -1475,7 +1475,7 @@ export namespace Config {
}) })
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
return yield* loadInstanceState(ctx) return yield* loadInstanceState(ctx)
}), }),
) )

View File

@ -0,0 +1,26 @@
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta"
export namespace Observability {
const base = Flag.OPENCODE_OTLP_BASE_URL?.trim() || undefined
export const enabled = !!base
export const layer = !base
? Layer.empty
: Otlp.layerJson({
baseUrl: base,
loggerMergeWithExisting: false,
resource: {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
},
}).pipe(Layer.provide(FetchHttpClient.layer))
}

View File

@ -3,6 +3,7 @@ import * as ServiceMap from "effect/ServiceMap"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Context } from "@/util/context" import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref" import { InstanceRef } from "./instance-ref"
import { Observability } from "./observability"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
@ -18,7 +19,7 @@ function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) { export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap })) const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap }))
return { return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))), runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))),

View File

@ -346,11 +346,11 @@ export namespace File {
const appFs = yield* AppFileSystem.Service const appFs = yield* AppFileSystem.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() => Effect.fnUntraced(function* () {
Effect.succeed({ return {
cache: { files: [], dirs: [] } as Entry, cache: { files: [], dirs: [] } as Entry,
}
}), }),
),
) )
const scan = Effect.fn("File.scan")(function* () { const scan = Effect.fn("File.scan")(function* () {

View File

@ -54,12 +54,12 @@ export namespace FileTime {
} }
}) })
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() => Effect.fnUntraced(function* () {
Effect.succeed({ return {
reads: new Map<SessionID, Map<string, Stamp>>(), reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(), locks: new Map<string, Semaphore.Semaphore>(),
}
}), }),
),
) )
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {

View File

@ -73,7 +73,7 @@ export namespace FileWatcher {
const config = yield* Config.Service const config = yield* Config.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")( Effect.fnUntraced(
function* () { function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return

View File

@ -44,6 +44,7 @@ export namespace Flag {
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL") export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
export const OPENCODE_OTLP_BASE_URL = process.env["OPENCODE_OTLP_BASE_URL"]
// Experimental // Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")

View File

@ -40,7 +40,7 @@ export namespace Format {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (_ctx) { Effect.fnUntraced(function* (_ctx) {
const commands: Record<string, string[] | false> = {} const commands: Record<string, string[] | false> = {}
const formatters: Record<string, Formatter.Info> = {} const formatters: Record<string, Formatter.Info> = {}
@ -84,50 +84,99 @@ export namespace Format {
return cmd !== false return cmd !== false
} }
async function getFormatter(ext: string) { function check(item: Formatter.Info, ext: string) {
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) return Effect.gen(function* () {
const checks = await Promise.all( yield* Effect.annotateCurrentSpan({
matching.map(async (item) => { ext,
formatter: item.name,
})
log.info("checking", { name: item.name, ext }) log.info("checking", { name: item.name, ext })
const cmd = await getCommand(item) const cmd = yield* Effect.promise(() => getCommand(item))
if (cmd) { if (cmd) {
log.info("enabled", { name: item.name, ext }) log.info("enabled", { name: item.name, ext })
} }
yield* Effect.annotateCurrentSpan({ enabled: !!cmd })
return { return {
item, item,
cmd, cmd,
} }
}), }).pipe(Effect.withSpan("Format.checkFormatter"))
)
return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! }))
} }
function formatFile(filepath: string) { function resolve(ext: string) {
return Effect.gen(function* () { return Effect.gen(function* () {
log.info("formatting", { file: filepath }) const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
const ext = path.extname(filepath) const checks = yield* Effect.all(matching.map((item) => check(item, ext)))
const enabled = checks.filter((item) => item.cmd).map((item) => ({ item: item.item, cmd: item.cmd! }))
yield* Effect.annotateCurrentSpan({
ext,
matched_formatters: matching.map((item) => item.name).join(",") || "none",
enabled_formatters: enabled.map((item) => item.item.name).join(",") || "none",
})
return {
matching,
enabled,
}
}).pipe(Effect.withSpan("Format.resolveFormatters"))
}
for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { function spawn(item: Formatter.Info, command: string[], filepath: string) {
if (cmd === false) continue return Effect.gen(function* () {
log.info("running", { command: cmd })
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory const dir = yield* InstanceState.directory
const code = yield* spawner yield* Effect.annotateCurrentSpan({
.spawn( file: filepath,
ChildProcess.make(replaced[0]!, replaced.slice(1), { formatter: item.name,
command: command.join(" "),
})
return yield* spawner.spawn(
ChildProcess.make(command[0]!, command.slice(1), {
cwd: dir, cwd: dir,
env: item.environment, env: item.environment,
extendEnv: true, extendEnv: true,
}), }),
) )
.pipe( }).pipe(Effect.withSpan("Format.spawnFormatter"))
Effect.flatMap((handle) => handle.exitCode), }
function wait(
handle: ChildProcessSpawner.ChildProcessHandle,
item: Formatter.Info,
command: string[],
filepath: string,
) {
return Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({
file: filepath,
formatter: item.name,
command: command.join(" "),
})
return yield* handle.exitCode
}).pipe(Effect.withSpan("Format.waitFormatter"))
}
function formatFile(filepath: string): Effect.Effect<void, never, never> {
return Effect.gen(function* () {
log.info("formatting", { file: filepath })
const ext = path.extname(filepath)
yield* Effect.annotateCurrentSpan({ file: filepath, ext })
const fmt = yield* resolve(ext)
yield* Effect.annotateCurrentSpan({
matched_formatters: fmt.matching.map((item) => item.name).join(",") || "none",
enabled_formatters: fmt.enabled.map((item) => item.item.name).join(",") || "none",
})
for (const { item, cmd } of fmt.enabled) {
if (cmd === false) continue
log.info("running", { command: cmd })
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
const code = yield* spawn(item, replaced, filepath).pipe(
Effect.flatMap((handle) => wait(handle, item, replaced, filepath)),
Effect.scoped, Effect.scoped,
Effect.catch(() => Effect.catch(() =>
Effect.sync(() => { Effect.sync(() => {
log.error("failed to format file", { log.error("failed to format file", {
error: "spawn failed", error: "spawn failed",
command: cmd, command: replaced,
...item.environment, ...item.environment,
file: filepath, file: filepath,
}) })

View File

@ -164,7 +164,7 @@ export namespace LSP {
const config = yield* Config.Service const config = yield* Config.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* () { Effect.fnUntraced(function* () {
const cfg = yield* config.get() const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {} const servers: Record<string, LSPServer.Info> = {}

View File

@ -478,7 +478,7 @@ export namespace MCP {
} }
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("MCP.state")(function* () { Effect.fnUntraced(function* () {
const cfg = yield* cfgSvc.get() const cfg = yield* cfgSvc.get()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
const s: State = { const s: State = {

View File

@ -142,7 +142,7 @@ export namespace Permission {
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const row = Database.use((db) => const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(), db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
) )

View File

@ -98,7 +98,7 @@ export namespace Plugin {
const config = yield* Config.Service const config = yield* Config.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const hooks: Hooks[] = [] const hooks: Hooks[] = []
const { Server } = yield* Effect.promise(() => import("../server/server")) const { Server } = yield* Effect.promise(() => import("../server/server"))

View File

@ -147,8 +147,7 @@ export namespace Vcs {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")((ctx) => Effect.fnUntraced(function* (ctx) {
Effect.gen(function* () {
if (ctx.project.vcs !== "git") { if (ctx.project.vcs !== "git") {
return { current: undefined, root: undefined } return { current: undefined, root: undefined }
} }
@ -179,7 +178,6 @@ export namespace Vcs {
return value return value
}), }),
),
) )
return Service.of({ return Service.of({

View File

@ -117,7 +117,7 @@ export namespace ProviderAuth {
const auth = yield* Auth.Service const auth = yield* Auth.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ProviderAuth.state")(function* () { Effect.fnUntraced(function* () {
const plugins = yield* plugin.list() const plugins = yield* plugin.list()
return { return {
hooks: Record.fromEntries( hooks: Record.fromEntries(

View File

@ -133,7 +133,7 @@ export namespace Pty {
} }
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Pty.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const state = { const state = {
dir: ctx.directory, dir: ctx.directory,
sessions: new Map<PtyID, Active>(), sessions: new Map<PtyID, Active>(),

View File

@ -111,7 +111,7 @@ export namespace Question {
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Question.state")(function* () { Effect.fnUntraced(function* () {
const state = { const state = {
pending: new Map<QuestionID, PendingEntry>(), pending: new Map<QuestionID, PendingEntry>(),
} }

View File

@ -138,7 +138,7 @@ export namespace SessionCompaction {
} }
}) })
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { const process = Effect.fn("SessionCompaction.process")(function* (input: {
parentID: MessageID parentID: MessageID
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
sessionID: SessionID sessionID: SessionID
@ -374,7 +374,7 @@ When constructing the summary, try to stick to this template:
return Service.of({ return Service.of({
isOverflow, isOverflow,
prune, prune,
process: processCompaction, process,
create, create,
}) })
}), }),

View File

@ -75,12 +75,12 @@ export namespace Instruction {
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() => Effect.fnUntraced(function* () {
Effect.succeed({ return {
// Track which instruction files have already been attached for a given assistant message. // Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(), claims: new Map<MessageID, Set<string>>(),
}
}), }),
),
) )
const relative = Effect.fnUntraced(function* (instruction: string) { const relative = Effect.fnUntraced(function* (instruction: string) {

View File

@ -415,9 +415,20 @@ export namespace SessionProcessor {
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined }) log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
yield* Effect.logError("session processor failed", {
agent: ctx.assistantMessage.agent,
modelID: ctx.model.id,
providerID: ctx.model.providerID,
sessionID: ctx.sessionID,
})
const error = parse(e) const error = parse(e)
if (MessageV2.ContextOverflowError.isInstance(error)) { if (MessageV2.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true ctx.needsCompaction = true
yield* Effect.logWarning("session processor requested compaction", {
modelID: ctx.model.id,
providerID: ctx.model.providerID,
sessionID: ctx.sessionID,
})
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
return return
} }
@ -446,6 +457,18 @@ export namespace SessionProcessor {
log.info("process") log.info("process")
ctx.needsCompaction = false ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
yield* Effect.annotateCurrentSpan({
agent: streamInput.agent.name,
modelID: streamInput.model.id,
providerID: streamInput.model.providerID,
sessionID: ctx.sessionID,
})
yield* Effect.logInfo("session processor started", {
agent: streamInput.agent.name,
modelID: streamInput.model.id,
providerID: streamInput.model.providerID,
sessionID: ctx.sessionID,
})
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
@ -459,6 +482,7 @@ export namespace SessionProcessor {
Stream.runDrain, Stream.runDrain,
) )
}).pipe( }).pipe(
Effect.withSpan("SessionProcessor.stream"),
Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))), Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))),
Effect.catchCauseIf( Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause), (cause) => !Cause.hasInterruptsOnly(cause),
@ -483,6 +507,12 @@ export namespace SessionProcessor {
if (aborted && !ctx.assistantMessage.error) { if (aborted && !ctx.assistantMessage.error) {
yield* abort() yield* abort()
} }
yield* Effect.logInfo("session processor finished", {
aborted,
blocked: ctx.blocked,
compact: ctx.needsCompaction,
sessionID: ctx.sessionID,
})
if (ctx.needsCompaction) return "compact" if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop" if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop"
return "continue" return "continue"

View File

@ -103,7 +103,7 @@ export namespace SessionPrompt {
const instruction = yield* Instruction.Service const instruction = yield* Instruction.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("SessionPrompt.state")(function* () { Effect.fnUntraced(function* () {
const runners = new Map<string, Runner<MessageV2.WithParts>>() const runners = new Map<string, Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer( yield* Effect.addFinalizer(
Effect.fnUntraced(function* () { Effect.fnUntraced(function* () {
@ -1340,12 +1340,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
let structured: unknown | undefined let structured: unknown | undefined
let step = 0 let step = 0
const session = yield* sessions.get(sessionID) const session = yield* sessions.get(sessionID)
yield* Effect.annotateCurrentSpan({ sessionID })
while (true) { while (true) {
yield* status.set(sessionID, { type: "busy" }) yield* status.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID }) log.info("loop", { step, sessionID })
let msgs = yield* MessageV2.filterCompactedEffect(sessionID) let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(
Effect.withSpan("SessionPrompt.loadMessages"),
)
let lastUser: MessageV2.User | undefined let lastUser: MessageV2.User | undefined
let lastAssistant: MessageV2.Assistant | undefined let lastAssistant: MessageV2.Assistant | undefined
@ -1398,13 +1401,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
} }
if (task?.type === "compaction") { if (task?.type === "compaction") {
const result = yield* compaction.process({ yield* Effect.logWarning("session compaction task", {
auto: task.auto,
overflow: task.overflow,
sessionID,
})
const result = yield* compaction
.process({
messages: msgs, messages: msgs,
parentID: lastUser.id, parentID: lastUser.id,
sessionID, sessionID,
auto: task.auto, auto: task.auto,
overflow: task.overflow, overflow: task.overflow,
}) })
.pipe(Effect.withSpan("SessionPrompt.compaction"))
if (result === "stop") break if (result === "stop") break
continue continue
} }
@ -1414,6 +1424,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
lastFinished.summary !== true && lastFinished.summary !== true &&
(yield* compaction.isOverflow({ tokens: lastFinished.tokens, model })) (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
) { ) {
yield* Effect.logWarning("session overflow detected", { modelID: model.id, sessionID, step })
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
continue continue
} }
@ -1429,6 +1440,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const maxSteps = agent.steps ?? Infinity const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps const isLastStep = step >= maxSteps
msgs = yield* insertReminders({ messages: msgs, agent, session }) msgs = yield* insertReminders({ messages: msgs, agent, session })
yield* Effect.logInfo("session turn", {
agent: agent.name,
modelID: model.id,
providerID: model.providerID,
sessionID,
step,
})
const msg: MessageV2.Assistant = { const msg: MessageV2.Assistant = {
id: MessageID.ascending(), id: MessageID.ascending(),
@ -1503,7 +1521,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Effect.promise(() => SystemPrompt.environment(model)), Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie), instruction.system().pipe(Effect.orDie),
Effect.promise(() => MessageV2.toModelMessages(msgs, model)), Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
]) ]).pipe(Effect.withSpan("SessionPrompt.buildInput"))
const system = [...env, ...(skills ? [skills] : []), ...instructions] const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const } const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)

View File

@ -58,7 +58,9 @@ export namespace SessionStatus {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())), Effect.fnUntraced(function* () {
return new Map<SessionID, Info>()
}),
) )
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) { const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {

View File

@ -99,8 +99,8 @@ export namespace SessionSummary {
if (part.type === "step-finish" && part.snapshot) to = part.snapshot if (part.type === "step-finish" && part.snapshot) to = part.snapshot
} }
} }
if (from && to) return yield* snapshot.diffFull(from, to) if (!from || !to || from === to) return []
return [] return yield* snapshot.diffFull(from, to)
}) })
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {

View File

@ -144,7 +144,7 @@ export namespace ShareNext {
} }
const state: InstanceState<State> = yield* InstanceState.make<State>( const state: InstanceState<State> = yield* InstanceState.make<State>(
Effect.fn("ShareNext.state")(function* (_ctx) { Effect.fnUntraced(function* (_ctx) {
const cache: State = { queue: new Map(), scope: yield* Scope.make() } const cache: State = { queue: new Map(), scope: yield* Scope.make() }
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>

View File

@ -197,7 +197,7 @@ export namespace Skill {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const fsys = yield* AppFileSystem.Service const fsys = yield* AppFileSystem.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("Skill.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const s: State = { skills: {}, dirs: new Set() } const s: State = { skills: {}, dirs: new Set() }
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree) yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
return s return s

View File

@ -1,4 +1,3 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect" import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
@ -82,7 +81,7 @@ export namespace Snapshot {
} }
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Snapshot.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const state = { const state = {
directory: ctx.directory, directory: ctx.directory,
worktree: ctx.worktree, worktree: ctx.worktree,
@ -150,7 +149,7 @@ export namespace Snapshot {
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
}) })
const add = Effect.fnUntraced(function* () { const add = Effect.fn("Snapshot.add")(function* () {
yield* sync() yield* sync()
const [diff, other] = yield* Effect.all( const [diff, other] = yield* Effect.all(
[ [
@ -203,7 +202,7 @@ export namespace Snapshot {
} }
}) })
const cleanup = Effect.fnUntraced(function* () { const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
if (!(yield* enabled())) return if (!(yield* enabled())) return
@ -221,7 +220,7 @@ export namespace Snapshot {
) )
}) })
const track = Effect.fnUntraced(function* () { const track = Effect.fn("Snapshot.track")(function* () {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
if (!(yield* enabled())) return if (!(yield* enabled())) return
@ -238,7 +237,9 @@ export namespace Snapshot {
log.info("initialized") log.info("initialized")
} }
yield* add() yield* add()
const result = yield* git(args(["write-tree"]), { cwd: state.directory }) const result = yield* git(args(["write-tree"]), { cwd: state.directory }).pipe(
Effect.withSpan("Snapshot.writeTree"),
)
const hash = result.text.trim() const hash = result.text.trim()
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
return hash return hash
@ -246,7 +247,7 @@ export namespace Snapshot {
) )
}) })
const patch = Effect.fnUntraced(function* (hash: string) { const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
yield* add() yield* add()
@ -273,7 +274,7 @@ export namespace Snapshot {
) )
}) })
const restore = Effect.fnUntraced(function* (snapshot: string) { const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
log.info("restore", { commit: snapshot }) log.info("restore", { commit: snapshot })
@ -299,7 +300,7 @@ export namespace Snapshot {
) )
}) })
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) { const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
const ops: { hash: string; file: string; rel: string }[] = [] const ops: { hash: string; file: string; rel: string }[] = []
@ -414,7 +415,7 @@ export namespace Snapshot {
) )
}) })
const diff = Effect.fnUntraced(function* (hash: string) { const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
yield* add() yield* add()
@ -434,7 +435,7 @@ export namespace Snapshot {
) )
}) })
const diffFull = Effect.fnUntraced(function* (from: string, to: string) { const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
return yield* locked( return yield* locked(
Effect.gen(function* () { Effect.gen(function* () {
type Row = { type Row = {
@ -451,7 +452,7 @@ export namespace Snapshot {
ref: string ref: string
} }
const show = Effect.fnUntraced(function* (row: Row) { const show = Effect.fn("Snapshot.show")(function* (row: Row) {
if (row.binary) return ["", ""] if (row.binary) return ["", ""]
if (row.status === "added") { if (row.status === "added") {
return [ return [
@ -478,7 +479,7 @@ export namespace Snapshot {
) )
}) })
const load = Effect.fnUntraced( const load = Effect.fn("Snapshot.load")(
function* (rows: Row[]) { function* (rows: Row[]) {
const refs = rows.flatMap((row) => { const refs = rows.flatMap((row) => {
if (row.binary) return [] if (row.binary) return []
@ -583,7 +584,7 @@ export namespace Snapshot {
const statuses = yield* git( const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: state.directory }, { cwd: state.directory },
) ).pipe(Effect.withSpan("Snapshot.diffStatus"))
for (const line of statuses.text.trim().split("\n")) { for (const line of statuses.text.trim().split("\n")) {
if (!line) continue if (!line) continue
@ -597,7 +598,7 @@ export namespace Snapshot {
{ {
cwd: state.directory, cwd: state.directory,
}, },
) ).pipe(Effect.withSpan("Snapshot.diffNumstat"))
const rows = numstat.text const rows = numstat.text
.trim() .trim()
@ -660,30 +661,14 @@ export namespace Snapshot {
) )
return Service.of({ return Service.of({
init: Effect.fn("Snapshot.init")(function* () { init: () => InstanceState.get(state).pipe(Effect.asVoid),
yield* InstanceState.get(state) cleanup: () => InstanceState.useEffect(state, (s) => s.cleanup()),
}), track: () => InstanceState.useEffect(state, (s) => s.track()),
cleanup: Effect.fn("Snapshot.cleanup")(function* () { patch: (hash: string) => InstanceState.useEffect(state, (s) => s.patch(hash)),
return yield* InstanceState.useEffect(state, (s) => s.cleanup()) restore: (snapshot: string) => InstanceState.useEffect(state, (s) => s.restore(snapshot)),
}), revert: (patches: Snapshot.Patch[]) => InstanceState.useEffect(state, (s) => s.revert(patches)),
track: Effect.fn("Snapshot.track")(function* () { diff: (hash: string) => InstanceState.useEffect(state, (s) => s.diff(hash)),
return yield* InstanceState.useEffect(state, (s) => s.track()) diffFull: (from: string, to: string) => InstanceState.useEffect(state, (s) => s.diffFull(from, to)),
}),
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
}),
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
}),
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
}),
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
}),
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
}),
}) })
}), }),
) )

View File

@ -82,7 +82,7 @@ export namespace ToolRegistry {
Effect.isEffect(tool) ? tool : Effect.succeed(tool) Effect.isEffect(tool) ? tool : Effect.succeed(tool)
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fnUntraced(function* (ctx) {
const custom: Tool.Info[] = [] const custom: Tool.Info[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Info { function fromPlugin(id: string, def: ToolDefinition): Tool.Info {

View File

@ -33,6 +33,7 @@ process.env["XDG_DATA_HOME"] = path.join(dir, "share")
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["XDG_STATE_HOME"] = path.join(dir, "state")
delete process.env["OPENCODE_OTLP_BASE_URL"]
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
// Set test home directory to isolate tests from user's actual home directory // Set test home directory to isolate tests from user's actual home directory