
1文字のエスケープ漏れが連鎖障害を引き起こした話 — DevLoop Runner開発中のデバッグ記録
CIが落ちて、直して、動いたと思ったら別の問題が出てきて——という、わりとよくある深夜のデバッグセッションの記録です。
ただ、今回は「1文字の \ が足りなかっただけで3つのエラーが連鎖する」という構造が見えたのと、AIエージェントの出力をどう安定させるかという設計判断があったので、残しておく価値はあるかなと思いました。
発端: rewrite-issue ジョブが動かない
いま開発している DevLoop Runner というツールがあります。AIエージェントを使って開発ワークフローを自動化するもので、そのなかにGitHub Issueを自動で再設計する rewrite-issue というコマンドがあります。これをJenkinsのパイプラインで回しているのですが、あるときこのジョブが動かなくなりました。
ある日、このジョブがこんなエラーで落ちました。
hudson.AbortException: Attempted to execute a step that requires a node context
while 'agent none' was specified.加えて、post ブロックで NullPointerException も出ている。ログを見ると3種類のエラーが出ていて、正直どこから手をつけるか一瞬迷いました。
問題1: Groovyの変数解決タイミングの罠
エラーの読み解き
スタックトレースを追っていくと、最初に目に入ったのは node context 系のエラーでした。でも、これは本当の原因じゃない。ログの末尾にもうひとつ、地味だけど決定的なエラーがあります。
groovy.lang.MissingPropertyException: No such property: WORKSPACE
for class: groovy.lang.BindingWORKSPACE が見つからない。Jenkinsを触ったことがある方なら「え、WORKSPACEって組み込み変数でしょ?」と思うかもしれません。自分もそう思いました。
原因: $ のエスケープ漏れ
動作している別のJenkinsfile(auto-issue)と比較してみると、違いは1箇所だけでした。
// ❌ rewrite-issue(壊れていた方)
args "-v ${WORKSPACE}:/workspace -w /workspace"
// ✅ auto-issue(動いていた方)
args "-v \${WORKSPACE}:/workspace -w /workspace"\ が1文字足りない。それだけです。
でも、これが意味するところはけっこう深い。Declarative Pipelineでは、agent ブロックの定義はノード割り当ての前にGroovyがパースします。つまり ${WORKSPACE} をエスケープしないと、まだノードが存在しない段階でGroovyが変数を解決しようとして、MissingPropertyException になる。
連鎖障害の構造
ここが今回のポイントです。1つの原因から3つのエラーが連鎖していました。
ログに出ていた3つのエラーは、全部この1文字に起因していました。
修正は本当に1行です。
- args "-v ${WORKSPACE}:/workspace -w /workspace -e CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1"
+ args "-v \${WORKSPACE}:/workspace -w /workspace -e CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1"ここで学んだのは、Jenkinsのエラーログは「最初に出たエラー」が原因とは限らないということ。むしろ末尾の方に根本原因が埋まっていることがある。今回も MissingPropertyException はログの最後に出ていて、最初に目に入る node context エラーだけを追いかけていたら遠回りしていたと思います。
問題2: AIエージェントの出力をどう安定させるか
Jenkinsfileを直してジョブを再実行すると、今度はジョブ自体は成功しました。ログにも Successfully updated issue #682 と出ている。
でも、実際のGitHub Issueを見に行くと——更新されていない。
「成功」なのに更新されていない理由
ログをもう一度見ると、こんなWARNINGがありました。
WARNING: Failed to parse agent response as JSON:
Unexpected non-whitespace character after JSON at position 740ここで rewrite-issue.ts の実装を読み返すと、問題が見えてきました。
AIエージェント(Claude Code)にIssueの再設計を依頼すると、テキストで応答が返ってきます。その中からJSONを抽出するために正規表現を使っていたのですが、これが /\{[\s\S]*\}/ という貪欲マッチでした。
エージェントの応答は、JSON以外にも説明文やコードブロックを含むことがあります。その中に { や } が複数あると、正規表現が最初の { から最後の } まで全部拾ってしまう。当然、それはvalidなJSONではない。
さらに問題だったのが、JSON parse失敗時のフォールバック処理です。パースに失敗すると、元のタイトル(fallbackTitle)とエージェントの生のレスポンス全文を「新しい本文」として返していました。その結果、GitHub APIの updateIssue は「成功」するけど、実質的には意味のない更新がされていた(あるいはタイトルが同じなので差分なしと判断された)わけです。
// フォールバック処理(修正前)
return {
newTitle: fallbackTitle, // ← 元のタイトルがそのまま返る
newBody: response.trim(), // ← エージェントの生レスポンス全文
};設計判断: テキスト抽出をやめて、ファイル出力に切り替える
正規表現を非貪欲にする(/\{[\s\S]*?\}/)とか、JSONブロックを正確にパースするロジックを書くとか、やり方はいくつかありました。
ただ、正直ここで思ったのは「エージェントのテキスト出力からJSONを抽出する」というアプローチ自体が不安定だな、ということです。エージェントの応答フォーマットは毎回微妙に違うし、今回直しても別のパターンでまた壊れる可能性がある。
そこで、根本的にアプローチを変えることにしました。エージェントにJSONを「出力」させるのではなく、指定したファイルパスに「書き込ませる」。
具体的には、プロンプトに出力先のファイルパスを埋め込んで、エージェントにWriteツールでJSONファイルを保存させます。コマンドハンドラ側ではそのファイルを読むだけ。
プロンプトの変更はこんな感じです。
## 出力形式
- 以下のJSON形式で出力してください:
+ 以下のJSON形式で**指定されたファイルパスに書き込んで**ください:
+
+ **出力先ファイルパス**: `{OUTPUT_FILE_PATH}`
{
"title": "新しいタイトル(80文字以内)",
"body": "新しい本文(Markdown形式)",
...
}
+ **重要**: 必ず上記の絶対パスにJSONファイルをWriteツールで書き込んでください。
+ コンソールへの出力ではなく、ファイルとして保存することが必須です。コマンドハンドラ側では、一時ファイルのパスを生成してプロンプトに注入し、エージェント実行後にそのファイルを読み取ります。
// 一時ファイルパスの生成
function createOutputFilePath(repoPath: string, issueNumber?: number): string {
const suffix = issueNumber ? `-${issueNumber}` : '';
const filename = `rewrite-issue${suffix}-${Date.now()}.json`;
const tmpDir = path.join(repoPath, '.ai-workflow', 'tmp');
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
return path.join(tmpDir, filename);
}ファイルが見つからない場合のフォールバックとして、従来のテキスト抽出も残しましたが、こちらも貪欲マッチのバグを修正した上で、ブラケットの深さを追跡するパーサーに書き換えています。
おまけ: ダブルエスケープのバグ
ついでに見つけたのが、generateUnifiedDiff と calculateDefaultMetrics でのダブルエスケープバグです。
// 修正前(文字列リテラル内でさらにバックスラッシュエスケープ)
const oldLines = oldBody.split('\\n'); // リテラル "\n" で分割してしまう
const sectionCount = (body.match(/^##\\s+/gm) ?? []).length; // マッチしない
// 修正後
const oldLines = oldBody.split('\n'); // 改行文字で正しく分割
const sectionCount = (body.match(/^##\s+/gm) ?? []).length; // 正しくマッチこれ、TypeScriptの文字列リテラルと正規表現リテラルでエスケープの扱いが異なることを忘れていると踏みやすい罠です。'\\n' は改行文字ではなく、バックスラッシュ + n という2文字のリテラルになります。
振り返り
今回の2つの問題に共通しているのは、「エスケープ」という地味な部分が全体の動作を壊していたことです。
Jenkinsfileでは \ 1文字が、Groovyの変数解決 → ノード割り当て → post処理という連鎖障害を引き起こした。TypeScriptでは \\ のダブルエスケープが、文字列分割と正規表現マッチを壊していた。
どちらも「なんとなく動くように見える」のが厄介で、実行時に初めて問題が顕在化するタイプのバグです。
AIエージェントの出力方式については、テキスト抽出からファイル出力への切り替えは、今のところ正解だったと感じています。エージェントの応答フォーマットに依存しなくなったことで、パースの安定性が大幅に上がりました。ただ、エージェントが本当にファイルに書き込むかどうかはエージェント側の挙動次第なので、フォールバックは引き続き必要です。DevLoop Runnerは「AIにコードを書かせる」ツールなので、こういう「AIの出力をどう受け取るか」という地味な設計判断の積み重ねが、安定性にそのまま効いてきます。このあたりはもう少し運用してみて判断したいと思います。
参考書籍
Jenkinsのパイプライン設計やトラブルシューティングについて体系的に学びたい方には、以下の書籍が参考になります。
AIエージェントを開発ワークフローに組み込む際の実践的な知見については、こちらが役立ちます。
AIエージェントの設計・運用全般について理解を深めたい方に。
この記事は役に立ちましたか?

この記事が、何かの整理につながったら
コーヒー1杯分の応援をもらえると嬉しいです。
あなたへのおすすめ
