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つのエラーが連鎖していました。

graph TD
    A["${WORKSPACE} エスケープ漏れ"] --> B["Groovyパース時にMissingPropertyException"]
    B --> C["ノード割り当てが行われない"]
    C --> D["post ブロックが node context なしで実行"]
    D --> E["sh / cleanWs() が node context required エラー"]
    C --> F["共通ライブラリ変数が初期化されない"]
    F --> G["common.sendWebhook() で NullPointerException"]

ログに出ていた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を「出力」させるのではなく、指定したファイルパスに「書き込ませる」

graph LR
    subgraph "修正前"
        A1[エージェント] -->|テキスト応答| B1[正規表現で抽出]
        B1 -->|不安定| C1[JSON.parse]
    end
    subgraph "修正後"
        A2[エージェント] -->|ファイル書き込み| B2[ファイル読み込み]
        B2 -->|安定| C2[JSON.parse]
    end

具体的には、プロンプトに出力先のファイルパスを埋め込んで、エージェントにWriteツールでJSONファイルを保存させます。コマンドハンドラ側ではそのファイルを読むだけ。

プロンプトの変更はこんな感じです。

  ## 出力形式

- 以下のJSON形式で出力してください:
+ 以下のJSON形式で**指定されたファイルパスに書き込んで**ください:
+
+ **出力先ファイルパス**: `{OUTPUT_FILE_PATH}`

  ```json
  {
    "title": "新しいタイトル(80文字以内)",
    "body": "新しい本文(Markdown形式)",
    ...
  }

コマンドハンドラ側では、一時ファイルのパスを生成してプロンプトに注入し、エージェント実行後にそのファイルを読み取ります。

```typescript
// 一時ファイルパスの生成
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のパイプライン設計やトラブルシューティングについて体系的に学びたい方には、以下の書籍が参考になります。

[📦 商品リンク: moshimo-book-e7vQm]

AIエージェントを開発ワークフローに組み込む際の実践的な知見については、こちらが役立ちます。

[📦 商品リンク: moshimo-book-9CeFY]

AIエージェントの設計・運用全般について理解を深めたい方に。

[📦 商品リンク: moshimo-card-tpujl]