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形式)",
...
}
- 重要: 必ず上記の絶対パスにJSONファイルをWriteツールで書き込んでください。
- コンソールへの出力ではなく、ファイルとして保存することが必須です。
コマンドハンドラ側では、一時ファイルのパスを生成してプロンプトに注入し、エージェント実行後にそのファイルを読み取ります。
```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);
}
ファイルが見つからない場合のフォールバックとして、従来のテキスト抽出も残しましたが、こちらも貪欲マッチのバグを修正した上で、ブラケットの深さを追跡するパーサーに書き換えています。
おまけ: ダブルエスケープのバグ
ついでに見つけたのが、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のパイプライン設計やトラブルシューティングについて体系的に学びたい方には、以下の書籍が参考になります。
[📦 商品リンク: moshimo-book-e7vQm]
AIエージェントを開発ワークフローに組み込む際の実践的な知見については、こちらが役立ちます。
[📦 商品リンク: moshimo-book-9CeFY]
AIエージェントの設計・運用全般について理解を深めたい方に。
[📦 商品リンク: moshimo-card-tpujl]