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
28 changes: 24 additions & 4 deletions packages/opencode/src/cli/cmd/run/stream.transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ type Trace = {

const StreamClosed = undefined as never

// Consecutive 250ms ticks of idle before a turn that was armed but never
// went live is reported as a failure. 40 ticks = 10 s. The margin covers
// cold-start / IO-loaded paths where the drain takes a moment to flip the
// session status from idle to busy after prompt admission; anything longer
// than this is almost certainly a drain that will never start.
const IDLE_ESCAPE_TICKS = 40

type StreamInput = {
sdk: OpencodeClient
directory?: string
Expand Down Expand Up @@ -890,15 +897,28 @@ function createLayer(input: StreamInput) {
// 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.
// starts). Reaching the streak below means the server accepted the
// prompt yet produced no event of any kind and never left idle, so
// no drain is coming.
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
if (idleStreak >= IDLE_ESCAPE_TICKS) {
// Resolving the wait here would report a turn that never ran as
// an ordinary empty success — a silent wrong answer, worse than
// the hang this escape exists to break. Fail only this turn;
// state.fault would poison every later prompt on the transport.
state.wait = undefined
yield* Deferred.fail(
next.done,
new Error(
`The server accepted the prompt but the session reported no activity for ${(IDLE_ESCAPE_TICKS * 250) / 1000}s, so the run never started. Check the opencode server log.`,
),
).pipe(Effect.ignore)
return
}
}
yield* complete(next, false)
}
Expand Down
6 changes: 1 addition & 5 deletions packages/opencode/src/dag/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,6 @@ async function describe(file: string): Promise<{ title?: string; nodes?: number
if (!isRecord(parsed)) return {}
const config = isRecord(parsed["config"]) ? parsed["config"] : undefined
const title = typeof parsed["title"] === "string" ? parsed["title"] : undefined
const name = config && typeof config["name"] === "string" ? config["name"] : undefined
const nodes = config && Array.isArray(config["nodes"]) ? config["nodes"].length : undefined
return {
...(title ?? name ? { title: title ?? name } : {}),
...(nodes === undefined ? {} : { nodes }),
}
return { ...(title ? { title } : {}), ...(nodes === undefined ? {} : { nodes }) }
}
4 changes: 2 additions & 2 deletions packages/opencode/test/dag/dag-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ describe("DagWorkflows.list", () => {
expect(entries.map((entry) => entry.name)).toEqual(["real"])
})

it("falls back to config.name when the spec declares no title", async () => {
it("leaves title undefined when the spec declares none", async () => {
await writeProject("untitled.yaml", "config:\n name: from-config\n nodes: []")
const entries = await Effect.runPromise(DagWorkflows.list(projectDir))
expect(entries[0]?.title).toBe("from-config")
expect(entries[0]?.title).toBeUndefined()
expect(entries[0]?.nodes).toBe(0)
})
})
Expand Down
Loading