#77 の実 Wiki 検証中に、wiki_sync の index 要約フェーズで agy 未認証または keyring 不可の状態を auth_required として短絡できず、各 doc が outer_timeout まで待たされる問題が見つかった。
実測ではページ同期は正常に完了している一方、index 要約フェーズで 51 件すべてが status=timeout, reason=outer_timeout になった。1 件あたり約 300 秒待つため、全件では数時間規模になり、#77 の確認が止まる。
重要なのは、これは #77 の「要約成功分を index に逐次保存する」対策とは別問題であること。#77 は timeout しても成功済み要約を失わないための対策、#78 は未認証状態でそもそも agy を全件起動しないための対策。
butler/agy_toolless.py の detect_auth_required() は、agy log に次のような確定 marker が出た場合だけ auth_required と判定する。
Print mode: silent auth failed, triggering OAuthAuthentication required. Please visit the URL to log in:Error: authentication failed:これは #76 の実機検証を踏まえた安全側の設計で、成功時にも出る auth noise を auth_required と誤判定しないために必要だった。
しかし #78 の実測では、agy が確定 marker を出す前にハングし、ログには Raising signal 15 程度しか残らなかった。この場合、実行後 arbiter は auth_required を確定できず、最終的に outer_timeout へ分類される。
butler/wiki_doc_summarizer.py の summarize_doc_result() は、backend が agy の場合、いきなり run_toolless() を実行する。
wiki_worker.py の index フェーズでは、変更された docs をループし、doc ごとに summarize_doc_result() を呼ぶ。そのため、未認証状態では最初の 1 件で止まるべきところを、各 doc ごとに timeout を繰り返す構造になっている。
#77 で session_logs/wiki_sync/*.jsonl の進捗ログを入れたため、詰まり位置の可視化はできるようになった。ただし #78 の本質は「詰まり位置を可視化すること」ではなく、「未認証なら要約ループに入らず brain に認証案内を返すこと」である。
agy -p 'Reply with exactly OK.' --print-timeout 5m を brain に返し、ユーザーの通常端末で認証してもらう。BUTLER_WIKI_SUMMARY_BACKEND=unavailable の rollback 経路は維持し、agy を一切起動しない。work_publish 経由では wiki_sync の Result status がそのまま brain へ伝播しないため、認証案内は必ず evidence に載せる。対象候補:
butler/wiki_summary_auth.pybutler/wiki_doc_summarizer.py関数案:
@dataclass(frozen=True)
class WikiSummaryAuthPreflight:
ok: bool
status: str # ok | need_input | failed | skipped
reason: str | None
guidance: str | None
command: str | None
evidence: dict[str, Any]
def wiki_summary_auth_preflight(*, backend: str | None = None) -> WikiSummaryAuthPreflight:
...
挙動:
backend == "unavailable" なら ok=True。
agy binary が無ければ ok=False, status="failed", reason="agy_not_found"。evaluate_auth(default_keyring_checker) を短時間で実行する。auth_status == "ok" なら token-file fast-path として ok=True。auth_status != "ok" の場合でも、ここで need_input に倒さない。default_keyring_checker は token file しか見ず OS keyring を照会しないため、keyring-only 認証済みユーザーを誤ってブロックする。summarize_doc_result() 経由ではなく run_toolless()(または canary 専用ランナー)を直接使う。ok / need_input の判定は agy log の auth marker を権威とし、AgyToollessResult.status は pending(quota / concurrency)とハード失敗の切り分けにのみ使う。success marker で early abort した canary は Stop event も DONE 応答も持たないため、status は ok にならず failed / timeout になる(後述)。これを「abort による正常」として扱う。AUTH_SUCCESS_MARKER または AUTH_REQUIRED_MARKERS のどちらかが log に出た時点で abort し、in-memory log の marker で分類する。AUTH_SUCCESS_MARKER が出た場合: keyring-only 認証済みを含め、認証フェーズ通過と確定して ok=True。モデル推論完了は待たない。AUTH_REQUIRED_MARKERS が出た場合: ok=False, status="need_input"。ok=False, status="need_input", reason="auth_canary_timeout" として短絡する。pending(quota / concurrency): ok=False, status="skipped" または need_input ではなく retryable な状態として扱う。認証案内とは分ける。ok=False, status="failed" とし、reason を evidence に残す。ここで重要なのは、review と挙動を分けるだけでなく、#76 で避けた token-file 偽陰性も再導入しないこと。review は 1 回の実行なので実行レベル arbiter に委ねられる。一方、Wiki 要約は多数 doc の batch 処理なので、token file が不十分な場合は canary 1 回で実行経路そのものを確認する。
canary は実 docs 本文を送らず、固定の非機密短文だけを使う。
例:
Reply with exactly OK.
または summarize_doc_result() の出力 validation を通す必要がある場合は、短い公開 smoke 文書を使う。
This is a public authentication canary for Butler Wiki summaries. It contains no secrets.
canary の目的は要約品質ではなく、agy が hidden/background runtime で認証フェーズを通過できるかを確認すること。AUTH_SUCCESS_MARKER はモデル推論より前に出るため、健全だが推論が遅い agy を auth_canary_timeout と誤判定しない。
既存の butler/agy_toolless.py には AUTH_SUCCESS_MARKER = "Print mode: silent auth succeeded" があるが、現状は success 検出関数がない。実装時に次を追加する。
def detect_auth_success(text: str) -> bool:
return AUTH_SUCCESS_MARKER in text
canary 用の abort check は、失敗 marker だけでなく success marker でも止める。
def _canary_abort() -> bool:
text = _read_request_log(agy_log_path)
return detect_auth_success(text) or detect_auth_required(text)
abort 後、raw log を in-memory に取り込んで削除し、次の順で分類する。
detect_auth_success(log_text) → okdetect_auth_required(log_text) → need_inputneed_input(reason="auth_canary_timeout", possible_auth_hang=True)run_toolless の外側ラッパーでは書けない上の _canary_abort 擬似コードは概念であり、現状の run_toolless をそのまま外から包む形では実装できない。butler/agy_runtime.py の run_toolless(L1293-1347 付近)は次のように request-owned log を内部に閉じている。
request = new_request(project_home) で result_dir を内部生成する。agy_log_path = request.result_dir / "agy.log" も内部で決まる。_auth_abort(detect_auth_required のみ)をハードコードして run_with_process_group へ渡している。したがって外側からは agy_log_path を観測できず、success marker 用の abort_check も差し込めない。run_toolless 側の小改修が必須で、次のいずれかで実装する。
run_toolless に外部 abort_check を注入可能にする(または canary 専用に「success / required どちらかで abort」する内部分岐を足す)。AgyToollessResult に surface する(例: auth_success_seen: bool)。または judge_success を通さない専用の軽量 canary ランナー(build_agy_command + run_with_process_group + marker 判定のみ)を agy_runtime に置く。いずれの場合も、canary の verdict は marker から決め、Stop event 欠落による status 失敗(stop_event_count / no_done_response 等)は「abort による正常」として扱う。
wiki_worker.py の index フェーズ入口で preflight する対象箇所:
butler/maids/wiki_worker.py の post-sync index 更新処理。changed_paths / all_entries を作った後、doc ループに入る前。
処理案:
preflight.ok が False のときは status を問わず要約ループへ入らない(#78 の「入口で 1 回判定して全件ループを避ける」目的)。分岐は status で分ける。
preflight = wiki_summary_auth_preflight()
evidence["index_summary_auth"] = preflight.evidence
_append_wiki_sync_progress(..., "index_auth_preflight", ...)
summaries_enabled = True
if not preflight.ok:
summaries_enabled = False # いずれの NG でも要約ループへ入らない
if preflight.status == "need_input":
evidence["index_summaries"] = []
evidence["index_page_update"] = {... 全 0 ...}
evidence["failed_stage"] = "index_summary_auth"
evidence["failure_reason"] = preflight.reason
evidence["next_recommendation"] = preflight.guidance
evidence["auth_command"] = preflight.command
return need_input(task.task_id, preflight.guidance, evidence)
# failed / skipped: 要約は諦めるが行(all_entries)の row sync は継続する
evidence["failure_reason"] = preflight.reason
if preflight.status == "failed":
evidence["failed_stage"] = "index_summary_auth"
else: # skipped(quota / concurrency 等 retryable)
evidence["index_summary_retry_after_seconds"] = preflight.evidence.get("retry_after_seconds")
# 要約ループは summaries_enabled が False なら空回しにし、row sync だけ 1 回継続する。
for page in (mapped_pages if summaries_enabled else []):
...
分岐の方針:
need_input: 要約ループに入らず need_input を返す。ページ同期自体はすでに完了している可能性があるが、直接 wiki_sync MCP 経路では need_input を返すのがよい。failed(agy_not_found / version_drift / deny_profile_invalid / canary_failed 等): 要約は諦めるが、行(all_entries)の row sync は 1 回だけ継続して index 行を失わない(#77 の前進保証)。failed_stage / failure_reason を evidence に残し、全体は ok(ページ同期は成功済み)で返す。skipped(quota / concurrency 等の retryable): 同様に要約ループへ入らず row sync だけ継続。index_summary_retry_after_seconds を evidence に残す。理由:
ただし work_publish 経由では注意が必要。work_record_backend.py の _run_wiki_sync は result.evidence を data.wiki_sync に入れるが、wiki_sync の Result status 自体は work_publish 全体の status として伝播しない。したがって work_publish では need_input status だけに期待してはいけない。必ず evidence に次を入れる。
failed_stage="index_summary_auth"failure_reasonnext_recommendationauth_commandindex_summary_auth.statusindex_summary_auth.guidanceこれにより、直接 wiki_sync でも work_publish 経由でも認証案内を読める。
summarize_doc_result() にもコスト回避用の保険を入れるwiki_worker 入口の preflight が本命だが、他の呼び出し元が summarize_doc_result() を直接呼ぶ可能性がある。
summarize_doc_result() は既に run_toolless() が auth_required を返した場合、summary_outcome_for() 経由で failed(reason=auth_required) に分類できる。したがってここで追加する preflight の価値は分類ではなく、未認証時に高コストな agy 実行へ入る前に短絡すること。
案(skipped も run へ進めず retry_after を保って返す):
def summarize_doc_result(..., auth_preflight: bool = True) -> SummaryOutcome:
if chosen_backend == "agy" and auth_preflight:
pre = wiki_summary_auth_preflight(backend=chosen_backend)
if not pre.ok:
status = "skipped" if pre.status == "skipped" else "failed"
return SummaryOutcome(
status, None, pre.reason or "auth_required",
pre.evidence.get("retry_after_seconds"),
)
ただし batch では毎 doc preflight すると無駄なので、wiki_worker からは auth_preflight=False で呼ぶ。
outcome = summarize_doc_result(..., auth_preflight=False)
こうすると:
wiki_worker は batch 入口で 1 回だけ判定。#77 で追加した session_logs/wiki_sync/*.jsonl に、次を追加する。
{
"event": "index_auth_preflight",
"status": "need_input",
"reason": "auth_canary_timeout",
"preflight_method": "canary",
"auth_marker": "none",
"possible_auth_hang": true,
"command": "agy -p 'Reply with exactly OK.' --print-timeout 5m"
}
認証が通っている場合:
{
"event": "index_auth_preflight",
"status": "ok",
"preflight_method": "token_file_fast_path",
"auth_marker": "token_file",
"reason": null
}
または keyring-only 認証済みの場合:
{
"event": "index_auth_preflight",
"status": "ok",
"preflight_method": "canary",
"auth_marker": "success",
"reason": null
}
これにより、MCP timeout 以前に短絡したかどうか、または preflight を通過して要約へ入ったかがログで分かる。
detect_auth_required() を timeout 時にも auth_required 寄りにする不採用。
outer_timeout は auth 以外でも発生する。stdout/stderr や agy log に確定 marker がない状態で timeout を auth_required に寄せると、実際のモデル遅延・プロセス異常・quota 待ちを誤分類する。
ただし補助情報として、timeout 時の agy_log が短く、auth success marker も stop event もない場合に possible_auth_hang=true のような診断フラグを付けるのは有効。
不採用。
被害時間は減るが、未認証時に全件ループする構造は残る。#78 の要求である「brain へ認証案内を返す」も満たさない。
run_toolless() の hard gate を復活する不採用。
#76 で token file precheck は keyring-only 認証済み構成を偽陰性にすることが分かっている。review など単発実行の経路を巻き込んで hard gate を戻すと回帰する可能性が高い。
Wiki 要約専用の batch preflight として閉じ込めるのが安全。
不採用。
最初の対策案はこの方式だったが、#78 レビューで却下する。default_keyring_checker は token file しか見ず、OS keyring のみで silent auth 可能な状態を検出できない。これを Wiki 要約で hard gate にすると、#76 でサポート対象にした keyring-only 認証済みユーザーを恒久的にブロックする。
token file は fast-path としてのみ使い、不十分な場合は canary で実行経路を確認する。
対象候補:
tests/test_wiki_doc_summarizer.pytests/test_wiki_summary_auth.pyケース:
unavailable は auth check せず ok。agy_not_found。evaluate_auth() が ("ok", "ok") なら canary を呼ばず ok(token-file fast-path)。evaluate_auth() が ("auth_required", "keyring_unavailable") でも即 need_input にせず canary を 1 回呼ぶ。AUTH_SUCCESS_MARKER が出たら ok(keyring-only 認証済みを許可)。モデル応答完了は待たない。AUTH_REQUIRED_MARKERS が出たら need_input。reason=auth_canary_timeout / possible_auth_hang=True。agy -p 'Reply with exactly OK.' --print-timeout 5m が含まれる。対象:
tests/test_wiki_index_summary.pyケース:
auth preflight が need_input の場合:
summarize_doc_result() が呼ばれない。index_summaries == []。index_page_update.total_attempts == 0。wiki_sync Result は need_input。failed_stage="index_summary_auth"。next_recommendation / auth_command / index_summary_auth が残る。index_auth_preflight(status=need_input) が残る。auth preflight が ok の場合:
token file 不在だが canary 成功の場合:
summarize_doc_result() ループへ進む。index_auth_preflight(status=ok, preflight_method=canary) が残る。auth_marker=success が残る。work_publish 経由の確認:
wiki_sync が need_input 相当でも、work_publish 全体 status だけに依存しない。data.wiki_sync.failed_stage / failure_reason / next_recommendation / auth_command が読めることを確認する。未認証状態は一度認証すると再現しづらいため、実機確認は可能なタイミングで次を見る。
wiki_sync を実行。need_input が返る。agy -p 'Reply with exactly OK.' --print-timeout 5m が案内される。session_logs/wiki_sync/*.jsonl に index_auth_preflight が記録される。agy -p 'Reply with exactly OK.' --print-timeout 5m を実行して認証。wiki_sync / work_publish。index_auth_preflight(status=ok)。wiki_summary_auth_preflight() を追加。detect_auth_success() を追加し、canary は success / required marker のどちらかで早期 abort する。canary は run_toolless の外側ラッパーでは書けないため、run_toolless に abort_check 注入+auth_success_seen surface を足すか、judge_success を通さない専用 canary ランナーを agy_runtime に置く(§1「実装上の制約」参照)。ok / need_input の判定は marker を権威にし、internal status は pending / failure の補助にのみ使う。summarize_doc_result() に単体呼び出し用のコスト回避 preflight を追加。wiki_worker.py の index フェーズ入口に batch preflight を追加し、NG なら need_input で短絡する。work_publish 用に evidence guidance も必ず残す。index_auth_preflight を追加。run_toolless() を起動しない。最大でも短時間 canary 1 回で止まる。AUTH_SUCCESS_MARKER が出る keyring-only 認証済み状態では、要約ループへ進める。outer_timeout ではなく need_input 相当と認証コマンドが返る。AUTH_SUCCESS_MARKER で ok 判定され、timeout と誤分類されない。work_publish 経由でも evidence から認証案内を読める。