From 0a9af82c1d0a9face6341bded24cf6ca4407a886 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 1 Aug 2026 09:37:54 +0800 Subject: [PATCH 1/2] fix(stream): fail idle-escape turn with diagnostic instead of silent completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle-escape threshold in RunStreamTransport.poll resolved the turn's deferred as success after 8 consecutive idle reads, reporting a turn that never ran as an ordinary empty success — a silent wrong answer worse than the hang the escape exists to break. Fail the deferred with a diagnostic error instead, scoped to this turn only (state.fault would poison every later prompt on the transport). Also raise the threshold from 8 to 40 ticks (2s → 10s) to widen the margin over the admission-to-busy latency. workflows.ts: drop the config.name → title fallback. name is a config field distinct from Entry.name (the filename); using it as a title fallback conflated two concepts and made the title source ambiguous. --- .../src/cli/cmd/run/stream.transport.ts | 28 ++++++++++++++++--- packages/opencode/src/dag/workflows.ts | 6 +--- .../opencode/test/dag/dag-workflows.test.ts | 4 +-- 3 files changed, 27 insertions(+), 11 deletions(-) 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) }) }) From f829996160ac93037938a9907b0797036fc58318 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 1 Aug 2026 10:35:53 +0800 Subject: [PATCH 2/2] chore(ci): retrigger dev CI to clear stale cancelled check The previous dev run's Unit Tests (linux) hung at the HttpAPI Exerciser worktree.create step and was cancelled; the cancelled conclusion is blocking PR #159's merge into main even though the PR's own CI passed all required checks. Empty commit to force a fresh green run.