環境変数による分離とは、並列ジョブごとに別の出力先(一時ファイルなど)を環境変数で渡し、同じファイルへの同時書き込みを避ける設計です。ただし受け側のスクリプトが同名の変数へ無条件に代入していると、渡した値は上書きされ、分離は効きません。

私の並列検収スクリプト(4並列)で、この上書きが起きていました。実行は成功し続けていて、見つけたのはスクリプトの置き場を整理する設計の途中でコードを読んでいた Claude Code です。当時の振り返りでは、このバグは「コード読みだけが拾った。実行は成功し続けていた」と述べられています。

「環境変数で渡した値は、子プロセスでそのまま使われる」という前提は、受け側の1行で崩れます。再現実走の数値と、自分の並列スクリプトで代入行を点検する手順まで示します。

一時台帳を環境変数で渡す設計にした理由

検収対象は、AI エージェントが調べた URL を実際に取得して確かめるスクリプトです。これを4並列で走らせると、全ジョブが同じ台帳 sources.tsv へ追記するため、競合しうる状況になります。それを避けるため、渡す側の batch-verify.sh が URL リストを4分割し、ジョブごとに LEDGER=<一時台帳> を環境変数で渡し、最後に一時台帳を正本へ結合する設計にしました(設計意図は Claude Code が当時説明したものです)。同じ検収スクリプトの設計は AIエージェントのWeb調査検収で起きた2つの罠 でも扱っています。

次が修正前のスクリプトの抜粋です。実行用のコードではなく、batch-verify.sh と verify-urls.sh から該当行だけを取り出しています。

ファイル: batch-verify.sh(渡す側。ジョブ起動の行と、結合の行)

LEDGER="$tmpdir/ledger.$chunk_name" "$VERIFY" --origin "$ORIGIN" "$chunk" > "$tmpdir/results.$chunk_name" &
tail -n +2 "$ledger_part" >> "$REAL_LEDGER"

ファイル: verify-urls.sh(受け側。先頭付近の代入行)

LEDGER="$RESEARCH_DIR/sources.tsv"

渡す側は、ジョブごとに別の LEDGER を渡しています。受け側は LEDGER に値を代入しています。この2つを並べて読んだとき、何が起きるでしょうか。

実行は成功したまま、分離は効いていなかった

答えは、スクリプトの置き場を整理する設計を Claude Code に依頼していた途中で出ました。依頼は、検収スクリプトに依存するスキルの依存をうまく解決する方法を考えてほしい、というものです。その作業で batch-verify.sh と verify-urls.sh を読んでいた Claude Code が、次のように指摘しました(行番号を省略して引用します)。

verify-urls.sh が LEDGER="$RESEARCH_DIR/sources.tsv" で無条件に上書きしている。4並列が全部そのまま正本へ追記していて、一時台帳は空のまま結合されている。

これはコード読みの結論で、実行して確かめたものではありません。実際に動かして確かめると、「一時台帳は空のまま」の部分は正確ではありませんでした。一時台帳は空だったのではなく、一時台帳のファイル自体が作られていませんでした。結合ループ(ledger.chunk-* のグロブ)は何も拾わず、4並列の書き込みはすべて正本に直接入っていました。

当時の記録には、修正前のスクリプトの実行出力が残っていません。そこで 2026-10-02 に、隔離したディレクトリで修正前後のスクリプトを同じ入力(4 URL)で実走し直しました。

結合直前の一時台帳正本 sources.tsv の行数
修正前0 本5 行(ヘッダ1+4件)
修正後4 本4 行(ヘッダなし)

表の値は、結合の直前に一時ディレクトリの中身を記録する1行を挿入した計測用コピーで実走したものです(ロジックは変えていません)。修正後の値は、計測日(2026-10-02)時点の現行版でのものです。修正後の正本にヘッダ行が無いのは、正本が存在しない状態から始めたためで、本題とは別の話です。

修正前は、4並列のジョブがすべて正本に直接追記していました。5行はヘッダ1行と4件で、この再現では欠落も破損も見られませんでした。つまり、実行の結果だけを見ても異常は出ません。異常に気づく手がかりは、受け側のコードの1行にありました。

最小の再現:受け側の1行で分離が消える

同じ現象を、手元で再現できる最小のスクリプトで確かめます。受け側 worker.sh が LEDGER に1行追記し、渡す側 runner.sh が4並列でジョブごとの一時台帳を渡します。必要なのは bash だけです。2つのファイルを同じディレクトリに置きます。

ファイル: worker.sh(受け側。修正前: 無条件代入)

#!/usr/bin/env bash
# 受け側: 渡された LEDGER に1行追記する(修正前: 無条件代入)
LEDGER="$(dirname "$0")/main.tsv"
echo "$1" >> "$LEDGER"

ファイル: runner.sh(渡す側。4並列でジョブごとの一時台帳を渡す)

#!/usr/bin/env bash
# 渡す側: 4並列で、ジョブごとの一時台帳を LEDGER で渡す
dir=$(cd "$(dirname "$0")" && pwd)
tmp=$(mktemp -d)
for i in 1 2 3 4; do
  LEDGER="$tmp/ledger.$i" "$dir/worker.sh" "job-$i" &
done
wait
echo "wait の終了コード: $?"
echo "一時台帳: $(ls "$tmp" | wc -l | tr -d ' ') 本"
echo "main.tsv: $( [ -f "$dir/main.tsv" ] && wc -l < "$dir/main.tsv" | tr -d ' ' || echo 0 ) 行"
rm -rf "$tmp" "$dir/main.tsv"

実行します。

chmod +x worker.sh runner.sh && ./runner.sh

出力は次のとおりでした。

wait の終了コード: 0
一時台帳: 0 本
main.tsv: 4 行

一時台帳は0本、main.tsv に4行です。終了コードは0で、エラーも出ません。ここで表示している wait の終了コードは、引数なしの wait の戻り値であり、各ジョブの終了コードではありません。なお、worker.sh は echo の戻り値で正常終了するため、実行は成功して見えます。実在のスクリプトでの再現は、前節の表のとおりです。

直し方:受け側を ${VAR:-default} にする

受け側の代入を、変数が未設定または空のときだけ既定値を入れる形にします。変更するのは worker.sh の3行目だけです。

ファイル: worker.sh(受け側。修正後: 未設定・空のときだけ既定値)

#!/usr/bin/env bash
# 受け側: 渡された LEDGER に1行追記する(修正後: 未設定・空のときだけ既定値)
LEDGER="${LEDGER:-$(dirname "$0")/main.tsv}"
echo "$1" >> "$LEDGER"

同じ ./runner.sh を実行すると、出力は次のようになりました。

wait の終了コード: 0
一時台帳: 4 本
main.tsv: 0 行

一時台帳が4本でき、main.tsv は作られません。実在のスクリプトでも、受け側の代入を LEDGER="${LEDGER:-<正本の既定パス>}" の形に変えて直しました。

${VAR:-default} は、変数が未設定または空のときだけ既定値を使い、渡された値があればそれを残します1。この形は bash 固有ではなく、POSIX のシェルコマンド言語にも同じ定義があります2。コロンを省いた ${VAR-default} は、未設定のときだけ既定値を使います1。3つの書き方の違いは次のとおりです。

書き方変数が未設定のとき変数が空文字のとき変数に値があるとき
VAR="x"x を代入x を代入x を代入(渡された値を上書き)
VAR="${VAR:-x}"xx渡された値を使う
VAR="${VAR-x}"x空文字のまま渡された値を使う

空文字の列は、LEDGER= を渡して試した実測です。${LEDGER:-default} は default に、${LEDGER-default} は空文字になりました。つまり :- は、空文字を渡されると既定値、本件では正本のほうへ戻ります。

なぜ無条件代入が上書きするのか

Bash は起動時に、環境にある各名前に対応するパラメータを作ります。環境内のパラメータの値を変更すると、新しい値が環境に入り、古い値を置き換えます3。環境から来た変数への代入も、他のシェル変数への代入と同じく値を置き換える、とマニュアルの記述から読めます。

渡す側の VAR=value command の形は、そのコマンドの環境にだけ入ります3。ジョブごとに別の値を渡せるのはこのためで、渡し方そのものは正しく動いていました。値が失われたのは、受け側の代入です。

自分の並列スクリプトを点検する

点検は3手です。実行結果ではなく、コードと、できたファイルの数を見ます。

  1. 代入行を洗い出す。 渡している変数名で、渡す側と受け側の両方を探します。

    grep -n 'LEDGER=' *.sh
    

    最小の再現では、次のように渡す側と受け側の行が1本の grep に並びます。

    runner.sh:6:  LEDGER="$tmp/ledger.$i" "$dir/worker.sh" "job-$i" &
    worker.sh:3:LEDGER="$(dirname "$0")/main.tsv"
    
  2. 受け側の代入が ${VAR:-...} か、無条件の VAR="..." かを見る。 無条件の代入なら、渡した値はその行で置き換わります。[ -z "$VAR" ] && VAR=... のような条件付き代入は grep に一致しても別物なので、一致した行は目で読んで判別します。export や declare を付けた形は上の grep では拾えません。必要なら別に探してください(手元で確かめたのは LEDGER= の1形だけです)。

  3. 結合の直前で、一時ファイルが何本できているかを数える。 最小の再現の runner.sh にある ls "$tmp" | wc -l と同じ形です。修正前は0本、修正後は4本でした。

この点検でわかること・わからないこと

この点検でわかるのは、渡した値が受け側で使われているか(代入行と、できた一時ファイルの本数)です。

書き込みの混在や台帳の破損が起きるかは、点検ではわかりません。本件の再現では欠落も破損もなく、5行はヘッダ1行と4件でした。

「テストを書けば気づけたのではないか」という反論はありえます。一時ファイルの本数を数える確認を最初から書いていれば、気づけたとは言えます。ただ本件では、実行結果に異常が出ませんでした。

前提が変われば結論はどう変わるか

受け側が外部のコマンドで、代入を書き換えられない場合は、受け側を直す修正は選べません。

再現は、4 URL・4チャンクの1条件で、各1回の実走です。毎回同じ結果になることまでは確かめていません。

よくある質問(FAQ)

環境変数で渡した値が、スクリプトの中で上書きされるのはなぜですか?

受け側のスクリプトが、同じ名前の変数へ無条件に代入しているからです。本件の再現では、その代入が渡された値を置き換えていました。環境から渡された値は起動時にシェルのパラメータになる3ので、他の代入と同じく置き換わる、というのがマニュアルの記述からの読みです。直し方は、受け側の代入を ${VAR:-default} に変えることです。

並列ジョブの分離が効いているかを確認するには、どうすればよいですか?

渡す側と受け側の代入行を grep -n 'VAR=' *.sh で両方見て(無条件の代入かどうかは、一致した行を読んで判別します)、結合の直前に一時ファイルが何本できているかを数えます。終了コードでは分かりません。

${VAR:-default} と ${VAR-default} の違いは何ですか?

:- は変数が未設定または空文字のときに既定値を使い、コロンの無い - は未設定のときだけ使います1。空文字を渡したとき、前者は default、後者は空のままです(上の実測のとおりです)。

まとめ

  • 受け側の無条件代入は、渡された環境変数の値を上書きしました。4 URL を4並列で渡した検収スクリプトと、4ジョブの最小の再現で確認した事実で、仕組みの説明はマニュアルの記述からの読みです。
  • 実在のスクリプトでは、当時の振り返りが「実行は成功し続けていた」と述べています。最小の再現でも、wait の終了コードは0で、エラーは出ませんでした。
  • 「渡したつもり」の確認は、渡す側と受け側の代入行を両方読み、結合の前に一時ファイルの本数を数えます。
  • 直し方は、受け側の代入を ${VAR:-default} にすることです。
  • この再現が示すのは、一時台帳が作られず正本に直接書かれたことです。修正前に台帳が壊れたか、他の環境・シェルで同じ結果になるかは、示していません。

AI エージェントを使った並列実行・検収の仕組みづくりについて、ぜひ コンタクトフォーム よりお気軽にご相談ください。

Footnotes

  1. Shell Parameter Expansion(Bash Reference Manual 3.5.3) GNU Project。${parameter:-word} の定義(未設定または空のときに word を使う)と、コロンを省いた形が未設定のときだけを判定することは、同節。 ↩ ↩2 ↩3

  2. Shell Command Language(POSIX.1-2024、2.6.2 Parameter Expansion) The Open Group、IEEE Std 1003.1-2024。${parameter:-[word]}(Use Default Values)の定義は、2.6.2 節。 ↩

  3. Environment(Bash Reference Manual 3.7.4) GNU Project。起動時に環境の各名前に対してパラメータを作ることと、環境内の値を変更すると新しい値が古い値を置き換えること、VAR=value command の代入がそのコマンドの環境にだけ入ることは、同節。 ↩ ↩2 ↩3