Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 20 additions & 15 deletions packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
Message,
Expand Down Expand Up @@ -302,21 +303,25 @@ export function applyDirectoryEvent(input: {
if (!result.found) break
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
input.setStore(
"part_text_accum_delta",
props.partID,
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
)
input.setStore(
"part",
props.messageID,
produce((draft) => {
const part = draft[result.index]
const field = props.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + props.delta
}),
)
// Hot path: one batch per delta so subscribers see a single reactive
// notification instead of two (accumulator + part) per streamed token.
batch(() => {
input.setStore(
"part_text_accum_delta",
props.partID,
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
)
input.setStore(
"part",
props.messageID,
produce((draft) => {
const part = draft[result.index]
const field = props.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + props.delta
}),
)
})
break
}
case "vcs.branch.updated": {
Expand Down
9 changes: 6 additions & 3 deletions packages/console/app/src/routes/zen/util/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,16 +546,19 @@ export async function handler(
return provider
})
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))

// Use the last 4 characters of session ID to select a provider
let h = 0
const l = stickyId.length
for (let i = l - 4; i < l; i++) {
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0]
// Weighted pick over cumulative weights — same slot layout as
// physically expanding each provider `weight` times, without the
// O(Σweight) array allocation per request.
const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0)
let slot = totalWeight > 0 ? (h >>> 0) % totalWeight : 0
const provider = providers.find((p) => (slot -= p.weight) < 0) ?? providers[0]

// sticky provider does not exist => use selected provider
if (!stickyProviderId) return provider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,23 @@ export async function convertToOpenAIResponsesInput({
const reasoningMessages: Record<string, OpenAIResponsesReasoning> = {}
const toolCallParts: Record<string, LanguageModelV3ToolCallPart> = {}

for (const part of content) {
// Request-build hot path: parse provider options for all reasoning
// parts in one batch instead of awaiting once per part inside the
// loop. Each parse is independent pure validation, so batching keeps
// ordering and the cross-part reasoningMessages state intact.
const reasoningOptions = await Promise.all(
content.map((part) =>
part.type === "reasoning"
? parseProviderOptions({
provider: "copilot",
providerOptions: part.providerOptions,
schema: openaiResponsesReasoningProviderOptionsSchema,
})
: Promise.resolve(undefined),
),
)

for (const [partIndex, part] of content.entries()) {
switch (part.type) {
case "text": {
input.push({
Expand Down Expand Up @@ -183,11 +199,7 @@ export async function convertToOpenAIResponsesInput({
}

case "reasoning": {
const providerOptions = await parseProviderOptions({
provider: "copilot",
providerOptions: part.providerOptions,
schema: openaiResponsesReasoningProviderOptionsSchema,
})
const providerOptions = reasoningOptions[partIndex]

const reasoningId = providerOptions?.itemId

Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Cause, DateTime, Deferred, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
Expand Down Expand Up @@ -136,7 +136,17 @@ export const layer = Layer.effect(
})

const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
Effect.raceFirst(
FiberSet.join(fibers),
// awaitEmpty also succeeds when the last fiber failed: the FiberSet
// observer deletes from the backing set before completing the join
// deferred, so both racers become ready in the same tick. Re-check the
// deferred so a lost race cannot swallow a tool settlement failure.
FiberSet.awaitEmpty(fibers).pipe(
Effect.andThen(Deferred.isDone(fibers.deferred)),
Effect.flatMap((failed) => (failed ? FiberSet.join(fibers) : Effect.void)),
),
)

// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,13 @@ function makeUsageService(sdk: OpencodeClient) {
) as Record<ProviderV2.ID, Provider.Info>
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
})
.catch(() => undefined)
.catch(() => {
// A transient lookup failure must not be cached forever — drop the
// in-flight entry so the next call retries instead of permanently
// disabling context usage for this model/directory combination.
if (limits.get(key) === next) limits.delete(key)
return undefined
})
limits.set(key, next)
return yield* Effect.promise(() => next)
},
Expand Down
37 changes: 35 additions & 2 deletions packages/opencode/src/cli/cmd/run/stream.transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,15 @@
let replayDisabled = false
let replayPending: SessionResizeReplayInput | undefined
const buffered: Event[] = []
// Untracked-session events are re-buffered indefinitely by
// drainBuffered (they only become useful if their session turns into
// a tracked subagent tab shortly after). Cap the backlog dropping the
// oldest so unrelated sessions cannot grow memory without bound.
const BUFFERED_EVENT_LIMIT = 1000
const buffer = (event: Event) => {
buffered.push(event)
if (buffered.length > BUFFERED_EVENT_LIMIT) buffered.splice(0, buffered.length - BUFFERED_EVENT_LIMIT)
}
const replayedParts = new Set<string>()
const recovering = new Set<string>()
const tracked = (sessionID: string | undefined) =>
Expand Down Expand Up @@ -560,7 +569,19 @@

recovering.add(partID)
try {
// Recovery is bounded: the question request either already exists
// server-side (found within a few polls) or will never appear. An
// unbounded loop polls one HTTP list call per 250ms until session
// close whenever the server fails to register the question.
const deadline = Date.now() + 120_000
while (!closed && !abort.signal.aborted && !input.footer.isClosed) {

Check warning on line 577 in packages/opencode/src/cli/cmd/run/stream.transport.ts

View workflow job for this annotation

GitHub Actions / Typecheck

eslint(no-unmodified-loop-condition)

'closed' is not modified in this loop.
if (Date.now() >= deadline) {
input.trace?.write("question.recover.timeout", {
sessionID: input.sessionID,
partID,
})
return
}
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
return
}
Expand Down Expand Up @@ -865,8 +886,20 @@
})

const poll = Effect.fn("RunStreamTransport.poll")(function* (next: Wait, signal: AbortSignal) {
// Prompt sends arm the wait but leave live=false until a session
// event arrives (promptAsync is durable admission — the session can
// still be idle right after the HTTP call returns, so setting live
// eagerly would let the completion gate fire before the drain
// starts). If the server stays idle with no events long past the
// admission window, no drain is coming; escape instead of hanging
// the turn forever.
let idleStreak = 0
while (state.wait === next && !signal.aborted && !input.footer.isClosed && !closed) {
yield* Effect.sleep("250 millis")
if (next.armed && !next.live) {
idleStreak = (yield* idle(false)) ? idleStreak + 1 : 0
if (idleStreak >= 8) next.live = true
}
yield* complete(next, false)
}
})
Expand Down Expand Up @@ -1151,15 +1184,15 @@
if (booting || replaying) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
buffer(event)
}
return
}

if (!tracked(sessionID)) {
if (sessionID) {
input.trace?.write("recv.event", event)
buffered.push(event)
buffer(event)
}
return
}
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,20 @@ export const TuiThreadCommand = cmd({
worker.terminate()
}

// A dead worker leaves every pending RPC hanging forever; surface the
// crash and exit instead of freezing the TUI with no diagnostics.
const fatal = (reason: string) => {
if (stopped) return
stopped = true
process.off("SIGUSR2", reload)
worker.terminate()
UI.error("server worker crashed: " + reason)
process.exit(1)
}
client.on<{ message: string; stack?: string }>("worker.fatal", (data) => fatal(data.message))
worker.addEventListener("error", (event) => fatal(errorMessage(event.error ?? event.message)))
worker.addEventListener("close", () => fatal("worker exited unexpectedly"))

const prompt = await input(args.prompt)
const config = await TuiConfig.get()

Expand Down
32 changes: 30 additions & 2 deletions packages/opencode/src/cli/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,40 @@ import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { Global } from "@opencode-ai/core/global"
import { appendFileSync } from "node:fs"
import path from "node:path"

Heap.start()

const onUnhandledRejection = (_error: unknown) => {}
// Crash observability: swallowing these silently leaves the worker running in a
// corrupt state with no diagnostic trail (TUI appears hung with zero logs).
// Log to the shared log file synchronously so the line survives process.exit.
const logFatal = (kind: string, error: unknown) => {
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error)
const line = `timestamp=${new Date().toISOString()} level=ERROR service=tui-worker kind=${kind} error=${JSON.stringify(detail)}\n`
try {
appendFileSync(path.join(Global.Path.log, "opencode.log"), line)
} catch {
// The log directory may be gone; never let the crash handler itself throw.
}
}

const onUnhandledRejection = (error: unknown) => {
// Keep the worker alive: stray rejections from background tasks are not
// proof of corrupt state, but they must be observable.
logFatal("unhandledRejection", error)
}

const onUncaughtException = (_error: Error) => {}
const onUncaughtException = (error: Error) => {
// Process state is unknown past this point; notify the parent and exit so
// the TUI can surface the failure instead of hanging on dead RPC calls.
logFatal("uncaughtException", error)
try {
Rpc.emit("worker.fatal", { message: error.message, stack: error.stack })
} catch {}
process.exit(1)
}

process.on("unhandledRejection", onUnhandledRejection)
process.on("uncaughtException", onUncaughtException)
Expand Down
14 changes: 13 additions & 1 deletion packages/opencode/src/dag/dag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DagStore } from "@opencode-ai/core/dag/store"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex"
import { isRecord } from "@/util/record"
import { validateRequiredNodes } from "@opencode-ai/core/dag/core/required-validator"
import { buildGraph, WorkflowRuntime, toSchedulingNodes } from "@opencode-ai/core/dag/core/scheduling"
import { CycleError } from "@opencode-ai/core/dag/core/graph"
Expand Down Expand Up @@ -186,7 +187,18 @@ const parseJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)

export function parseWorkflowConfig(raw: string): WorkflowConfig | undefined {
const parsed = parseJsonOption(raw)
if (Option.isNone(parsed) || typeof parsed.value !== "object" || parsed.value === null) return undefined
if (Option.isNone(parsed)) return undefined
const candidate: unknown = parsed.value
if (!isRecord(candidate)) return undefined
// Guard the invariants every caller relies on (config.nodes.map, node.id,
// depends_on iteration) instead of trusting the persisted row blindly. Kept
// structural rather than a full strict schema: rejecting a legacy row that
// callers could still consume would break recovery of in-flight workflows.
if (!Array.isArray(candidate.nodes)) return undefined
const nodesValid = candidate.nodes.every(
(node) => isRecord(node) && typeof node.id === "string" && Array.isArray(node.depends_on),
)
if (!nodesValid) return undefined
return parsed.value as WorkflowConfig
}

Expand Down
49 changes: 34 additions & 15 deletions packages/opencode/src/dag/runtime/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,20 +809,27 @@ export const layer = Layer.effect(
// in spawnReady, settle+spawnReady in the terminal handlers),
// so reading the five conditions under the same lock waits
// out the markRunning→fibers.set window instead of misreading
// it as a stalled orchestrator. dag.fail stays outside the
// lock to avoid holding evalLock across the KeyedMutex.
const shouldFail = yield* entry.evalLock.withPermits(1)(
Effect.sync(() =>
!entry.runtime.isPaused()
&& !entry.runtime.isStepMode()
// Suppress the net only when current-process execution
// ownership proves that a running node is making progress.
&& !entry.runtime.hasRunningMatching((id) => entry.fibers.has(id))
&& entry.runtime.getReadyNodes().length === 0
&& !entry.runtime.isComplete(),
),
// it as a stalled orchestrator. dag.fail must run under the
// SAME permit: releasing the lock between the check and the
// fail lets a terminal-event handler spawn new ready nodes in
// the window, and the stale verdict would then kill a
// progressing workflow. evalLock→workflowLock nesting is the
// established order here (checkCompletion inside evalLock
// takes the same KeyedMutex); dag.ts never acquires evalLock,
// so no reverse ordering exists.
yield* entry.evalLock.withPermits(1)(
Effect.gen(function* () {
const shouldFail =
!entry.runtime.isPaused()
&& !entry.runtime.isStepMode()
// Suppress the net only when current-process execution
// ownership proves that a running node is making progress.
&& !entry.runtime.hasRunningMatching((id) => entry.fibers.has(id))
&& entry.runtime.getReadyNodes().length === 0
&& !entry.runtime.isComplete()
if (shouldFail) yield* dag.fail(dagID, "orchestrator_unresponsive").pipe(Effect.ignore)
}),
)
if (shouldFail) yield* dag.fail(dagID, "orchestrator_unresponsive").pipe(Effect.ignore)
}
}
return
Expand Down Expand Up @@ -927,16 +934,28 @@ export const layer = Layer.effect(
// Terminal rows can survive a process crash after projection but before
// parent delivery. Re-enter the normal serialized drain for every
// affected parent session without waiting for a new status event.
// Store failures here are defects (the store's error channel is never);
// absorb them with a warning so layer construction survives, but never
// silently — a swallowed failure means wake redelivery is lost until
// the next process restart.
const pendingWakeSessions = yield* store.getSessionsWithUnreportedWakes().pipe(
Effect.catch(() => Effect.succeed([] as string[])),
Effect.catchCause((cause) =>
Effect.logWarning("DagLoop failed to list sessions with unreported wakes", { cause }).pipe(
Effect.as([] as string[]),
),
),
)
for (const sessionID of pendingWakeSessions) {
// Cross-instance guard: wake redelivery is store-global. A session's
// workflows share its project (enforced at dag.create), so the wake
// snapshot's own workflow rows carry the ownership proof — only
// drain sessions whose unreported workflows belong to this project.
const snapshot = yield* store.getWakeSnapshot(sessionID).pipe(
Effect.catch(() => Effect.succeed({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot)),
Effect.catchCause((cause) =>
Effect.logWarning("DagLoop failed to read wake snapshot", { sessionID, cause }).pipe(
Effect.as({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot),
),
),
)
if (!snapshot.workflows.some((wf) => wf.projectId === ctx.project.id)) continue
yield* tryDeliverWake(sessionID).pipe(Effect.forkScoped)
Expand Down
Loading
Loading