一覧に戻る
    1文字のエスケープ漏れが連鎖障害を引き起こした話 — DevLoop Runner開発中のデバッグ記録
    開発ラボ
    PRこの記事には広告が含まれています

    1文字のエスケープ漏れが連鎖障害を引き起こした話 — DevLoop Runner開発中のデバッグ記録

    15 分で読める

    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.Binding

    WORKSPACE が見つからない。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);
    }

    ファイルが見つからない場合のフォールバックとして、従来のテキスト抽出も残しましたが、こちらも貪欲マッチのバグを修正した上で、ブラケットの深さを追跡するパーサーに書き換えています。

    おまけ: ダブルエスケープのバグ

    ついでに見つけたのが、generateUnifiedDiffcalculateDefaultMetrics でのダブルエスケープバグです。

    // 修正前(文字列リテラル内でさらにバックスラッシュエスケープ)
    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エージェントの設計・運用全般について理解を深めたい方に。

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について