Skip to content

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

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions