Skip to content

Shell tool can return (no output) with exit 0 on first call when using workdir #35511

Closed as not planned
chethanuk/opencode
#2

Description

@FlintyLemming

Summary

I observed an intermittent issue where the bash/shell tool is called with a valid workdir, runs a command that should produce output, but returns (no output) even though the command appears to have succeeded.

Image

In the attached screenshot/log, the tool call uses:

  • command: ls -la docs
  • workdir: /home/flintylemming/Projects/leycode-hub

The next command using an absolute path succeeds, which makes this look like workdir did not take effect. However, after reproducing against ShellTool directly, the more likely issue is an output collection race: the process exits successfully before the output reader has drained stdout/stderr.

Suspected Code Location

The race appears to be in packages/opencode/src/tool/shell.ts, inside the run function.

Current shape:

yield* Effect.forkScoped(
  Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
    // append chunk into list/full/metadata
  }),
)

const exit = yield* Effect.raceAll([
  handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
  abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
  timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])

The output reader is forked with forkScoped, but the main flow only waits for handle.exitCode. When the scoped block ends, the output-reading fiber can be interrupted before it has appended output into list.

That can produce:

{
  "exit": 0,
  "output": "(no output)"
}

even though the command actually succeeded and should have emitted stdout.

Why this is likely not a workdir resolution bug

When reproduced directly against ShellTool.execute, I hit this result:

{
  "iterations": 1,
  "command": "ls -la docs",
  "emptyCount": 1,
  "failureCount": 0,
  "firstEmpty": {
    "index": 1,
    "exit": 0,
    "output": "(no output)"
  }
}

The important detail is exit: 0. If workdir were wrong and docs did not exist, the command should exit non-zero and include an ls error. Instead, the command exits successfully but output is missing.

Reproduction Script

Run from packages/opencode after dependencies are installed.

Create tmp-shelltool-repro.ts:

import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Layer } from "effect"
import { Shell } from "@opencode-ai/core/shell"
import { Config } from "@/config/config"
import { Agent } from "@/agent/agent"
import { MessageID, SessionID } from "@/session/schema"
import { Plugin } from "@/plugin"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ShellTool } from "@/tool/shell"
import { Truncate } from "@/tool/truncate"
import { provideInstance, testInstanceStoreLayer } from "./test/fixture/fixture"

const workdir = "/Users/flintylemming/Projects/leycode-hub"
const iterations = Number(process.argv[2] ?? 1)
const command = process.argv[3] ?? "ls -la docs"

const shellLayer = Layer.mergeAll(
  LayerNode.compile(
    LayerNode.group([
      CrossSpawnSpawner.node,
      FSUtil.node,
      Plugin.node,
      Truncate.node,
      Config.node,
      Agent.node,
      RuntimeFlags.node,
    ]),
  ),
  testInstanceStoreLayer,
)

const ctx = {
  sessionID: SessionID.make("ses_repro"),
  messageID: MessageID.make("msg_repro"),
  callID: "call_repro",
  agent: "build",
  abort: AbortSignal.any([]),
  messages: [],
  metadata: () => Effect.void,
  ask: () => Effect.void,
}

const program = Effect.gen(function* () {
  Shell.acceptable.reset()
  const info = yield* ShellTool
  const shell = yield* info.init()
  const results = yield* Effect.forEach(
    Array.from({ length: iterations }, (_, index) => index + 1),
    (index) =>
      shell.execute({ command, workdir }, ctx).pipe(
        Effect.map((result) => ({
          index,
          exit: result.metadata.exit,
          output: result.output,
        })),
      ),
    { concurrency: 1 },
  )
  return results
}).pipe(provideInstance(workdir), Effect.provide(shellLayer))

const results = await Effect.runPromise(program)
const empty = results.filter((result) => result.exit === 0 && result.output === "(no output)")
const failures = results.filter((result) => result.exit !== 0)

console.log(JSON.stringify({ iterations, command, emptyCount: empty.length, failureCount: failures.length, firstEmpty: empty[0], firstResult: results[0] }, null, 2))

Then run fresh-process attempts:

for i in {1..50}; do
  out=$(bun tmp-shelltool-repro.ts 1 2>/dev/null)
  if printf '%s' "$out" | rg -q '"emptyCount": 1'; then
    echo "hit iteration $i"
    printf '%s\n' "$out"
    exit 0
  fi
done
echo "no hit"

This is intermittent. In one run, it reproduced on fresh process iteration 8.

Suggested Fix Direction

Do not fire-and-forget the output reader. Store the fiber returned by Effect.forkScoped(...) and wait for it before leaving the scoped block, or restructure the code so process exit and output drain are awaited together.

For example:

const outputFiber = yield* Effect.forkScoped(readOutput)

const exit = yield* Effect.raceAll([
  handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
  abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
  timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])

if (exit.kind === "abort" || exit.kind === "timeout") {
  yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}

yield* Fiber.join(outputFiber)

A deterministic regression test could use a fake ChildProcessSpawner where exitCode resolves immediately but handle.all emits output after a short delay. The current code should return (no output); the fixed code should return the delayed output.

Plugins

Superpowers

OpenCode version

1.17.10

Activity

  1. github-actions commented on Jul 6, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • ShellTool can return before the output stream drains #30001: Same root cause and symptom — ShellTool returns (no output) with exit 0 due to a race condition where the output-reader fiber is forked but not joined before the main fiber reads the result. That issue also identifies the fix as joining the output fiber before computing the final result.
  2. github-actions commented on Sep 5, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions