diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 987120e86..875f7550a 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -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 @@ -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) } diff --git a/packages/opencode/src/dag/workflows.ts b/packages/opencode/src/dag/workflows.ts index 7b3cee3c2..86df7e504 100644 --- a/packages/opencode/src/dag/workflows.ts +++ b/packages/opencode/src/dag/workflows.ts @@ -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 }) } } diff --git a/packages/opencode/test/dag/dag-workflows.test.ts b/packages/opencode/test/dag/dag-workflows.test.ts index 5ba04a45f..4e8d6ae91 100644 --- a/packages/opencode/test/dag/dag-workflows.test.ts +++ b/packages/opencode/test/dag/dag-workflows.test.ts @@ -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) }) })