関連 issue: #73 wiki_syncで多数ページ同期時にGemini要約ステップが詰まりタイムアウトする
wiki_sync(dry_run=false) で多数の docs/**/*.md を一括同期したとき、Wiki.js へのページ書き込みは進む一方で、同期後のインデックスページ更新に含まれる Gemini 要約生成が逐次実行され、MCP tool call の 120 秒制限に到達する。
現在の WikiWorker.execute() は大きく以下の順で処理している。
mapped_pages を作るcreated_pages / updated_pages / failed_pages などを evidence に入れるrecord_project() と _update_wiki_home_page() を実行するcreated_pages + updated_pages の各ページに summarize_doc() を呼び、_update_index_page() を実行する問題は 6 が同期本体の後に同期的に走るため、ページ書き込みが成功していても、要約生成が詰まると呼び出し側には timeout だけが見える点である。
created_pages / updated_pages / failed_pages / uploaded_assets / failed_assets を失わないwiki_sync 全体の状態を判断できる補足:
wiki_sync の処理を、必須の「ページ同期」と付随的な「メタ更新」に分ける。
さらに、ページ同期自体も 1 回の tool call で無制限に処理せず、件数上限または soft deadline で区切れるようにする。
必須処理:
evidence への同期結果格納sync_status="partial" として返す付随処理:
付随処理の失敗は wiki_sync の同期結果を消さず、evidence["post_sync"] 配下に状態として残す。
wiki_sync の呼び出し結果は、Brain がそのままユーザーへ説明できる粒度で返す。
最低限、Brain は以下を判断できる必要がある。
Brain 側の会話例:
Wiki 同期は 20 ページ中 10 ページまで完了しました。
残り 10 ページは時間上限に近づいたため未同期です。
続けて残りを同期できます。
このため、Butler は「同期成功/失敗」だけでなく、「要約サブタスクの状態」を構造化して返す。
Butler の責務:
Brain の責務:
wiki_sync の返却結果をユーザーに説明するこの分担により、ユーザーが Wiki を直接見に行かなくても「今どうなっているか」を判断できる。
wiki_sync に要約モードを追加する。
def wiki_sync_tool(
dry_run: bool = False,
summary_mode: str = "light",
paths: list[str] | None = None,
sync_token: str | None = None,
) -> dict[str, Any]:
return sync_wiki(
dry_run=dry_run,
summary_mode=summary_mode,
paths=paths,
sync_token=sync_token,
)
summary_mode の意味:
| 値 | 意味 |
|---|---|
none |
Gemini 要約を実行しない。インデックスページは既存説明文を保持し、新規・変更ページの説明文は空欄または deterministic fallback にする |
light |
変更ページ数が少ない場合のみ要約する。上限を超えたら自動スキップする |
full |
従来どおり変更ページすべてを要約する。手動指定時のみ使用する |
初期デフォルトは light とする。
paths / sync_token の意味:
| 引数 | 意味 |
|---|---|
paths |
同期対象を特定の repo file または wiki path に絞る。Brain が未同期ページだけを後続同期するときに使う |
sync_token |
前回 wiki_sync が返した続きトークン。未同期ページのリストや順序を Butler 側で再現できる場合に使う |
Phase 1 では、sync_token の永続化が重い場合は、まず pending_pages[].repo_file を Brain が paths として渡す方式でもよい。
ただし返却値には将来の sync_token に相当する next_action と pending_pages を必ず含める。
理由:
wiki_sync の主目的は docs を Wiki.js に反映することであり、要約は補助機能であるwiki_sync によってインデックスの説明文も更新されることを期待しうるsummary_mode="none" を明示するsummary_mode="full" を明示するbutler/mcp_facade.py
summary_mode / paths / sync_token を追加するwiki_sync_tool() から sync_wiki() へ渡すbutler/work_record_backend.py
sync_wiki(dry_run=False, summary_mode="light") に変更する_run_wiki_sync() からは summary_mode="light" を使う_call_wiki_worker() の payload に summary_mode / paths / sync_token を入れるbutler/maids/wiki_worker.py
summary_mode を読むpaths / sync_token を読み、同期対象を絞るneed_input または failed で返すsync.pending_pages に残すpost_sync に分離するbutler/wiki_doc_summarizer.py
summarize_doc(title, content_head, timeout=...) のように timeout 引数を追加するsummary_mode="light" では短い timeout を渡すPhase 1 のページ同期 soft deadline は 90秒 を既定値とする。MCP tool call の 120 秒制限に対して、結果整形、post sync 判定、呼び出しのオーバーヘッドに少なくとも 30 秒を残す。
reason="page_sync_deadline_exceeded" として sync.pending_pages に残すreason="page_batch_limit_exceeded" として同様に残すこの既定値は実装と docs/capabilities/wiki/仕様書.md の両方に明記し、テストでは経過時間を注入またはモックして deadline 前後の partial 判定を確認する。
ページ同期の90秒と要約の45秒を独立して消費すると合計が MCP の120秒制限を超えるため、WikiWorker.execute() の開始時に 110秒の全体 soft deadline を1つ作り、すべてのフェーズで共有する。残り10秒は結果整形と MCP 応答の余白とする。
min(summary_deadline_seconds, 全体の残り時間 - 終了処理用余白) とするmin(summary_timeout_seconds, 要約フェーズと全体の残り時間) に短縮するreason="operation_deadline_insufficient" の pending_pages に残す全体 deadline は各処理の開始可否を決めるだけの独立した目安ではなく、外部コマンドとネットワーク呼び出しへ渡す実 timeout の上限としても使用する。
既存のトップレベル結果は維持する。
evidence.update({
"sync": {
"status": "partial",
"requested": len(mapped_pages),
"processed": len(created_pages) + len(updated_pages) + len(failed_pages),
"created": len(created_pages),
"updated": len(updated_pages),
"failed": len(failed_pages),
"pending": 10,
"pending_pages": [
{
"repo_file": "docs/operations/データ移行.md",
"path": "riceshop/operations/データ移行",
"title": "データ移行",
"reason": "page_sync_deadline_exceeded"
}
],
"next_action": "ask_user_whether_to_continue_wiki_sync",
"message": "10 pages synced, 10 pages pending because page sync deadline was reached.",
},
"created_pages": created_pages,
"updated_pages": updated_pages,
"failed_pages": failed_pages,
"uploaded_assets": uploaded_assets,
"failed_assets": failed_assets,
"deleted_pages": deleted_pages,
"failed_orphans": failed_orphans,
})
sync.status は以下のいずれかにする。
| 値 | 意味 |
|---|---|
complete |
対象ページすべての create / update が完了した |
partial |
一部は同期済みだが、未同期ページが残っている |
failed |
ページ同期に失敗がある |
skipped |
同期対象がない、または dry-run のみ |
sync.pending_pages[].reason は Brain がユーザーへ説明できる値にする。
| 値 | 意味 |
|---|---|
page_batch_limit_exceeded |
1回の同期件数上限に達した |
page_sync_deadline_exceeded |
ページ同期の soft deadline に達した |
asset_sync_deferred |
関連 asset の処理を次回へ送った |
orphan_delete_deferred |
orphan delete を次回へ送った |
付随処理は以下へ集約する。
evidence["post_sync"] = {
"summary_mode": summary_mode,
"home_page_update": {"ok": True, "message": "..."},
"index_page_update": {"ok": True, "message": "..."},
"summaries": {
"status": "partial",
"mode": "light",
"requested": len(created_pages) + len(updated_pages),
"generated": 5,
"skipped": 15,
"failed": 0,
"timeout": 0,
"generated_pages": [
{"path": "riceshop/overview", "title": "概要"}
],
"pending_pages": [
{
"path": "riceshop/operations/データ移行",
"title": "データ移行",
"reason": "summary_limit_exceeded"
}
],
"failed_pages": [],
"next_action": "ask_user_whether_to_generate_pending_summaries",
"message": "5 summaries generated, 15 summaries pending because summary limit was reached.",
},
}
互換性のため、当面は既存の home_page_update / index_page_update もトップレベルに複製してよい。ただし新規参照先は post_sync とする。
summaries.status は以下のいずれかにする。
| 値 | 意味 |
|---|---|
complete |
対象ページすべての要約が生成済み |
partial |
一部は生成済みだが、未生成ページがある |
skipped |
要約生成を実行していない |
failed |
要約生成またはインデックス更新が失敗した |
pending_pages[].reason / failed_pages[].reason は Brain がユーザーへ説明できる値にする。
| 値 | 意味 |
|---|---|
summary_mode_none |
summary_mode="none" により未実行 |
summary_limit_exceeded |
light mode の件数上限を超えた |
summary_deadline_exceeded |
要約処理全体の soft deadline を超えた |
summary_timeout |
1件の Gemini 要約がタイムアウトした |
gemini_failed |
Gemini CLI が非0終了した |
index_update_failed |
要約後のインデックスページ更新に失敗した |
summary_mode=none のインデックス更新_update_index_page() は既存ページから未変更エントリの説明文を引き継ぐ実装になっている。
summary_mode=none では、変更ページについても Gemini を呼ばず、以下の優先順で説明文を決める。
wiki_path の説明文があれば保持するこのため、_update_index_page() 側またはその前段で「変更ページだが説明文未生成」の entry を扱えるようにする。
ページ同期が sync.status="partial" の場合、原則として Gemini 要約とインデックス更新は実行しない。
理由:
返却値では以下のように明示する。
evidence["post_sync"] = {
"skipped": True,
"reason": "page_sync_partial",
"next_action": "continue_wiki_sync_before_generating_summaries",
}
Brain はユーザーへ「残りページを同期してから要約へ進む」ことを説明する。
summary_mode="light" では、要約対象件数と要約処理の総時間に上限を設ける。
推奨初期値:
| 項目 | 値 |
|---|---|
| 最大要約件数 | 5 |
| 1件あたり Gemini timeout | 15秒 |
| 要約処理全体の soft deadline | 45秒 |
設定方法:
summary_limitsummary_timeout_secondssummary_deadline_secondssoft deadline 到達時は、残りを skipped として evidence["post_sync"]["summaries"] に残し、Wiki ページ同期自体は成功扱いにする。
実装上の注意:
summary_timeout_seconds を効かせるため、butler/wiki_doc_summarizer.py の summarize_doc() は timeout 引数を受け取るreason="summary_timeout" の再実行可能な pending_pages に戻すreason="summary_timeout" として failed_pages または再実行可能な pending_pages に残すwiki_sync で未生成要約が返った後、ユーザーが「要約だけ追加して」と指示できるように、要約とインデックス更新を別 tool または別 operation に分離する。
候補:
wiki_sync(dry_run=False, summary_mode="none")wiki_refresh_index(prefix?, summary_mode="light", only_pending=True)wiki_generate_summaries(prefix?, paths?, limit?)この段階では、Wiki 書き込み後に一度 wiki_sync の結果を返し、説明文生成はユーザーが明示的に実行する運用にできる。
要約追加 tool は、Brain が進捗報告しやすいように小さな単位で実行できる必要がある。
実行単位案:
paths が指定されればそのページだけ要約するlimit が指定されれば最大件数だけ要約するgenerated_pages / pending_pages / failed_pages を含めるBrain 側の進捗例:
未生成の要約 15 件のうち、まず 5 件を追加します。
5 件追加できました。残り 10 件です。
次の 5 件を続けます。
同様に、ページ同期が partial の場合も Brain は未同期ページを小分けに同期する。
20 ページ中 10 ページを同期しました。
残り 10 ページを続けて同期します。
残り 10 ページも同期できました。次に要約の状態を確認します。
非同期ジョブ化する場合も、MCP tool call 内で完了を待たず、以下だけを返す。
{
"post_sync": {
"summary_job": {
"queued": True,
"job_id": "...",
"target_pages": [...],
}
}
}
従来どおり failed_pages / failed_assets / failed_orphans がある場合は failed(...) を返す。
このとき post sync は原則実行しない。
ただし、件数上限または soft deadline によって未同期ページが残っただけなら、failed(...) ではなく ok(...) と sync.status="partial" を返す。
これは「処理失敗」ではなく「安全に中断して続きがある」状態として扱う。
record_project() / _update_wiki_home_page() / _update_index_page() / summarize_doc() の失敗は、ページ同期結果を失わせない。
返却ステータス案:
okokfailedpost sync 失敗時の summary は以下のようにする。
Wiki sync succeeded: updated 20 pages. Summaries generated 5, pending 15.
summary_mode="none" では summarize_doc() が呼ばれないsummary_mode="none" でも created_pages / updated_pages が evidence に返るsync.status="partial" と sync.pending_pages が返るpost_sync.reason="page_sync_partial" が返るpaths 指定で未同期ページだけを後続同期できるsummary_mode="light" では要約の生成件数、未生成件数、未生成理由が evidence に返るok と同期結果を返すsummary_mode="light" で対象件数が上限を超えた場合、超過分が skipped になるreason="summary_timeout" の failed_pages または pending_pages が返るoperation_deadline_insufficient の pending が返るsummary_mode は明示的なエラーになるwork_publish() 経由の自動 wiki sync は summary_mode="light" を使い、未生成要約がある場合は返却値で分かる既存でインデックスページの説明文生成を期待しているテストがあれば、明示的に summary_mode="full" または light を指定する形へ更新する。
summary_mode を payload / facade / backend に通すpaths / sync_token を payload / facade / backend に通すWikiWorker.execute() で summary_mode を検証するsync.pending_pages に返すpost_sync に分離するsummarize_doc() に timeout 引数を追加するsummary_mode="light" の件数上限、1件 timeout、soft deadline を実装するpending_pages / failed_pages に返すsummary_mode="none" で summarize_doc() を呼ばないようにするwork_publish() 経由の wiki sync を summary_mode="light" にするdocs/capabilities/wiki/仕様書.md に新しい挙動を反映するPhase 1 は採用推奨。
理由:
summary_mode="full" を残すため、従来挙動を完全には失わないただし、既定動作を従来相当の全件要約から summary_mode="light" に変えるため、ユーザー可視の挙動変更になる。
この変更は採用する。
理由:
light は要約を完全に捨てるのではなく、できた分と未生成分をユーザーへ見える形で返すsummary_mode="full" を明示できるPhase 2 以降は、Phase 1 適用後に実運用でまだ説明文生成を同期実行したいかを見て決める。