diff --git a/CLAUDE.md b/CLAUDE.md index 262718a7..e01a0f91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ - [ADR-068: pre-push fix step の権限境界 — 後退検知 backstop と設計級 remedy の human routing](docs/adr/adr-068-fix-step-authority-boundary.md) *(試験運用)* - [ADR-069: PR chain 宣言規約 — 分割チェーンと missing-consumer 検査の両立](docs/adr/adr-069-pr-chain-declaration.md) *(試験運用)* - [ADR-070: weekly-review の分析フェーズを cloud routine へ移行 — 常時性の獲得と成果物デリバリの未解決](docs/adr/adr-070-weekly-review-cloud-routine.md) *(試験運用)* +- [ADR-071: 未マージ draft PR 数による背圧 — draft-pr クラスの自主減速](docs/adr/adr-071-draft-pr-backpressure.md) *(試験運用)* ## 開発 convention / チェックリスト diff --git a/autonomy-config.toml b/autonomy-config.toml index 6d99089b..ce31b6b8 100644 --- a/autonomy-config.toml +++ b/autonomy-config.toml @@ -50,3 +50,26 @@ # # 停止する場合: 恒久停止は本行を false へ、緊急停止は Actions variable を削除する。 enabled = true + +# --------------------------------------------------------------------------- +# 背圧: 未マージ draft PR 数の上限 (ADR-071 / WP-18 PR 1、ADR-052 原則 5 の契約) +# --------------------------------------------------------------------------- +# 自律 actor が draft PR を作る前に、`claude/` prefix の未マージ draft PR を数え、 +# **この件数以上なら停止する** (>= 判定)。上の enabled が「動いてよいか」を決めるのに対し、 +# 本値は「もう十分積んだので今夜は作らない」という自主減速を決める。 +# +# 極性と欠損時の挙動は enabled と同じ「欠損 → 停止」: +# - キーが無い / 型が違う (文字列・負値・小数) → 背圧未接続として draft PR を deny +# - 実測件数を取得できなかった場合も deny (呼び手が数を渡せなければ停止) +# 既定値へ倒さないのは、書き忘れが「勝手に 3 件まで作る」へ倒れないようにするため。 +# +# なお本キーが型違いだと toml の parse がファイル単位で失敗し、上の enabled も読めなくなる +# = 自律動作が全停止する。config が半壊した状態で「kill-switch だけ有効」と読ませないための +# 意図した挙動で、編集後は `pnpm autonomy-status` で読めていることを確認すること。 +# +# 0 を指定すると「draft PR を 1 件も作らない」= draft-pr クラスだけの停止になる +# (fix push は本値の影響を受けない。fix push の背圧は cli-pr-monitor の有界 retry が担う)。 +# +# 初期値 3 の根拠: 人間のレビュー待ち行列として 3 件が「1 セッションで捌ける上限」の見積り。 +# 稼働後の実績 (採用率・滞留時間) で見直す (ADR-071 § 試験運用判断基準)。 +max_open_draft_prs = 3 diff --git a/docs/adr/adr-071-draft-pr-backpressure.md b/docs/adr/adr-071-draft-pr-backpressure.md new file mode 100644 index 00000000..d9290f56 --- /dev/null +++ b/docs/adr/adr-071-draft-pr-backpressure.md @@ -0,0 +1,141 @@ +# ADR-071: 未マージ draft PR 数による背圧 — draft-pr クラスの自主減速 + +## ステータス + +試験運用 (2026-08-06) + +> 本 ADR は [ADR-039](adr-039-experimental-feature-standard-pattern.md) の 3 点セット (config opt-in / kill-switch / bounded lifetime) に従う。全体 kill-switch は [ADR-066](adr-066-autonomy-global-kill-switch.md) が持ち、本 ADR はその上に載る**自主減速**の層である。 + +## コンテキスト + +[ADR-052](adr-052-autonomy-execution-boundary-classes.md) 原則 5 は、自動実行可クラス (特に draft PR 作成) を**背圧なしで有効化することをアンチパターンとして明示的に禁止**している。同原則の契約表は「背圧 (未マージ draft 数の監視) または kill-switch が未接続・読み取り不能なら、自動実行可クラスを無効化しゲート必須へ倒す」と定める。 + +この契約は [ADR-066](adr-066-autonomy-global-kill-switch.md) の実装で**構造的な deny** として具体化されていた — `lib-autonomy-policy` の `Operation::backpressure_connected()` が `DraftPr => false` を固定で返し、kill-switch の 2 面がどちらも有効でも `draft-pr` は通らない。これは未実装の placeholder ではなく、「背圧より先に draft PR 自動作成が有効化される順序事故」を型で塞ぐ意図的な状態だった。 + +夜間 todo 消化ループ (計画書 WP-18) は draft PR 作成を主たる成果物とするため、この構造的 deny を解除しない限り成立しない。本 ADR は解除の条件 = 背圧の実装を定める。 + +### なぜ「無料だから積み放題」ではないのか + +実行コスト面では制約が緩い。にもかかわらず背圧が要るのは、**律速がマシン資源ではなく人間のレビュー帯域と Max 枠**だからである (§ 外部 SaaS の課金・上限事実)。未マージ draft が積み上がると: + +- レビュー待ち行列が人間の処理能力を超え、採用率 (= マージされた割合) が下がる。採用されない draft を作る run は Max 枠を消費するだけになる。 +- 同一台帳から次のタスクを選ぶループが、前夜の draft と重複・衝突する変更を作りやすくなる。 +- 「止め方」が全体 kill-switch しかないと、減速したいだけの場面で全自律動作を止めることになる。 + +### 当初案からの格上げ + +計画書の WP-19 ステップ 2 の当初案は「routine プロンプト冒頭の自己抑制判定」だった。これは [ADR-028](adr-028-pnpm-create-pr-gate.md) が指摘した「Claude が守る意志に依存する soft 防衛」そのものであり、決定論層 (`cli-autonomy-gate` の入力) へ格上げして実装する。 + +## 決定 (試験運用) + +### 1. 背圧の指標は「未マージ draft PR 数 (`claude/` prefix)」 + +自律 actor が作る draft PR は `claude/` prefix ブランチに限られる ([ADR-067](adr-067-phase-b-unattended-fix-push.md) の target 軸、ruleset 除外とも整合)。この prefix の **open かつ draft** な PR 件数を背圧の指標とする。 + +「レビュー待ち行列の長さ」を直接数える指標であり、run 回数や経過時間のような代理指標より、止めたい事象 (人間が捌けない量の未処理成果物) に近い。 + +### 2. 背圧の状態は `GateInputs` だけが持つ + +`Operation` は「どの指標を要求するか」だけを持ち (`requires_draft_backpressure()`)、指標の実測値・閾値は `GateInputs` が持つ。 + +状態を両方に持たせない。`backpressure_connected()` のような enum 側の状態と入力側のフィールドが併存すると、片方だけを更新した瞬間に判定経路が二股に分かれ、「テストは通るが実運用では別の枝を通る」形の drift を生む。**背圧の真偽を導出する場所は `evaluate()` の 1 箇所**に限る。 + +`FixPush` が draft 数を要求しないのは背圧が無いからではない。fix push の背圧は cli-pr-monitor の有界 retry (`max_retries`) が担っており、**呼び出しごとに gate へ渡す状態を持たない**ため判定入力に現れない。この非対称は doc コメントとテストの両方で固定する。 + +### 3. 閾値は `autonomy-config.toml` の `[autonomy] max_open_draft_prs`、判定は `>=` + +kill-switch フラグ (`enabled`) と同じファイル・同じ信頼境界 (CI からは master ref の写しを読む) に置く。閾値だけを Actions variable 側へ分けると、リポジトリ内 config と CI 側に自律制御の状態が分散し、[ADR-051](adr-051-cross-system-config-coupling.md) が扱ったクロスシステム drift を招く。 + +- **`>` ではなく `>=`**。閾値は「これ以上は積まない」上限。`max_open_draft_prs = 3` なら 3 件目が未マージの間は新規作成しない。 +- **`0` は draft-pr クラスだけの停止**。fix push は本値の影響を受けないため、全体 kill-switch を倒さずに夜間ループだけを止める操作点になる。 +- **キー欠落は既定値へ倒さない**。書き忘れが「勝手に 3 件まで作る」という fail-open にならないよう、欠落は背圧未接続 = deny とする。 + +toml の parse はファイル単位なので、本キーが型違い (文字列 / 負値 / 小数) だと `enabled` も含めて全フィールドが読めなくなり自律動作が全停止する。config が半壊した状態を「kill-switch だけ有効」で運転させないための意図した挙動である ([ADR-043](adr-043-security-gates-fail-closed.md))。 + +### 4. 実測件数を数えるのは呼び手、判断するのは gate + +`cli-autonomy-gate` は自分で GitHub を数えない。呼び手 (workflow step の `gh api`) が数え、`--open-draft-prs ` で渡す。 + +- gate の I/O 面を config と env だけに保つ ([ADR-066](adr-066-autonomy-global-kill-switch.md) の `sources.rs` の設計をそのまま維持する)。exe が `gh` に依存すると、ローカル drill が GitHub 到達性と認証に依存して**再現可能な安全装置の検証ができなくなる**。 +- 数える主体と判断する主体を分ける構図は [ADR-067](adr-067-phase-b-unattended-fix-push.md) と同型 (findings を出す agent と push を判断する gate が別)。 + +**呼び手が数を偽れる**という指摘は成立するが、この経路の呼び手は schedule イベントで起動する workflow であり、GitHub の仕様上 **default branch (master) の workflow 定義が実行される**。PR ブランチ上で数え方を書き換えても schedule 実行には反映されない。これは config の master ref 契約 ([ADR-066](adr-066-autonomy-global-kill-switch.md) § 決定 3) と同じ信頼境界に乗っている。 + +### 5. 数えられなかったことは 0 件ではない + +`--open-draft-prs` の値は `u32` としてパースし、空文字 / 負値 / 小数 / 非数値は**引数不正 (exit 2)** として loud に落とす。`None` へ黙って潰さない。 + +`gh api` が失敗したときの出力 (空文字など) を 0 件と読み違えて「draft が 1 件も無いので作ってよい」に倒れるのが、この機構で最も避けたい failure mode である。フラグ自体の省略は `None` = 背圧未接続 = deny なので、**省略も不正値も許可へは倒れない**。 + +## 外部 SaaS の課金・上限事実 (2026-08-06 再確認) + +計画書 (ephemeral) が保持していた前提事実のうち、本 WP に関係するものを永続化する (計画書 § 2 の移管義務)。research preview 由来の仕様変動があるため、確認日を明記する。 + +| 事実 | 確認日 | 備考 | +|---|---|---| +| public リポジトリ + standard GitHub-hosted runner の Actions 実行は無料。分数は無制限 | 2026-08-06 | GitHub 公式 docs の原文: "GitHub Actions usage is free for self-hosted runners and for public repositories that use standard GitHub-hosted runners" | +| GitHub Free の 2,000 分/月は **private リポジトリのみ**に適用 | 2026-08-06 | 本リポジトリ (aloekun/claude-code-hook-test) は public のため対象外 | +| `anthropics/claude-code-action@v1` は `claude_code_oauth_token` (Pro/Max のサブスク認証) で動く | 2026-08-06 | 本リポジトリの `.github/workflows/pr-monitor.yml` が 3 箇所で実運用中 | +| OAuth token は**個人サブスクに紐づき**、自動化の消費が対話作業のレート枠を圧迫しうる | 2026-08-06 | 夜間ループの実質コストはここ。背圧の経済的根拠 (§ コンテキスト) | + +要約すると、**Actions の実行時間は無料だが Max 枠は有限**であり、夜間ループのコスト上限は「無駄な run をどれだけ抑えられるか」で決まる。背圧はその抑制の第一段である。 + +## 試験運用判断基準 (ADR-039) + +| 項目 | 内容 | +|---|---| +| **Config opt-in** | `[autonomy] max_open_draft_prs`。キーが無ければ背圧未接続 = `draft-pr` は deny。既定が OFF かつ fail-closed 方向で、[ADR-066](adr-066-autonomy-global-kill-switch.md) と同じく両原則が同じ向きを指す | +| **Kill-switch** | draft-pr クラスだけ止めるなら `max_open_draft_prs = 0`。全自律動作を止めるなら `enabled = false` または Actions variable `AUTONOMY_ENABLED` の削除 (ADR-066 の操作反射をそのまま使う。新しい停止操作を増やさない) | +| **Bounded lifetime** | decision trigger: 夜間ループ稼働後に (a) 閾値未満で draft PR 作成が通ること、(b) **閾値到達で実際に次の run が `backpressure-saturated` で止まること**、(c) deny 理由が run log 1 行で切り分けられること、(d) 閾値 3 が運用実態 (滞留時間・採用率) に合っていること、を確認したら本採用。**2026-11-06 までに判定材料が集まらなければ延長 / 却下を判断する** | + +(b) は本 ADR に固有の観測点である。(a) と (c) は exe 単体 drill で固定できるが、**閾値に到達する状態は夜間ループが実際に draft を積まないと作れない**。閾値 3 は初期値であり、[dev-conventions](../dev-conventions.md) § LLM を含む自動化経路は実走でしか検証できない、の適用対象。 + +## 検証記録 + +### 背圧 drill 12 シナリオ (2026-08-06、release build の実 exe) + +`cli-autonomy-gate.exe` を実バイナリで直接起動し、境界と欠損の全経路を確認した。全て設計どおり。 + +| # | 入力 | 期待 | 結果 | +|---|---|---|---| +| 1 | `draft-pr` / 外部フラグ未設定 / config 正常 / open=0 | deny `external-unset`、exit 1 | 一致 | +| 2 | `draft-pr` / 外部 `1` / config `enabled=false` | deny `repo-config-disabled`、exit 1 | 一致 | +| 3 | `draft-pr` / 外部 `1` / config ファイル不在 | deny `repo-config-unavailable`、exit 1 | 一致 | +| 4 | `draft-pr` / config 正常 / `--open-draft-prs` 省略 | deny `backpressure-unavailable`、exit 1 | 一致 | +| 5 | `draft-pr` / config に閾値キー無し / open=0 | deny `backpressure-unavailable`、exit 1 | 一致 | +| 6 | `draft-pr` / limit=3 / open=2 | allow、`backpressure(draft-pr)=ok(2/3)`、exit 0 | 一致 | +| 7 | `draft-pr` / limit=3 / open=3 | deny `backpressure-saturated`、`saturated(3/3)`、exit 1 | 一致 | +| 8 | `draft-pr` / limit=0 / open=0 | deny `backpressure-saturated`、`saturated(0/0)`、exit 1 | 一致 | +| 9 | `fix-push` / limit=3 / open=99 | allow、`backpressure(fix-push)=structural`、exit 0 | 一致 | +| 10 | `draft-pr` / 閾値が文字列 `"3"` | deny `repo-config-unavailable` (半壊 config は `enabled` ごと停止)、exit 1 | 一致 | +| 11 | `--open-draft-prs abc` | 引数不正、exit 2 | 一致 | +| 12 | `--open-draft-prs -1` | 引数不正、exit 2 | 一致 | + +#6 と #7 の対で `>=` 境界が、#8 で `limit = 0` の停止が、#9 で fix push が draft 数から独立していることが、それぞれ実バイナリ上で確認できている。 + +#1 / #2 の状態行には deny 理由が背圧でないにもかかわらず `backpressure(draft-pr)=ok(0/3)` が出る。これは `describe_sources` が deny 理由を 1 つに絞るのとは別に**全ソースの状態を独立に出す**設計 ([ADR-066](adr-066-autonomy-global-kill-switch.md)) によるもので、「フラグを 1 つ直したのにまだ止まる」の切り分けを 1 run で終わらせるための意図した冗長性である。 + +### unit test + +判定コア 22 件 / sources 11 件 / 引数解析 7 件。うち網羅走査 1 件が **216 組合せ** (repo config 3 × 外部フラグ 15 × 背圧 4 × 操作 2) を走査し、許可される組合せ数を式で固定している。 + +## 帰結 + +### 利点 + +- ADR-052 原則 5 の契約が `draft-pr` についても機械判定可能な実体を得た。夜間ループ (WP-18 PR 3) は gate を呼ぶだけで契約を満たせる。 +- 「全部止める」以外の減速手段ができた。`max_open_draft_prs = 0` は fix push を生かしたまま夜間ループだけを止める。 +- 実測値と閾値を状態行に必ず併記するため、「数え損ねて止まった」と「積み過ぎて止まった」が run log 1 行で分かれる。 +- 停止操作を新設していない。既存の 2 つ (config / Actions variable) に 1 つの数値キーが増えただけで、緊急時の操作反射は変わらない。 + +### 欠点 / 留意点 + +- **背圧の実測は呼び手依存**。gate は渡された数を信じるしかなく、数え方の誤り (prefix の取りこぼし、draft でない PR の混入) は gate では捕捉できない。schedule イベントが master の workflow 定義を使う点が唯一の構造的担保であり、数え方そのものは workflow レビューで守る。 +- **閾値 3 に実測の裏付けが無い**。「1 セッションで捌ける上限」の見積りにすぎず、bounded lifetime (d) で見直す。 +- **半壊 config が全停止を招く**。閾値キーの typo で `enabled` ごと読めなくなるのは fail-closed として正しいが、運用上は「1 文字直したら全部止まった」に見える。config のコメントに `pnpm autonomy-status` での確認を明記して緩和した。 +- 背圧が効くのは**次の判定時点**であり、既に起動済みの run は止まらない (ADR-052 原則 5 の停止手順と同じ性質)。 + +### 残課題 + +- 実測件数を渡す呼び手 (夜間 workflow の `gh api` step) は本 ADR の PR には含まれない。WP-18 PR 3 で追加する。それまで**リポジトリ内の自動化経路には `--open-draft-prs` を渡す呼び手が 1 つも無い**ため、`draft-pr` は常に `backpressure-unavailable` で deny になり運用挙動は変わらない。exe を手動で実行し 3 拠点 (外部フラグ / repo config / 実測件数と閾値) をすべて満たせば allow になる — これは drill として意図した挙動であり、「自動で draft PR が作られる」ことを意味しない。 +- bounded lifetime (b) の観測 = 閾値到達での実停止は、夜間ループが 3 件の draft を積むまで発生しない。WP-18 の 2 週間の試験運用期間中に観測できなければ、閾値を一時的に下げて意図的に到達させる。 diff --git a/docs/harness-improvement-plan.md b/docs/harness-improvement-plan.md index 04c6faee..31962a60 100644 --- a/docs/harness-improvement-plan.md +++ b/docs/harness-improvement-plan.md @@ -24,7 +24,11 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 ## 2. 検証済みの前提事実(再調査不要、2026-07-04 確認) -> 本節の外部 SaaS の課金・上限事実(GitHub Actions 課金 / routines cap 等)は、残作業 WP-17〜19 の前提として本ファイルに保持する。research preview 由来の仕様変動があり得るため現時点では ADR 化せず、**WP-17〜19 の ADR 起票時に最新値へ再確認したうえで永続化する**(退役条件 2 がこの移管を必須化している)。 +> **本節の外部 SaaS の課金・上限事実は、担当 WP の ADR 起票時に最新値を再確認して永続化し、本節から削除する**(退役条件 2 がこの移管を必須化している)。research preview 由来の仕様変動があるため、移管時は必ず再確認し確認日を ADR 側に明記すること。まだ移管先の ADR が無い事実だけを本節に残す。 +> +> **移管済み(2026-08-06、WP-18 PR 1)**: GitHub Actions 課金 2 点と claude-code-action の OAuth 認証 → [ADR-071](adr/adr-071-draft-pr-backpressure.md) § 外部 SaaS の課金・上限事実。 +> +> **未移管**: cloud routines の事実群(daily cap / webhook 上限 / GitHub App 必須 / 緑ステータスの意味)は WP-19 / [ADR-070](adr/adr-070-weekly-review-cloud-routine.md) の担当範囲のため本節に残す。 ### ユーザー環境 @@ -32,14 +36,13 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 - GitHub アカウントは **GitHub Free**。本リポジトリ(aloekun/claude-code-hook-test)は **public**。 - Linux 対応の主ターゲットは **claude.ai/code クラウドセッション**。ループエンジニアリングの理想像は**常時稼働エージェント**。 -### GitHub Actions 課金(GitHub 公式 docs で確認済み) +### GitHub Actions 課金 -- **public リポジトリ + standard GitHub-hosted runner の Actions 実行は完全無料・回数無制限**。2,000 分/月(Free)の枠は private リポジトリにのみ適用される。 -- runner 単価は Linux が最安(Windows 約 2 倍、macOS 約 10 倍)。private 化した場合のみ関係する。 +- public リポジトリの無料枠と claude-code-action の OAuth 認証は [ADR-071](adr/adr-071-draft-pr-backpressure.md) § 外部 SaaS の課金・上限事実へ移管済み(2026-08-06 に最新値を再確認)。 +- runner 単価は Linux が最安(Windows 約 2 倍、macOS 約 10 倍)。private 化した場合のみ関係するため移管せず本節に残す。 ### Claude 側の実行経路(公式 docs で確認済み) -- **claude-code-action** は `CLAUDE_CODE_OAUTH_TOKEN`(ローカルで `claude setup-token` を実行して生成。Pro/Max ユーザー対応)での認証をサポート。API キー従量課金なしで **Max 枠内**で動く。 - **cloud routines**(claude.ai/code/routines)は Anthropic 管理インフラで実行され、使用量は Max 枠消費。**アカウント毎の 1 日あたり run 数上限**あり。one-off run は daily cap の対象外。 - routines の **GitHub トリガー**は Claude GitHub App の webhook 経由で、**GitHub Actions の分数を一切消費しない**。webhook イベントには per-routine / per-account の時間あたり上限あり(超過分は破棄)。research preview のため仕様変動に注意。 - routines の GitHub トリガーには **Claude GitHub App のインストールが必須**(`/web-setup` だけでは不足)。また `/schedule` はクラウドセッション内からは使えないため、routine の作成・編集は claude.ai/code/routines の Web UI で行う。 @@ -77,8 +80,8 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 | WP-15 | 3 | Linux バイナリビルド + クラウド setup script | M | WP-13, 14 | 完了([ADR-063](adr/adr-063-linux-portability-release-binaries.md)。クラウド実測は [ADR-060](adr/adr-060-cloud-harness-sessionstart-dispatcher.md) dogfood で達成、以降は ADR-060 の bounded lifetime で管理。追補の陽性証拠設計は [ADR-064](adr/adr-064-monitor-success-positive-evidence.md) → park 実観測は § 残作業) | | WP-16 | 3 | CI matrix(移植退行防止) | S | WP-13, 14 | 観測中([ADR-065](adr/adr-065-ci-matrix-cross-os-regression.md)。2 OS matrix は PR #342 でマージ済・master 稼働中、初回観測期間に実バグ 1 件捕捉(PR #344 で修正)。観測継続と required check 化は → § 残作業) | | WP-17 | 4 | イベント駆動バックボーン完成(Phase B + routines 移行 + 全体 kill-switch 前倒し) | M-L | WP-09, 10, 11 | **観測中(実装は 2026-08-04 に全 land)** — #347 / #350 / #351 / #352 / #353 / #354、実走バグ修正 #356 / #357 / #358、記帳 #359。実走スモーク段 0〜2 まで完走。**観測待ち**: 停止側の実走 2 点 / 自動起動経路 / 週末またぎ / ADR-066 bounded lifetime(1 of 3〜5 run)→ § WP-17。派生 ADR: [ADR-068](adr/adr-068-fix-step-authority-boundary.md) #348 / [ADR-069](adr/adr-069-pr-chain-declaration.md) #349 | -| WP-18 | 4 | 夜間 todo 消化ループ | M-L | WP-15, 17 | **着手可** — 着手前決定 3 件確定済み(2026-08-05 ユーザー確認 → § WP-18)。実行主体 = GitHub Actions schedule、背圧(WP-19 ステップ 2)を PR 1 へ前倒し統合、タスク台帳 = [claude-code-web-tasks.md](claude-code-web-tasks.md) | -| WP-19 | 4 | 常時性ガード(自主減速 / 監査ループ。全体 kill-switch は WP-17 PR 1、背圧は WP-18 PR 1 へ前倒し) | S-M | WP-18 | 未着手(残りは監査ループのみ) | +| WP-18 | 4 | 夜間 todo 消化ループ | M-L | WP-15, 17 | **着手中** — PR 1(背圧 + [ADR-071](adr/adr-071-draft-pr-backpressure.md))実装済み(2026-08-06)。PR 2(タスク台帳 = [claude-code-web-tasks.md](claude-code-web-tasks.md))/ PR 3(夜間 schedule workflow)未着手 → § WP-18 | +| WP-19 | 4 | 常時性ガード(自主減速 / 監査ループ。全体 kill-switch は WP-17 PR 1、背圧は WP-18 PR 1 へ前倒し) | S-M | WP-18 | 未着手(残りは監査ループのみ。背圧は WP-18 PR 1 で land 済み) | ## 5. 残作業(観測継続) @@ -151,19 +154,31 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 - **routine 出力の受け渡し手段が未決**: 分析結果が transcript にしか残らずユーザーが読まなければ消える。実行主体を含む 3 択(routine / GitHub Actions schedule / ローカル維持 = 断念)で、**断念も正規の出口**。判定は ADR-070 bounded lifetime (b) の観測後([ADR-070](adr/adr-070-weekly-review-cloud-routine.md) § 残課題)。 - **Phase B の実効価値は WP-18 に依存**: 対象が docs 指摘に限られるため、WP-18 の夜間ループが `claude/` ブランチ PR を作り始めるまで発火機会が小さい(ADR-067 § 欠点)。 -### WP-18: 夜間 todo 消化ループ — 着手可(着手前決定 3 件確定済み、2026-08-05) +### WP-18: 夜間 todo 消化ループ — 着手中(PR 1 実装済み、2026-08-06) + +> 夜間に 1 タスクを無人実装し **draft PR 作成で停止**する(マージ判断は人間)ループを、WP-17 のバックボーン上に組む。 -> 次セッションは本節だけで着手できる。夜間に 1 タスクを無人実装し **draft PR 作成で停止**する(マージ判断は人間)ループを、WP-17 のバックボーン上に組む。 +**進捗**: PR 1(背圧)実装済 → PR 2(タスク台帳)未着手 → PR 3(夜間 workflow)未着手。 - **着手前決定(2026-08-05、ユーザー確認済み)**: 1. **実行主体 = GitHub Actions schedule workflow**(cloud routine ではない)。根拠: [ADR-070](adr/adr-070-weekly-review-cloud-routine.md) § 実現可能性の未検証点の実測 — routine の `jj git push` はローカル hook(`jj-push-guard`)に阻まれ、例外新設は「自律 push 経路の新設」= 採用バー超え。Actions は workflow step が push する Phase B([ADR-067](adr/adr-067-phase-b-unattended-fix-push.md))と同構造でこの問題が発生せず、`claude/` prefix ブランチは ruleset 除外とも整合する。 - 2. **WP-19 ステップ 2(背圧)を本 WP の PR 1 へ前倒し統合**。根拠: [ADR-052](adr/adr-052-autonomy-execution-boundary-classes.md) 原則 5 は背圧なしの draft-pr クラス有効化をアンチパターンとして明示的に禁止し、`lib-autonomy-policy` の `backpressure_connected()` は `DraftPr => false` 固定で**構造的に deny する**(コード内コメントが「WP-18 で背圧を実装する PR がここを `true` へ反転させる」と指定済み)。WP-17 の kill-switch 前倒しと同じ判断。 + 2. **WP-19 ステップ 2(背圧)を本 WP の PR 1 へ前倒し統合**。根拠: [ADR-052](adr/adr-052-autonomy-execution-boundary-classes.md) 原則 5 は背圧なしの draft-pr クラス有効化をアンチパターンとして明示的に禁止し、着手時点の `lib-autonomy-policy` は `backpressure_connected()` が `DraftPr => false` 固定で**構造的に deny していた**。WP-17 の kill-switch 前倒しと同じ判断。→ PR 1 で解消済み(設計は [ADR-071](adr/adr-071-draft-pr-backpressure.md))。 3. **タスク台帳 = [claude-code-web-tasks.md](claude-code-web-tasks.md)**(旧案「todo-summary へ自律実行可列を追加」は置き換え)。同ファイルは既に「Web 実行可の判定基準 + curated なタスク表 + 着手フロー」を持ち、夜間ループの選択元に転用できる。ただし現状は ephemeral(全タスク land で retire)なので、**「定期更新される管理台帳」へ lifecycle を改訂**し、自律実行可の判断は **weekly-review と同じタイミングで定期更新**する(WP-19 ステップ 3 の監査ループと接続)。 - **PR 構成(新規 3 本)**: - 1. **PR 1: 背圧実装 + ADR 起票(M)** — 判定コア側は `lib-autonomy-policy` の `Operation::backpressure_connected()` が `DraftPr => false` 固定で構造的 deny しているので、**これを `true` へ反転させるのが必須の 1 点**。あわせて未マージ draft PR 数(`claude/` prefix)を決定論的に取得し(workflow step の `gh api`)、閾値は WP-19 ステップ 2 の「3 件以上で停止」を初期値に置く。**閾値判定をどの層に置くかは実装時の設計判断**((a) `GateInputs` に読み取り済みの draft 数を渡して gate 内で判定 — 現状のフィールドは `repo_config_enabled` / `external_raw` / `operation` の 3 つで入力の口を足す必要がある / (b) workflow step 側で判定し gate には接続状態だけ持たせる)。**同じ背圧状態を 2 箇所で持たない**こと — `backpressure_connected()` と別フィールドの二重管理は判定経路の分岐を招く。drill で実測を固定する。ADR 起票は [ADR-039](adr/adr-039-experimental-feature-standard-pattern.md) 3 点セット + **§ 3 の SaaS 課金・上限事実を最新値へ再確認して永続化**(本ファイル退役条件 2 の移管義務)。 + 1. **PR 1: 背圧実装 + ADR 起票(M)— 実装済み(2026-08-06、[ADR-071](adr/adr-071-draft-pr-backpressure.md))**。閾値判定の層は実装時判断で **(a) gate 内**を採った(`GateInputs` に実測値と閾値を渡す)。`Operation::backpressure_connected()` は廃し、`requires_draft_backpressure()`(指標の要求のみ・状態を持たない)と `GateInputs::{open_draft_prs, max_open_draft_prs}`(状態)へ分けて二重管理を避けた。閾値は `autonomy-config.toml` の `[autonomy] max_open_draft_prs = 3`。実 exe による drill 12 シナリオと unit test 40 件で実測を固定済み。SaaS 課金・上限事実(§ 2)の最新値再確認と永続化も同 ADR で完了。 2. **PR 2: タスク台帳のブラッシュアップ(docs、S)** — [claude-code-web-tasks.md](claude-code-web-tasks.md) の stale 行検証(land 済みタスクの除去)、無人実行可マークの追加(Web 実行可 = 人間が対話で補助できる、無人可 = 補助なしで完結、の 2 段階。最初は 5〜10 件だけ人間がマーク)、lifecycle を ephemeral から定期更新台帳へ改訂、weekly-review パイプラインへ台帳更新手順を接続。 - 3. **PR 3: 夜間 workflow(schedule、M-L)** — タスク選択(台帳の機械判定・fail-closed)→ 実装 → `cargo test` 検証 → draft PR 作成。**push / PR 作成は workflow step が gate(`cli-autonomy-gate --operation draft-pr`)経由で実行し、agent は push の主体にしない**(ADR-067 と同型)。**実走スモーク段を受け入れ基準に含める**([dev-conventions](dev-conventions.md) § LLM を含む自動化経路は実走でしか検証できない)。スモークで WP-17 残課題 2 件(Phase B 自動起動経路の実測 / `coderabbitai[bot]` allowlist 要否)も同梱観測する(ADR-067 § 検証記録に「WP-18 着手時に実測」と記帳済み)。 + 3. **PR 3: 夜間 workflow(schedule、M-L)** — タスク選択(台帳の機械判定・fail-closed)→ 実装 → `cargo test` 検証 → draft PR 作成。**push / PR 作成は workflow step が gate 経由で実行し、agent は push の主体にしない**(ADR-067 と同型)。**実走スモーク段を受け入れ基準に含める**([dev-conventions](dev-conventions.md) § LLM を含む自動化経路は実走でしか検証できない)。スモークで WP-17 残課題 2 件(Phase B 自動起動経路の実測 / `coderabbitai[bot]` allowlist 要否)も同梱観測する(ADR-067 § 検証記録に「WP-18 着手時に実測」と記帳済み)。 + +- **PR chain 宣言([ADR-069](adr/adr-069-pr-chain-declaration.md) 決定 1)**: PR 1 が導入した以下は **PR 3 まで呼び手を持たない**。リポジトリ内の自動化経路に `--open-draft-prs` を渡す呼び手が 1 つも無いため、PR 1 単体では `draft-pr` が許可される経路が動かず運用挙動は変わらない(exe を手動実行して 3 拠点をすべて満たせば allow になる = drill として意図した挙動。[ADR-071](adr/adr-071-draft-pr-backpressure.md) § 残課題)。 + + | PR 1 が導入するもの | PR 3 の消費側 | + |---|---| + | `cli-autonomy-gate` の `--open-draft-prs ` フラグ | 夜間 workflow(`.github/workflows/` に新設する schedule workflow)の draft PR 作成前 step が、`gh api` で数えた `claude/` prefix の open かつ draft な PR 件数をこのフラグへ渡す | + | `autonomy-config.toml` の `[autonomy] max_open_draft_prs` | 同 workflow が `--config` に渡す master ref の写しを通じて `cli-autonomy-gate` が読む(kill-switch の `enabled` と同じ経路・同じファイル) | + | `lib_autonomy_policy::Operation::DraftPr` の許可経路 | 同 step が `cli-autonomy-gate --operation draft-pr` を実行し、exit 0 のときだけ後続の PR 作成 step へ進む | + + この順序は逆にできない。[ADR-052](adr/adr-052-autonomy-execution-boundary-classes.md) 原則 5 が背圧の接続を draft-pr クラス有効化の**前提条件**としているため、背圧が先に land する必要がある(WP-17 の kill-switch 先行と同じ構造)。 - **運用ノート**: クラウドは使い捨てクローンのため jj workspace 分離は不要。ローカルで同ループを回す場合のみ [ADR-045](adr/adr-045-jj-workspace-parallel-sessions.md) の workspace を使う。稼働後 1 週間は run 頻度と Max 枠消費を観測して頻度調整。 - **受け入れ基準**: 2 週間の試験運用で無人 draft PR の採用率(人間がマージした割合)を測定。**50% 超で継続・拡大、未満なら対象クラスを絞って再試行**。測定は weekly-review の自律アクション棚卸し(WP-19 ステップ 3)に載せて仕組み化する。 @@ -172,7 +187,7 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 - **ステップ**: 1. **全体 kill-switch**: WP-17 の PR #347 へ前倒し済み(2026-08-02 決定、根拠 = ADR-052 原則 5 の契約。設計・実装内容は [ADR-066](adr/adr-066-autonomy-global-kill-switch.md))。 - 2. **自主減速(背圧)**: WP-18 の PR 1 へ前倒し済み(2026-08-05 決定、根拠 = ADR-052 原則 5 の契約と `backpressure_connected()` の構造的 deny。→ § WP-18)。当初案の「routine プロンプト冒頭の自己抑制判定」は決定論層(`cli-autonomy-gate` の背圧入力)へ格上げして実装する — instruction 層の自己抑制は ADR-028 が指摘した soft 防衛のため。 + 2. **自主減速(背圧)**: WP-18 の PR 1 で **land 済み**(2026-08-06、[ADR-071](adr/adr-071-draft-pr-backpressure.md))。当初案の「routine プロンプト冒頭の自己抑制判定」は決定論層(`cli-autonomy-gate --open-draft-prs` と `autonomy-config.toml` の `max_open_draft_prs`)へ格上げして実装した — instruction 層の自己抑制は ADR-028 が指摘した soft 防衛のため。残る観測は ADR-071 の bounded lifetime が管理する。 3. **監査ループを閉じる**: 自律アクション一覧(workflow run 履歴 + `claude/` ブランチ PR)を weekly-review の入力に追加し、「自律動作の週次棚卸し」を人間のレビューポイントとして固定する。WP-18 の採用率測定と台帳([claude-code-web-tasks.md](claude-code-web-tasks.md))の定期更新もここに載る。 ## 7. 完了条件と退役手順 diff --git a/src/cli-autonomy-gate/src/main.rs b/src/cli-autonomy-gate/src/main.rs index b47f82d8..ca73951d 100644 --- a/src/cli-autonomy-gate/src/main.rs +++ b/src/cli-autonomy-gate/src/main.rs @@ -11,9 +11,15 @@ //! # 使い方 //! //! ```text -//! cli-autonomy-gate --operation --config +//! cli-autonomy-gate --operation --config \ +//! [--open-draft-prs ] //! ``` //! +//! `--open-draft-prs` は `draft-pr` の背圧入力 (ADR-071)。呼び手 (workflow step) が +//! `gh api` で数えた **未マージ draft PR (`claude/` prefix) の実測件数**を渡す。省略すると +//! 背圧未接続として deny に倒れるため、`draft-pr` では実質必須。`fix-push` の背圧は +//! cli-pr-monitor の有界 retry が担うので本フラグは判定に影響しない。 +//! //! # exit コード //! //! - `0` = 許可 @@ -48,49 +54,59 @@ const EXIT_ALLOWED: i32 = 0; const EXIT_DENIED: i32 = 1; const EXIT_USAGE: i32 = 2; -const USAGE: &str = "usage: cli-autonomy-gate --operation --config "; +const USAGE: &str = "usage: cli-autonomy-gate --operation --config \ +[--open-draft-prs ]"; fn main() { std::process::exit(run(std::env::args().skip(1).collect())); } -/// コマンドライン設定。既定値は設けない — 両方とも明示必須。 +/// コマンドライン設定。`--operation` / `--config` に既定値は設けない — 明示必須。 /// /// `--config` を省略可能にして cwd から推測すると、CI で master ref の写しを渡し忘れた /// 呼び手が PR ブランチの config を黙って読む (ADR-066 § 決定 3 の信頼境界)。省略を /// 引数不正として弾くことで、呼び手にパスの出所を必ず意識させる。 +/// +/// `--open-draft-prs` だけは省略可能。`fix-push` では判定に使わないためで、`draft-pr` で +/// 省略した場合は `None` = 背圧未接続として deny に倒れる (省略が許可へ倒れることはない)。 struct Cli { operation: Operation, config_path: PathBuf, + open_draft_prs: Option, } fn parse_args(args: &[String]) -> Result { let mut operation = None; let mut config_path = None; + let mut open_draft_prs = None; let mut index = 0; while index < args.len() { let flag = args[index].as_str(); let value = args.get(index + 1); + let take = || value.ok_or_else(|| format!("{flag} の値がありません")); match flag { "--operation" => { - let raw = value.ok_or_else(|| "--operation の値がありません".to_string())?; + let raw = take()?; operation = Some( Operation::parse(raw) .ok_or_else(|| format!("未知の operation です: {raw:?}"))?, ); - index += 2; } - "--config" => { - let raw = value.ok_or_else(|| "--config の値がありません".to_string())?; - config_path = Some(PathBuf::from(raw)); - index += 2; + "--config" => config_path = Some(PathBuf::from(take()?)), + "--open-draft-prs" => { + let raw = take()?; + open_draft_prs = Some(raw.parse::().map_err(|_| { + format!("--open-draft-prs は 0 以上の整数である必要があります: {raw:?}") + })?); } other => return Err(format!("未知の引数です: {other:?}")), } + index += 2; } Ok(Cli { operation: operation.ok_or_else(|| "--operation が必要です".to_string())?, config_path: config_path.ok_or_else(|| "--config が必要です".to_string())?, + open_draft_prs, }) } @@ -104,9 +120,12 @@ fn run(args: Vec) -> i32 { } }; let external = sources::read_external_raw(); + let repo_config = sources::read_repo_config(&cli.config_path); let inputs = GateInputs { - repo_config_enabled: sources::read_repo_config_enabled(&cli.config_path), + repo_config_enabled: repo_config.enabled, external_raw: external.as_deref(), + open_draft_prs: cli.open_draft_prs, + max_open_draft_prs: repo_config.max_open_draft_prs, operation: cli.operation, }; report(&cli, inputs) @@ -165,6 +184,32 @@ mod tests { .expect("parse"); assert_eq!(cli.operation, Operation::FixPush); assert_eq!(cli.config_path, PathBuf::from("a.toml")); + assert_eq!(cli.open_draft_prs, None, "省略時は背圧未接続 (= 停止側)"); + } + + #[test] + fn parses_the_open_draft_count() { + let cli = parse_args(&args(&[ + "--operation", "draft-pr", + "--config", "a.toml", + "--open-draft-prs", "0", + ])) + .expect("parse"); + assert_eq!(cli.open_draft_prs, Some(0)); + } + + /// 数えられなかった結果を空文字や負値で渡されても、`None` (= 0 件扱いになりうる形) では + /// なく引数不正として弾く。呼び手の `gh api` が失敗した形が黙って通らないようにする。 + #[test] + fn non_numeric_open_draft_counts_are_usage_errors() { + for raw in ["", "-1", "3.0", "three", "1 "] { + let parsed = parse_args(&args(&[ + "--operation", "draft-pr", + "--config", "a.toml", + "--open-draft-prs", raw, + ])); + assert!(parsed.is_err(), "{raw:?} が引数不正として弾かれない"); + } } #[test] diff --git a/src/cli-fix-push-gate/src/main.rs b/src/cli-fix-push-gate/src/main.rs index c6a887a2..20e34684 100644 --- a/src/cli-fix-push-gate/src/main.rs +++ b/src/cli-fix-push-gate/src/main.rs @@ -116,8 +116,10 @@ fn run(args: Vec) -> i32 { }; let external = sources::read_external_raw(); let autonomy = lib_autonomy_policy::evaluate(GateInputs { - repo_config_enabled: sources::read_repo_config_enabled(&cli.config_path), + repo_config_enabled: sources::read_repo_config(&cli.config_path).enabled, external_raw: external.as_deref(), + open_draft_prs: None, + max_open_draft_prs: None, operation: Operation::FixPush, }); let facts = GateFacts { diff --git a/src/lib-autonomy-policy/src/decision.rs b/src/lib-autonomy-policy/src/decision.rs index c44cd7a7..90700793 100644 --- a/src/lib-autonomy-policy/src/decision.rs +++ b/src/lib-autonomy-policy/src/decision.rs @@ -15,13 +15,14 @@ const RAW_VALUE_LOG_CAP: usize = 32; /// 自律 actor が実行しようとしている操作クラス (ADR-052 原則 2 の自動実行可クラス内訳)。 /// -/// ADR-052 原則 5 の契約は背圧の接続も自動実行可の前提条件とするが、背圧の指標は操作クラス -/// ごとに異なる。よって「背圧が接続済みか」は本 enum の性質として持たせる。 +/// ADR-052 原則 5 の契約は背圧の接続も自動実行可の前提条件とするが、背圧の**指標**は操作 +/// クラスごとに異なる。よって「どの指標を要求するか」だけを本 enum が持ち、指標の実測値は +/// [`GateInputs`] から受け取る。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Operation { /// 既存 PR ブランチへの fix push。背圧は cli-pr-monitor の有界 retry (max_retries) が担う。 FixPush, - /// draft PR 作成。背圧の指標は「未マージ draft 数」で、WP-18 まで未接続。 + /// draft PR 作成。背圧の指標は「未マージ draft 数」(ADR-071)。 DraftPr, } @@ -41,17 +42,16 @@ impl Operation { } } - /// 本操作クラスの背圧が接続済みか (ADR-052 原則 5 の契約)。 + /// 本操作クラスが「未マージ draft 数」の背圧を要求するか (ADR-052 原則 5 / ADR-071)。 /// - /// `DraftPr` が `false` 固定なのは未実装の placeholder ではなく、**現時点の正しい - /// fail-closed 状態**である。未マージ draft 数の背圧 (WP-18) が入るまで draft PR の - /// 自動作成を構造的に禁止し、「kill-switch だけ有効化して draft の山を積む」経路を塞ぐ。 - /// WP-18 で背圧を実装する PR がここを `true` へ反転させる。 - fn backpressure_connected(self) -> bool { - match self { - Operation::FixPush => true, - Operation::DraftPr => false, - } + /// `FixPush` が `false` なのは背圧が不要だからではない。fix push の背圧は cli-pr-monitor の + /// 有界 retry (`max_retries`) が担っており、**呼び出しごとに gate へ渡す状態を持たない** + /// ため、判定入力としては現れない。draft 数を入力として要求するのは `DraftPr` だけ。 + /// + /// 背圧の「状態」(実測 draft 数・閾値) はここには持たない。持たせると [`GateInputs`] の + /// 入力と二重管理になり、判定経路が分岐する (ADR-071 § 決定 2)。 + fn requires_draft_backpressure(self) -> bool { + matches!(self, Operation::DraftPr) } } @@ -63,6 +63,13 @@ pub struct GateInputs<'a> { pub repo_config_enabled: Option, /// 外部フラグ (CI variable → env / ローカル env) の生値。`None` = 未設定。 pub external_raw: Option<&'a str>, + /// 未マージ draft PR (`claude/` prefix) の実測件数。`None` = 未取得 / 取得失敗 (= 停止)。 + /// + /// 取得は呼び手 (workflow step の `gh api`) の責務。`0` と `None` は意味が異なる — + /// `0` は「数えた結果 0 件」、`None` は「数えられなかった」で、後者は deny に倒れる。 + pub open_draft_prs: Option, + /// `autonomy-config.toml` の `[autonomy] max_open_draft_prs`。`None` = 読めない (= 停止)。 + pub max_open_draft_prs: Option, pub operation: Operation, } @@ -77,8 +84,10 @@ pub enum DenyReason { RepoConfigUnavailable, /// repo config が明示的に `enabled = false`。 RepoConfigDisabled, - /// 操作クラスの背圧が未接続。 + /// 操作クラスが要求する背圧の指標を読めない (実測値 / 閾値のいずれかが欠落)。 BackpressureUnavailable(Operation), + /// 背圧が飽和した = 未マージ draft が閾値に達している (自主減速)。 + BackpressureSaturated { open: u32, limit: u32 }, } impl DenyReason { @@ -98,9 +107,14 @@ impl DenyReason { format!("{config_path} で [autonomy] enabled = false が指定されています") } DenyReason::BackpressureUnavailable(op) => format!( - "操作クラス {} の背圧が未接続です (ADR-052 原則 5 の契約により停止)", + "操作クラス {} の背圧を読めません (未マージ draft 数の実測値または {config_path} の \ +[autonomy] max_open_draft_prs が欠落。ADR-052 原則 5 の契約により停止)", op.as_str() ), + DenyReason::BackpressureSaturated { open, limit } => format!( + "未マージ draft PR が {open} 件で閾値 {limit} 件に達しています (自主減速。\ +draft をマージ / クローズするか {config_path} の max_open_draft_prs を見直してください)" + ), } } @@ -112,6 +126,7 @@ impl DenyReason { DenyReason::RepoConfigUnavailable => "repo-config-unavailable", DenyReason::RepoConfigDisabled => "repo-config-disabled", DenyReason::BackpressureUnavailable(_) => "backpressure-unavailable", + DenyReason::BackpressureSaturated { .. } => "backpressure-saturated", } } } @@ -129,6 +144,9 @@ pub enum Decision { /// 緊急停止で最初に操作される面 (CI variable) だからで、drill 時の deny 理由が操作クラスに /// 依らず一定になる。全ソースの状態は [`describe_sources`] が別途 loud 出力するため、 /// 先頭理由だけを返しても診断情報は失われない。 +/// +/// 背圧の飽和判定が `>=` であって `>` ではないのは、閾値が「これ以上は積まない」上限だから。 +/// `limit = 0` は「draft を 1 件も作らない」= 実質停止を意味する (ADR-071 § 決定 3)。 pub fn evaluate(inputs: GateInputs<'_>) -> Decision { match inputs.external_raw { None => return Decision::Denied(DenyReason::ExternalUnset), @@ -142,8 +160,13 @@ pub fn evaluate(inputs: GateInputs<'_>) -> Decision { Some(false) => return Decision::Denied(DenyReason::RepoConfigDisabled), Some(true) => {} } - if !inputs.operation.backpressure_connected() { - return Decision::Denied(DenyReason::BackpressureUnavailable(inputs.operation)); + if inputs.operation.requires_draft_backpressure() { + let (Some(open), Some(limit)) = (inputs.open_draft_prs, inputs.max_open_draft_prs) else { + return Decision::Denied(DenyReason::BackpressureUnavailable(inputs.operation)); + }; + if open >= limit { + return Decision::Denied(DenyReason::BackpressureSaturated { open, limit }); + } } Decision::Allowed } @@ -163,17 +186,30 @@ pub fn describe_sources(inputs: GateInputs<'_>, env_name: &str) -> String { Some(true) => "enabled", Some(false) => "disabled", }; - let backpressure = if inputs.operation.backpressure_connected() { - "connected" - } else { - "unavailable" - }; format!( - "{env_name}={external} repo_config={repo_config} backpressure({})={backpressure}", - inputs.operation.as_str() + "{env_name}={external} repo_config={repo_config} backpressure({})={}", + inputs.operation.as_str(), + describe_backpressure(inputs) ) } +/// 背圧 1 ソース分の状態表記。 +/// +/// `structural` は「この操作クラスは draft 数を入力として要求しない」を意味する +/// (fix push は cli-pr-monitor の有界 retry が背圧を担う)。draft 数を要求するクラスでは +/// 実測値と閾値を必ず併記し、「止まったのは数え損ねか、それとも積み過ぎか」を run log +/// 1 行で切り分けられるようにする。 +fn describe_backpressure(inputs: GateInputs<'_>) -> String { + if !inputs.operation.requires_draft_backpressure() { + return "structural".to_string(); + } + match (inputs.open_draft_prs, inputs.max_open_draft_prs) { + (Some(open), Some(limit)) if open >= limit => format!("saturated({open}/{limit})"), + (Some(open), Some(limit)) => format!("ok({open}/{limit})"), + _ => "unavailable".to_string(), + } +} + /// 生値を診断用に切り詰める。切り詰めた場合は省略記号を付けて全量でないことを明示する。 fn truncate_for_log(raw: &str) -> String { let cleaned: String = raw @@ -197,6 +233,7 @@ mod tests { /// truthy でない表記。空文字・0・false に加え、解釈不能なゴミ値を含む。 const NOT_TRUTHY: &[&str] = &["", "0", "false", "off", "no", "enabled", "2", "1"]; + /// 背圧入力を接続しない既定形。`DraftPr` はこの形では常に deny になる。 fn inputs<'a>( repo_config_enabled: Option, external_raw: Option<&'a str>, @@ -205,6 +242,23 @@ mod tests { GateInputs { repo_config_enabled, external_raw, + open_draft_prs: None, + max_open_draft_prs: None, + operation, + } + } + + /// kill-switch 2 面を有効にしたうえで背圧入力だけを差し替える。 + fn with_backpressure<'a>( + operation: Operation, + open_draft_prs: Option, + max_open_draft_prs: Option, + ) -> GateInputs<'a> { + GateInputs { + repo_config_enabled: Some(true), + external_raw: Some("true"), + open_draft_prs, + max_open_draft_prs, operation, } } @@ -253,35 +307,116 @@ mod tests { ); } - /// 背圧未接続の操作クラスは、kill-switch が両面とも有効でも通さない。 + /// 背圧入力が 1 つでも欠ければ、kill-switch が両面とも有効でも draft PR は通さない。 + /// 実測値と閾値のどちらが欠けても同じ deny に倒れることを固定する (ADR-052 原則 5)。 + #[test] + fn denies_draft_pr_when_any_backpressure_input_is_missing() { + for (open, limit) in [(None, None), (Some(0), None), (None, Some(3))] { + assert_eq!( + evaluate(with_backpressure(Operation::DraftPr, open, limit)), + Decision::Denied(DenyReason::BackpressureUnavailable(Operation::DraftPr)), + "open={open:?} limit={limit:?} は背圧未接続として deny でなければならない" + ); + } + } + + /// 閾値ちょうどで止まる (`>` ではなく `>=`)。境界の off-by-one を pin する。 + #[test] + fn draft_pr_stops_at_the_limit_not_after_it() { + assert_eq!( + evaluate(with_backpressure(Operation::DraftPr, Some(2), Some(3))), + Decision::Allowed + ); + assert_eq!( + evaluate(with_backpressure(Operation::DraftPr, Some(3), Some(3))), + Decision::Denied(DenyReason::BackpressureSaturated { open: 3, limit: 3 }) + ); + assert_eq!( + evaluate(with_backpressure(Operation::DraftPr, Some(9), Some(3))), + Decision::Denied(DenyReason::BackpressureSaturated { open: 9, limit: 3 }) + ); + } + + /// `limit = 0` は「draft を 1 件も作らない」= 実質停止。0 件でも通さない。 + #[test] + fn zero_limit_denies_every_draft_pr() { + assert_eq!( + evaluate(with_backpressure(Operation::DraftPr, Some(0), Some(0))), + Decision::Denied(DenyReason::BackpressureSaturated { open: 0, limit: 0 }) + ); + } + + /// fix push の背圧は cli-pr-monitor の有界 retry が担うため、draft 数の飽和では止めない。 + /// 「draft が溜まったら fix push も止まる」という意図しない結合が入っていないことを固定する。 + #[test] + fn fix_push_is_unaffected_by_draft_backpressure_inputs() { + for (open, limit) in [(None, None), (Some(99), Some(1)), (Some(0), Some(3))] { + assert_eq!( + evaluate(with_backpressure(Operation::FixPush, open, limit)), + Decision::Allowed, + "open={open:?} limit={limit:?} で fix-push が止まってはならない" + ); + } + } + + /// 背圧が通っても kill-switch 2 面は依然として先に効く (判定順の固定)。 #[test] - fn denies_draft_pr_until_backpressure_lands() { + fn backpressure_never_overrides_the_kill_switch() { + let saturated_but_switched_off = GateInputs { + repo_config_enabled: Some(false), + external_raw: Some("true"), + open_draft_prs: Some(0), + max_open_draft_prs: Some(3), + operation: Operation::DraftPr, + }; assert_eq!( - evaluate(inputs(Some(true), Some("true"), Operation::DraftPr)), - Decision::Denied(DenyReason::BackpressureUnavailable(Operation::DraftPr)) + evaluate(saturated_but_switched_off), + Decision::Denied(DenyReason::RepoConfigDisabled) ); } /// 全組み合わせを走査し、「許可されるのは 3 条件が揃った場合だけ」を網羅的に固定する。 #[test] - fn allow_is_exhaustively_limited_to_the_single_all_connected_combination() { + fn allow_is_exhaustively_limited_to_the_fully_connected_combinations() { let externals: Vec> = std::iter::once(None) .chain(TRUTHY.iter().chain(NOT_TRUTHY.iter()).map(|s| Some(*s))) .collect(); + let backpressures: [(Option, Option); 4] = + [(None, None), (Some(0), None), (Some(0), Some(3)), (Some(3), Some(3))]; let mut allowed_count = 0; for op in [Operation::FixPush, Operation::DraftPr] { for repo in [None, Some(true), Some(false)] { for external in &externals { - let allowed = evaluate(inputs(repo, *external, op)) == Decision::Allowed; - let expected = repo == Some(true) - && external.is_some_and(lib_telemetry::is_truthy) - && op == Operation::FixPush; - assert_eq!(allowed, expected, "op={op:?} repo={repo:?} ext={external:?}"); - allowed_count += usize::from(allowed); + for (open, limit) in backpressures { + let allowed = evaluate(GateInputs { + repo_config_enabled: repo, + external_raw: *external, + open_draft_prs: open, + max_open_draft_prs: limit, + operation: op, + }) == Decision::Allowed; + let backpressure_ok = op == Operation::FixPush + || matches!((open, limit), (Some(o), Some(l)) if o < l); + let expected = repo == Some(true) + && external.is_some_and(lib_telemetry::is_truthy) + && backpressure_ok; + assert_eq!( + allowed, expected, + "op={op:?} repo={repo:?} ext={external:?} open={open:?} limit={limit:?}" + ); + allowed_count += usize::from(allowed); + } } } } - assert_eq!(allowed_count, TRUTHY.len(), "許可される組み合わせ数が想定外"); + let fix_push_allows_every_backpressure = backpressures.len(); + let draft_pr_allows_only_the_unsaturated_one = 1; + assert_eq!( + allowed_count, + TRUTHY.len() + * (fix_push_allows_every_backpressure + draft_pr_allows_only_the_unsaturated_one), + "許可される組み合わせ数が想定外" + ); } #[test] @@ -292,6 +427,22 @@ mod tests { assert!(line.contains("backpressure(draft-pr)=unavailable"), "{line}"); } + /// 背圧の 3 状態が実測値付きで出ること。deny 行だけで「数え損ね」と「積み過ぎ」を + /// 切り分けられるかは、この表記が実数を含むかに依存する。 + #[test] + fn describe_sources_distinguishes_backpressure_states() { + let describe = |open, limit, op| { + describe_sources(with_backpressure(op, open, limit), "AUTONOMY_ENABLED") + }; + assert!(describe(Some(1), Some(3), Operation::DraftPr).contains("backpressure(draft-pr)=ok(1/3)")); + assert!(describe(Some(3), Some(3), Operation::DraftPr) + .contains("backpressure(draft-pr)=saturated(3/3)")); + assert!(describe(None, Some(3), Operation::DraftPr) + .contains("backpressure(draft-pr)=unavailable")); + assert!(describe(Some(9), Some(1), Operation::FixPush) + .contains("backpressure(fix-push)=structural")); + } + #[test] fn long_and_control_raw_values_are_truncated_and_sanitized() { let raw = format!("{}\nTAIL", "x".repeat(RAW_VALUE_LOG_CAP)); @@ -315,5 +466,18 @@ mod tests { let reason = DenyReason::ExternalNotTruthy("secret-ish".to_string()); assert_eq!(reason.code(), "external-not-truthy"); assert!(!reason.code().contains("secret")); + assert_eq!( + DenyReason::BackpressureSaturated { open: 3, limit: 3 }.code(), + "backpressure-saturated" + ); + } + + /// 飽和の説明文は実数と復旧手段を含む。run log だけで次の操作が決まるようにする。 + #[test] + fn saturated_description_names_the_numbers_and_the_remedy() { + let text = DenyReason::BackpressureSaturated { open: 4, limit: 3 } + .describe("AUTONOMY_ENABLED", "autonomy-config.toml"); + assert!(text.contains('4') && text.contains('3'), "{text}"); + assert!(text.contains("max_open_draft_prs"), "{text}"); } } diff --git a/src/lib-autonomy-policy/src/lib.rs b/src/lib-autonomy-policy/src/lib.rs index 4244d648..f1939d7f 100644 --- a/src/lib-autonomy-policy/src/lib.rs +++ b/src/lib-autonomy-policy/src/lib.rs @@ -22,4 +22,4 @@ pub mod decision; pub mod sources; pub use decision::{evaluate, describe_sources, Decision, DenyReason, GateInputs, Operation}; -pub use sources::{read_external_raw, read_repo_config_enabled, EXTERNAL_ENV}; +pub use sources::{read_external_raw, read_repo_config, RepoConfig, EXTERNAL_ENV}; diff --git a/src/lib-autonomy-policy/src/sources.rs b/src/lib-autonomy-policy/src/sources.rs index 0769e5aa..8447b0b3 100644 --- a/src/lib-autonomy-policy/src/sources.rs +++ b/src/lib-autonomy-policy/src/sources.rs @@ -29,19 +29,47 @@ struct AutonomyConfigFile { #[derive(serde::Deserialize)] struct AutonomySection { enabled: Option, + max_open_draft_prs: Option, } -/// `[autonomy] enabled` を読む。読めない一切のケースは `None`。 +/// `[autonomy]` から読めた値。各フィールドの `None` は「読めなかった」= 停止側。 +/// +/// 型として 1 つにまとめてあるのは、呼び手にファイルを 2 回読ませないため。読み取りを +/// 分けると、2 回の read の間に config が差し替わった場合に「kill-switch は旧世代・閾値は +/// 新世代」という混成状態で判定しうる。1 回の read から派生した値だけを使う。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RepoConfig { + /// `[autonomy] enabled` (kill-switch のリポジトリ側の面)。 + pub enabled: Option, + /// `[autonomy] max_open_draft_prs` (draft PR 背圧の閾値、ADR-071)。 + pub max_open_draft_prs: Option, +} + +/// `[autonomy]` section を読む。読めない一切のケースは全フィールド `None`。 +/// +/// toml の parse は section 単位ではなくファイル単位なので、**どれか 1 つのキーが型違いだと +/// 全フィールドが `None` に倒れる**。これは意図した挙動で、config の一部が壊れている状態を +/// 「半分だけ有効」で運転させない (ADR-043 fail-closed)。 /// /// **CI から呼ぶ場合、`path` は master ref から取り出した写しでなければならない。** /// PR ブランチの checkout をそのまま渡すと、自律 actor 自身 (または injection を受けた /// fix エージェント) が `claude/` ブランチ上で config を書き換えて自己解除できる /// (ADR-054 が塞いだ信頼境界と同型)。本 exe はパスの出所を検証できないため、これは /// 呼び手の契約であり、履行の監査は deny/allow 行に出る `config=` の実パスで行う。 -pub fn read_repo_config_enabled(path: &Path) -> Option { +pub fn read_repo_config(path: &Path) -> RepoConfig { + let Some(section) = read_autonomy_section(path) else { + return RepoConfig::default(); + }; + RepoConfig { + enabled: section.enabled, + max_open_draft_prs: section.max_open_draft_prs, + } +} + +fn read_autonomy_section(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; let parsed: AutonomyConfigFile = toml::from_str(&content).ok()?; - parsed.autonomy?.enabled + parsed.autonomy } /// 外部フラグの生値。未設定 / 非 UTF-8 は `None` (= 停止)。 @@ -62,51 +90,103 @@ mod tests { (dir, path) } + fn enabled_of(body: &str) -> Option { + let (_dir, path) = config_with(body); + read_repo_config(&path).enabled + } + + fn limit_of(body: &str) -> Option { + let (_dir, path) = config_with(body); + read_repo_config(&path).max_open_draft_prs + } + #[test] fn reads_explicit_boolean_values() { - let (_dir, path) = config_with("[autonomy]\nenabled = true\n"); - assert_eq!(read_repo_config_enabled(&path), Some(true)); - let (_dir, path) = config_with("[autonomy]\nenabled = false\n"); - assert_eq!(read_repo_config_enabled(&path), Some(false)); + assert_eq!(enabled_of("[autonomy]\nenabled = true\n"), Some(true)); + assert_eq!(enabled_of("[autonomy]\nenabled = false\n"), Some(false)); } #[test] - fn missing_file_is_none() { + fn reads_the_draft_backpressure_threshold() { + assert_eq!( + limit_of("[autonomy]\nenabled = true\nmax_open_draft_prs = 3\n"), + Some(3) + ); + assert_eq!( + limit_of("[autonomy]\nenabled = true\nmax_open_draft_prs = 0\n"), + Some(0) + ); + } + + /// 閾値キーが無ければ `None` = 背圧未接続 = draft PR は deny。既定値へ倒さない + /// (「書き忘れたら 3 件まで自動で作る」という fail-open を作らないため)。 + #[test] + fn missing_threshold_key_is_none_not_a_default() { + assert_eq!(limit_of("[autonomy]\nenabled = true\n"), None); + } + + #[test] + fn missing_file_is_all_none() { let dir = tempfile::tempdir().expect("tempdir"); assert_eq!( - read_repo_config_enabled(&dir.path().join("absent.toml")), - None + read_repo_config(&dir.path().join("absent.toml")), + RepoConfig::default() ); } #[test] fn missing_section_or_key_is_none() { - let (_dir, path) = config_with("[other]\nvalue = 1\n"); - assert_eq!(read_repo_config_enabled(&path), None); - let (_dir, path) = config_with("[autonomy]\n"); - assert_eq!(read_repo_config_enabled(&path), None); + assert_eq!(enabled_of("[other]\nvalue = 1\n"), None); + assert_eq!(enabled_of("[autonomy]\n"), None); } #[test] - fn malformed_toml_is_none() { + fn malformed_toml_is_all_none() { let (_dir, path) = config_with("[autonomy\nenabled = true\n"); - assert_eq!(read_repo_config_enabled(&path), None); + assert_eq!(read_repo_config(&path), RepoConfig::default()); } /// bool 以外の型で書かれた `enabled` は parse 失敗 → `None` (= 停止)。 /// 「`enabled = "true"` と書いたのに有効にならない」は fail-closed として正しい挙動。 #[test] fn non_boolean_enabled_is_none() { - let (_dir, path) = config_with("[autonomy]\nenabled = \"true\"\n"); - assert_eq!(read_repo_config_enabled(&path), None); - let (_dir, path) = config_with("[autonomy]\nenabled = 1\n"); - assert_eq!(read_repo_config_enabled(&path), None); + assert_eq!(enabled_of("[autonomy]\nenabled = \"true\"\n"), None); + assert_eq!(enabled_of("[autonomy]\nenabled = 1\n"), None); + } + + /// 閾値の型違い (文字列 / 負値 / 小数) は section 全体の parse を失敗させ、 + /// **kill-switch 側の `enabled` も** `None` へ倒す。config が半壊した状態で + /// 「kill-switch だけ有効」と読ませないための挙動を明示的に固定する。 + #[test] + fn malformed_threshold_also_disables_the_kill_switch_flag() { + for body in [ + "[autonomy]\nenabled = true\nmax_open_draft_prs = \"3\"\n", + "[autonomy]\nenabled = true\nmax_open_draft_prs = -1\n", + "[autonomy]\nenabled = true\nmax_open_draft_prs = 1.5\n", + ] { + let (_dir, path) = config_with(body); + assert_eq!( + read_repo_config(&path), + RepoConfig::default(), + "半壊 config が部分的に有効と読まれた: {body}" + ); + } } - /// ディレクトリを指された場合も read に失敗して `None`。 + /// ディレクトリを指された場合も read に失敗して全 `None`。 #[test] - fn directory_path_is_none() { + fn directory_path_is_all_none() { let dir = tempfile::tempdir().expect("tempdir"); - assert_eq!(read_repo_config_enabled(dir.path()), None); + assert_eq!(read_repo_config(dir.path()), RepoConfig::default()); + } + + /// 未知のキーは無視する。config へ新しい設定を足しても、旧バイナリが + /// 「parse 失敗 → 全停止」に倒れないようにするため。 + #[test] + fn unknown_keys_are_ignored() { + assert_eq!( + enabled_of("[autonomy]\nenabled = true\nfuture_knob = \"x\"\n"), + Some(true) + ); } }