A subagent needed to run a multi-minute build-and-test suite as part of a larger automated build-verify loop. It launched the build in the background in one tool call and intended to poll it in a later call. The logs came back corrupted: interleaved output from two independent mvn processes, both writing to the same log file. It looked like a real BUILD FAILURE with a NoClassDefFoundError; it was actually a shell statelessness bug.

The problem: shell job control doesn’t survive across tool calls

The attempt was straightforward: launch the build in one tool call with a bare backgrounding operator,

mvn test > build.log 2>&1 &

then wait on the job from a separate, later call. That doesn’t work: each tool call in this kind of harness runs in its own shell, with no memory of the first call’s background job. The shell has no way to know that the second call is a continuation of the first; it sees two independent commands.

Two things fail here, and they are worth separating:

  • State does not survive. The job table, $!, and even the exit status of the first command are gone by the time the second call runs. Anything that lived only in shell memory is unrecoverable.

  • The process might survive anyway. Depending on the harness, a raw & job is either reaped when the launching shell exits, or it is orphaned and keeps running. In this case it kept running. The agent, seeing no job to wait on, assumed the launch had failed and started a second mvn. Both processes wrote to build.log at once, interleaving their output into what looked like a cryptic compile failure. The root cause was not Maven; it was two writers sharing one file.

The fix: use the harness’s background primitive

Most modern agent harnesses already solve this. Claude Code, for example, exposes a run_in_background parameter on its Bash tool: the process starts, the tool returns a task ID immediately, and later calls poll that task’s output or stop it by ID. The harness owns the process lifecycle, so it survives across tool calls by construction and each task gets its own output stream. When your harness offers this, use it — do not hand-roll process tracking on top of raw shell syntax.

The same harness also auto-backgrounds a foreground command that exceeds its timeout rather than killing it, handing you a task ID and an output file path. Either way, the reconnection handle comes from the harness, not from the shell.

The fallback: persist the handle to disk

If your harness has no background primitive, you have to make the shell’s ephemeral state durable yourself. Three things must survive the first call: the process, its PID, and its eventual exit code. Write all of them to disk, and give the build its own log file so nothing can clobber it:

# One call: detach the process, log to a unique file,
# and record both the PID and the exit code on disk.
nohup sh -c 'mvn test > build.log 2>&1; echo $? > build.exit' >/dev/null 2>&1 &
echo $! > build.pid

nohup (or setsid) detaches the job so it is not reaped when the launching shell exits. The inner echo $? > build.exit captures the real exit status the moment the build finishes — because wait "$pid" in a later call cannot do it. A PID that is not a child of the current shell is not waitable; wait on it returns 127, never the build’s actual exit code. The exit status is just as stateless as the job table, so it has to be written down.

A later call then polls by PID number, which is stateless and works fine across invocations, and reads the exit code from the file:

pid=$(cat build.pid)
while kill -0 "$pid" 2>/dev/null; do sleep 20; done
echo "exit code: $(cat build.exit)"

The key point: every durable handle has to be established in the same call that starts the process. Once launch and poll communicate through the filesystem instead of the shell’s job table, the corruption and the phantom failures stop.

Takeaway

Tool calls in most agent harnesses are stateless relative to each other. Shell job control — $!, fg, bg, wait without a child PID — and even a command’s exit status only survive within a single invocation. Anything backgrounded with raw shell syntax in one call is invisible to the next, and a second call that assumes the first failed will happily launch a duplicate.