diff --git a/.gitignore b/.gitignore index 90a1af19..2a67f0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,9 +48,10 @@ src/*/Cargo.lock .claude/weekly-review-pending.json .claude/weekly-review-deferred.json -# WP-12 / ADR-055: 発火テレメトリ収集層 (lib-telemetry) の JSONL。ローカル運用データ (内部 -# artifact)。per-process/per-day partition (firings--.jsonl)。ROI 棚卸しの -# 集計は後続 PR (WP-12 step 2)。 +# WP-12 / ADR-055: テレメトリ収集層 (lib-telemetry) の JSONL。ローカル運用データ (内部 +# artifact)。per-process/per-day partition。firings-<...>.jsonl = hook 発火 (ROI 棚卸し集計は +# WP-12 step 2)。push-runs-<...>.jsonl = push パイプライン per-run メトリクス (R3、ADR-055 +# amendment。ADR-057/058 判定と after 計測の集計元)。 .claude/telemetry/ # ADR-030: takt workflow への入力 (cli-merge-pipeline が生成、workflow が読む) diff --git a/Cargo.lock b/Cargo.lock index f791b5f6..523693cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,7 @@ dependencies = [ "lib-docs-policy", "lib-jj-helpers", "lib-subprocess", + "lib-telemetry", "serde", "serde_json", "tempfile", diff --git a/docs/adr/adr-055-firing-telemetry-collection.md b/docs/adr/adr-055-firing-telemetry-collection.md index 2ec09050..004696f8 100644 --- a/docs/adr/adr-055-firing-telemetry-collection.md +++ b/docs/adr/adr-055-firing-telemetry-collection.md @@ -156,6 +156,47 @@ decision trigger: 層 1 の「2 つ目の使用例待ち」トリガに到達したため、将来 3 crate 目が現れたら中立 crate (例 `lib-time`) への抽出候補とする +## Amendment (2026-07-18): push-run メトリクス record kind (R3、todo 順位 325) + +本 ADR のスコープは hook 発火イベント (`firings-*.jsonl`) だが、push パイプラインの +per-run メトリクス永続化 (push-pipeline-fix-plan2 §3 R3) が同じ器を必要としたため、 +**別 record kind** として相乗りさせる。判断と設計は以下。 + +### 別 record kind (別ファイル) を採用 — amendment ではなくスキーマ分離 + +`firings-*.jsonl` は WP-12 step 2 の集計が glob 走査する前提で、行の shape (hook/kind/id/ +decision) が固定されている。push-run メトリクスは shape が全く異なる (stage 別 elapsed の +object・exit_code・os 等) ため、同一ファイルに混ぜると firing 集計を壊す。よって +**`push-runs--.jsonl` という別 prefix** に書き、firing 集計と分離する。 +opt-in (`[telemetry] enabled`) / kill-switch (`CLAUDE_TELEMETRY_DISABLE`) / fail-open / +per-pid×日次 partition / `WRITE_LOCK` 直列化の既存原則には相乗りする (グローバル telemetry +スイッチ 1 つで両 record kind を統べる)。 + +### 責務分離 — lib-telemetry はドメイン中立の器のまま + +push-run 固有スキーマ (`RunRecord`) は消費側 `cli-push-runner` (`src/metrics.rs`) が保持し、 +lib-telemetry には**任意 `Serialize` を prefix 付き partition へ書く汎用 writer** +(`record_metric` / `record_metric_to` / `record_metric_gated_to`) だけを追加した。これにより +「観測層を特定ドメインに結合させない」という本 ADR の設計思想 (§欠点 の UTC ヘルパー複製の +論点と同根) を保つ。`ts` は writer が書き込み時に差し込むため、UTC ヘルパーの消費者は +lib-telemetry 内に留まり、§欠点 の「3 crate 目で lib-time 抽出」トリガには到達しない。 + +### 記録フィールドとプライバシー + +os / exit_code / total_secs / docs_only / skipped_groups / post_takt_regate 判定 +(skip / run-pass / block を区別) / takt_workflow / bookmarks / stage 別 elapsed。**メタデータ +のみ**の原則 (§プライバシー) は維持する。bookmark 名は branch 識別子 (= メタデータ、 +session_id と同性質) であり、ファイルパス・コマンド本文ではないため run の識別鍵として載せる。 + +**deferred (別 PR、完了基準の必須外)**: takt run slug (`run_cmd_inherit` が takt 出力を +捕捉しないため `.takt/runs/` との join 鍵を clean に取得できない)・pr_size 行数 +(`run_pr_size_check` が総行数を返さない)。 + +### 消費者 + +ADR-057 / ADR-058 の採否判定 (期限 2026-08-15) と R5/R6 の after 計測。これらの効果検証が +「push 時コンソール出力の手動保存」に依存していたのを、機械集計可能な JSONL に置き換える。 + ## 関連 ADR - [ADR-039](adr-039-experimental-feature-standard-pattern.md) — 試験運用標準パターン (opt-in / kill-switch / bounded lifetime) diff --git a/docs/push-pipeline-fix-plan2.md b/docs/push-pipeline-fix-plan2.md index d55298a8..99a03840 100644 --- a/docs/push-pipeline-fix-plan2.md +++ b/docs/push-pipeline-fix-plan2.md @@ -33,6 +33,7 @@ cli-pr-monitor) の遅延 (コード変更 push 最大 14.6 分) と不具合の | T12 | #289 | fix 後の決定論再ゲート `post_takt_regate` + fix.md 自己検証義務の縮小 (ADR-058、判定期限 8/15) | | T13 | #290 + 判定 | backlog 13 項目の処置確定: **採用 2 (→ R1/R2)**、todo 移管 3 (項目 9→順位 324 / 項目 10→順位 323 / 項目 12→既存順位 16)、却下 6 (項目 2/4/5/6/8/11)、条件付き却下 2 (項目 7/13、再評価トリガー付き)。判断根拠は旧計画 §6/§8 | | R1 | #292 | quality_gate 失敗 step の出力を全量表示 (40 行 truncate 除去。成功経路は cap 維持 = T5「失敗経路は診断を落とさない」の残り半分。ADR-049 流の回帰テスト 2 本追加) | +| R2 | #293 | loop_monitor stall-detection judge を sonnet → haiku 化 (2 択 routing のみ、post-pr-review.yaml に前例)。pre-push-review + refute 両 yaml を同期変更 (片方だけは効果ゼロ = T10 の罠、原則 6)。ADR-047 配下 | ### 実測の現在地 (2026-07-18 時点) @@ -47,7 +48,9 @@ cli-pr-monitor) の遅延 (コード変更 push 最大 14.6 分) と不具合の - **計測方法 (永続データ)**: `.takt/runs//meta.json` の startTime/endTime と `trace.md` の iteration ヘッダ (takt 部分・fix 発生の判定)。refute run の抽出は `meta.json` の `"piece": "pre-push-review-refute"` 基準 (run ディレクトリ名では判別不可、ADR-047 に記載)。 -- **stage 別 (gate/push 等) は T0 ログが stderr のみで非永続** — R3 (順位 325) が解消する。 +- **stage 別 (gate/push 等)** は T0 ログが stderr のみで非永続だったが、**R3 で per-run JSONL + (`.claude/telemetry/push-runs-*.jsonl`) に永続化**した (実装済み・未 push)。次回 push 以降が + 自動的に集計コーパスになる。 - ⚠ **旧計画 §8 の目標「docs-only push 1 分台」は総時間としては達成不能の見込み**: T11 で「docs-only でも takt レビューは skip しない」(docs の事実誤り検出実績があるため) を ユーザー承認済みであり、takt が支配項として残る。R6 の after 計測で目標を gate 部分 @@ -103,14 +106,14 @@ cli-pr-monitor) の遅延 (コード変更 push 最大 14.6 分) と不具合の - **§1 表に R1 行 (#292) を追加済み** (2026-07-18 マージ完了に伴い backfill)。未 push だった間は §1 (「全 PR マージ済み」スナップショット) に載せず §3 本欄を完了記録としていた。 -### R2: loop_monitor judge の haiku 化 (T13 項目 3 採用分) — XS **【実装済み・未 push, 2026-07-18】** +### R2: loop_monitor judge の haiku 化 (T13 項目 3 採用分) — XS **【マージ済み #293, 2026-07-18】** - **内容**: loop_monitor の `judge.model: sonnet` → `haiku` (2 択判定のみ。post-pr-review.yaml に前例)。 - **対象**: `pre-push-review.yaml` と `pre-push-review-refute.yaml` の**両方** (原則 6 参照。 片方だけ変えると効果ゼロで気付けない = T10 で実際に起きた罠)。 - **受け入れ基準**: 実 push で judge が haiku で完走。fix iteration 発生 run での遷移時間を - 記録できれば尚可 (R3 未実装の間は push ログの手動保存)。 -- **実施結果 (2026-07-18, 実装済み / 未 push)**: + 記録できれば尚可 (遷移時間は R3 (#294) が per-run JSONL で永続化)。 +- **実施結果 (2026-07-18, マージ済み #293)**: - **方針**: loop_monitor の stall-detection judge を `sonnet` → `haiku`。judge は cycle が threshold (2) 回反復した時に `Healthy → reviewers(refute 側も同じ) / Unproductive → supervise` の **2 択 routing** を返すだけで、コード読解や修正判断を伴わない。haiku で十分という前例は @@ -133,21 +136,85 @@ cli-pr-monitor) の遅延 (コード変更 push 最大 14.6 分) と不具合の `reportContent is required` は実 report 本文を要さない dry preview 固有の制約で、yaml 不正 ではない)。両ファイルの `loop_monitors[0].judge.model` が `haiku` であることも確認済み。 - **受け入れ基準の充足度 (未検証事項として記録)**: 基準「実 push で judge が haiku で完走」は - 本 work unit のスコープ (commit まで) 外。judge は **loop_monitor が cycle 停滞を検出した - 時のみ fire** する = fix iteration が 1 回以上発生する run でしか起動しないため、fix なし run - (§1 実測では T10 後 0/4 が fix なし) では judge 自体が呼ばれない。よって「haiku 完走」の - 実証は fix 発生 run が出た時の push ログ手動保存 (R3 未実装のため) に持ち越す。 - - **§1 表への行追加と PR 番号 backfill は push/マージ時に実施** (R1 と同じ扱い。§1 は - 「全 PR マージ済み」のスナップショットのため、未 push の本タスクは §3 本欄で完了記録とする)。 - -### R3: push per-run メトリクスの JSONL 永続化 (todo 順位 325) — S - -- **内容**: run 終了時に stage 別 elapsed / docs_only 判定 / post_takt_regate 判定 / - pr_size 行数 / takt run slug / total / exit code / os を 1 行 JSONL で `.claude/telemetry/` へ - append。lib-telemetry (ADR-055) を再利用。**詳細仕様と作業計画は docs/todo13.md 順位 325 が canonical**。 + judge が **loop_monitor の cycle 停滞検出時のみ fire** する = fix iteration が 1 回以上発生する + run でしか起動しないため、fix なし run (§1 実測では T10 後 0/4 が fix なし) では judge 自体が + 呼ばれない。マージ (#293) 後もまだ fix 発生 run が出ておらず「haiku 完走」の実証は次の fix + 発生 run 待ち。遷移時間の記録 (尚可) は R3 (#294) が per-run stage timing を永続化したため + コンソール手動保存に依存しなくなった (judge 発火の詳細は従来どおり `.takt/runs//trace.md`)。 + - **§1 表に R2 行 (#293) を追加済み** (2026-07-18 マージ完了に伴い backfill)。未 push だった間は + §1 (「全 PR マージ済み」スナップショット) に載せず §3 本欄を完了記録としていた。 + +### R3: push per-run メトリクスの JSONL 永続化 (todo 順位 325) — S **【実装済み・未 push, 2026-07-18】** + +- **内容 (当初案の全フィールド)**: run 終了時に stage 別 elapsed / docs_only 判定 / + post_takt_regate 判定 / pr_size 行数 / takt run slug / total / exit code / os を 1 行 JSONL で + `.claude/telemetry/` へ append。lib-telemetry (ADR-055) を再利用。**詳細仕様と作業計画は + docs/todo13.md 順位 325 が canonical**。⚠ **このうち `pr_size 行数` と `takt run slug` は実装で + deferred** (下記実施結果 / ADR-055 amendment 参照)。実際に永続化される schema は実施結果の + 「記録フィールド」を正とすること (集計利用者が存在しないフィールドを期待しないため)。 - **位置付け**: R5/R6 の計測基盤。harness-improvement-plan セクション 3 (Linux 対応) 着手前に 入れると、同作業の push (8〜15 回見込み) が自動的に after 計測コーパスになる。 -- **完了時**: todo13.md エントリ + todo-summary.md 順位 325 行を削除 (todo 側の運用に従う)。 +- **実施結果 (2026-07-18, 実装済み / 未 push)**: + - **スキーマ判定 (canonical の「ADR-055 amendment か別 record kind か」)**: **別 record kind = + 別ファイル `push-runs--.jsonl`** を採用。firing (`firings-*.jsonl`) は + WP-12 step 2 の集計が glob 走査する前提で shape 固定のため、shape の異なる push-run 行を + 混ぜると firing 集計を壊す。opt-in (`[telemetry] enabled`) / kill-switch + (`CLAUDE_TELEMETRY_DISABLE`) / fail-open / per-pid×日次 partition / `WRITE_LOCK` の既存原則 + には相乗り (グローバル telemetry スイッチ 1 つで両 record kind を統べる)。ADR-055 に + amendment section を追記した。 + - **責務分離**: lib-telemetry には**ドメイン中立の汎用 writer** (`record_metric` / + `record_metric_to` / `record_metric_gated_to`。任意 `Serialize` を prefix 付き partition へ + 書き `ts` を差し込む) のみ追加し、push-run 固有スキーマ `RunRecord` は消費側 + `cli-push-runner/src/metrics.rs` が保持。ADR-055 §欠点 の「観測層を特定ドメインに結合させない」 + 思想を保つ (UTC ヘルパーの消費者も lib-telemetry 内に留まり、lib-time 抽出トリガに未到達)。 + - **記録フィールド**: os / exit_code / total_secs / docs_only (bool) / skipped_groups / + post_takt_regate 判定 (**skip / run-pass / block を区別**: disabled・override_skipped・ + no_change・changed_pass・changed_block・indeterminate_pass・indeterminate_block) / + takt_workflow / bookmarks / stage 別 elapsed (JSON object)。プライバシーはメタデータのみ + (ADR-055 §プライバシー)。bookmark 名は branch 識別子 (session_id と同性質) のため run 識別鍵 + として採用。 + - **deferred (canonical フィールド案のうち本 PR 見送り、完了基準の必須外)**: **takt run slug** + (`run_takt` の `run_cmd_inherit` が takt 出力を捕捉しないため `.takt/runs/` との join 鍵を + clean に取れない)・**pr_size 行数** (`run_pr_size_check` が総行数を返さない)。いずれも + 別 PR 候補。 + - **変更範囲の確認・明記 (完了基準の「main.rs と .claude/telemetry のみが対象」主張の検証)**: + canonical の想定より広く、以下も変更が必要だった。**主張は不正確**と判明: + - `metrics.rs` (新規): 収集 struct `RunMetrics` + `RunRecord` serde。 + - `main.rs`: `run_pipeline` を「メトリクス所有 + 全終了経路で 1 回 write」の薄い wrapper と、 + stage を回す `run_stages` に分割。各 `timed()` を `metrics.timed()` に置換。 + - `log.rs`: 計測を `RunMetrics::timed` に一元化するため free `timed()` を撤去し、stderr + contract 出力を `log_stage_elapsed` に抽出 (T0 の `stage=... elapsed=...s` 書式は不変)。 + → canonical の「log.rs 変更不要」は不成立。bool 戻り値の `timed()` からは elapsed を蓄積 + できず、計測点の一元化 = `RunMetrics::timed` への移設が最小の clean 解だった。 + - `stages/post_takt_regate.rs`: `run_post_takt_regate` の戻り値を bool → `RegateOutcome` + (`decision` + `proceed`) に変更し `RegateDecision` を surface。→ canonical の + 「post_takt_regate 呼び出し元 変更不要」も不成立。bool 単独では「無変更 skip」と + 「変更あり pass」を区別できず、**完了基準が要求する post_takt_regate 判定 (ADR-058 の + skip vs run-pass vs block 信号)** を満たせないため必須の逸脱。 + - `lib-telemetry`: 上記の汎用 writer 追加 (器の再利用の実体)。 + - **テスト**: cli-push-runner 252→256 passed (差引 +4 = metrics module 5 本新規 [timed 戻り値+stage + 記録 / 完了 run の全フィールド / 中断 run exit7 でも書かれる / kill-switch OFF で書かれない / + takt skip 時 workflow 省略] − log.rs の `timed` テスト 1 本撤去。252 は R1/#292 後の基準)。 + lib-telemetry 14→20 passed (+6 = 汎用 writer テスト 4 [ts 差し込み / firing と別ファイル / + gate ON ×1・OFF ×1] + file_prefix path-traversal 検証 2 [CodeRabbit #294 対応])。 + post_takt_regate は既存テストに verdict assert を追加 (本数不変)。 + `cargo clippy --workspace --all-targets --all-features -- -D warnings` warning 0。 + - **実機 end-to-end 検証 (配布 exe)**: `pnpm build:cli-push-runner` で `.claude/` に再配布後、 + config 不在の一時 cwd から起動し **config error (exit 4) の中断経路でも** `os=windows` / + `exit_code=4` / `ts` 付き `push-runs-*.jsonl` 行が `firings-*` とは別ファイルに 1 行書かれること、 + `CLAUDE_TELEMETRY_DISABLE=1` では書かれないことを実測 (検証で出た合成行は削除済)。 + - **受け入れ基準の充足度**: 完了基準「stage 別 elapsed / docs_only / post_takt_regate 判定 / + total_secs が事後集計できる」✓、「ADR-057/058 の効果検証がコンソール手動保存に依存しない」✓ + (機械集計可能な JSONL 化)。実 push コーパスは**次回 push 以降**に自動蓄積される (本 work unit は + commit までのため実データ 0 件。R5/R6 が消費)。 + - **todo 側**: docs/todo13.md 順位 325 エントリ + todo-summary.md 順位 325 行を削除済 (canonical + の「本エントリ削除」)。exe 再ビルド済 (原則 4)。§1 表への行追加と PR 番号 backfill は + push/マージ時に実施 (R1/R2 と同じ扱い)。 + - **CodeRabbit レビュー対応 (PR #294)**: 未解決 3 件を対応 — (a) `lib-telemetry` の公開 API + `record_metric*` 由来 `file_prefix` に path-traversal 検証 (`is_safe_file_prefix`、fail-open + skip) + 回帰テスト 2 本を追加 (Major、現 exploit 無しだが `pub` API の defense-in-depth)、 + (b) 本 R2 セクションのマージ済み表記の不統一を解消 (Minor)、(c) 本 R3「内容」欄の deferred + フィールド (pr_size 行数 / takt run slug) を明示 (Minor)。CI pass / CodeRabbit review 完了。 ### R4: ADR-047 / ADR-056 の採否判定 — 判定期限 2026-07-31 (doc PR) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index a34c80dc..f1611815 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -5,7 +5,7 @@ > **更新方針**: table への新規行追加・既存行の削除・順位の再採番はすべて本ファイルで実施する。詳細エントリは現行の追加先ファイル (= `docs/todo13.md`、2026-06-29 PR #224 セッションで新設。従来の追加先 `docs/todo10.md` が約 95KB = 50KB 安定読み取り閾値の約 2 倍に達したため移行、todo10.md は以降 既存エントリの編集・完了削除専用) に記録する。なお `docs/todo11.md` は 2026-06-06 todo9.md 分割で新設された専用ファイル (順位 157, 160-173 を収容)、`docs/todo12.md` は 2026-06-12 PR #204 で todo10.md 分割により新設された専用ファイル (順位 176/178/179/180/181/182/193/194 = PR #185 〜 PR #196 era を収容) で、いずれも新規追加先ではない。 -## 推奨実行順序サマリー (2026-07-18 更新、順位 323-325 追加) +## 推奨実行順序サマリー (2026-07-18 更新、順位 323-324 追加。325 = push per-run メトリクス JSONL 永続化は R3 で実装完了し削除) 開発環境の作業効率への貢献度を基準にした推奨実行順序。詳細は各タスク冒頭の **「実行優先度」** 行を参照。 @@ -169,7 +169,6 @@ | 322 | 🚀 Tier 1 | **post-merge-feedback が repo root に scratch script を残し `scratch_file_warning` の pattern をすり抜ける (near-miss 実観測)** | todo13.md | S | なし (PR #85 と同一クラス。pattern 列挙 (deny-list) の構造的限界が露呈) | | 323 | 🚀 Tier 1 | **`lib-subprocess` `run_cmd_shell_*` の timeout が wall-clock を縛れない — 孫プロセス残存で join がブロック (push-pipeline-fix-plan §6 backlog 10 移管)** | todo13.md | S | なし (quality_gate step_timeout / push timeout / cli-merge-pipeline のハング打ち切りが実質無効。#286 post-merge-feedback の orphan/stale marker と同根の実害 1 件観測済。回帰テストに経過時間 assert 必須 = T6 教訓) | | 324 | 🚀 Tier 1 | **`cli-pr-monitor::push_to_remote` に push 拒否検知が無く post-PR re-push が無言で失敗し得る (push-pipeline-fix-plan §6 backlog 9 移管)** | todo13.md | XS | なし (T5 = PR #282 が cli-push-runner 側で塞いだ silent-failure push と同型の穴。出力は `run_cmd_direct` で全量取得済のため判定追加のみ) | -| 325 | 🚀 Tier 1 | **push パイプライン per-run メトリクスの JSONL 永続化 — stage 別 elapsed / routing 判定の遡及分析基盤 (T12 後検証セッションで欠落を実測)** | todo13.md | S | なし (T0 の stage ログは stderr のみで消失し、T1/T3/T11/T12 の効果が落ちる決定論 stage 層を遡及分析できない。ADR-057/058 判定期限 2026-08-15 と T99 after 計測の前提データ。harness-improvement-plan セクション 3 着手前の実装を推奨) | **戦略**: Tier 1 を 2〜3 セッションで片付け → Tier 2 で ADR-032 の前提 + rate-limit + convergence cost 削減を進める → Tier 3 で ADR-032 を land + ドキュメント整備。Tier 4-5 は cleanup / 外部展開で daily efficiency への直接効果は小さい。 diff --git a/docs/todo13.md b/docs/todo13.md index de4453f0..9187efce 100644 --- a/docs/todo13.md +++ b/docs/todo13.md @@ -1815,36 +1815,6 @@ --- -### push パイプライン per-run メトリクスの JSONL 永続化 — stage 別 elapsed / routing 判定の遡及分析基盤 - -> **動機**: T12 完了後の検証セッション (2026-07-18) で、push パイプラインの可観測性が「takt 部分のみ十分」であることを実測した。takt 部分は `.takt/runs//meta.json` + `trace.md` で全 run 永続化されており fix 発生率・レビュー時間の before/after 分析が成立する。一方、**T0 (PR #278) で追加した `stage= elapsed=<秒>s` ログは stderr のみで永続化されず** (`src/cli-push-runner/src/log.rs` の `timed()` → `eprintln!`)、quality_gate の group 別時間・`pr_size` の diff 行数・docs_only skip の発火・post_takt_regate の判定・パイプライン総所要時間はセッションが閉じると消失する。**T1/T3/T11/T12 の改善効果はまさにこの決定論 stage 層に落ちる**ため、ADR-057/058 (判定期限 2026-08-15) の効果検証と push-pipeline-fix-plan T99 の after 計測が「push 時のコンソール出力を手動保存する」運用に依存している。ユーザーが直接ターミナルで push した分は記録が残らない。 -> -> **対処案**: run 終了時に 1 行の JSONL を `.claude/telemetry/` へ append する。計測点は `log.rs` の `timed()` に一元化済みのため、収集 struct を `main.rs` で蓄積し pipeline 終了時 (中断時含む) に書き出すだけで済む。器は lib-telemetry (ADR-055) を再利用し fail-open / opt-in (`[telemetry] enabled`) / kill-switch の既存原則に相乗りする — ただし ADR-055 のスコープは hook 発火イベントなので、**push run 記録への流用は ADR-055 amendment か別 record kind かの判断が要る** (プライバシー原則 = メタデータのみ、bookmark 名を含めるかも判断)。 -> -> **フィールド案**: ts / bookmark / pr_size_lines / docs_only (bool) / stage 別 elapsed / skip した gate group / post_takt_regate 判定 (skip・run・block) / takt run slug (**`.takt/runs/` と join する鍵**) / total_secs / exit_code / **os** (harness-improvement-plan WP-15 以降はクラウド Linux run が混入しハードウェアの土俵が変わるため、改善効果の判定を Windows ローカル分に限定できるよう環境を分離する)。 -> -> **着手時期の推奨**: harness-improvement-plan セクション 3 (WP-13〜16) 着手**前**。同セクションは本機での code push が 8〜15 回程度見込まれ、先に永続化しておけば全 push が自動的に after 計測コーパスになる (手動記録不要)。 -> -> **参照**: `src/cli-push-runner/src/log.rs` (`format_stage_elapsed` の doc が「before/after 比較の contract」と明記しつつ永続化されていない)、[ADR-055](adr/adr-055-firing-telemetry-collection.md)、[ADR-057](adr/adr-057-docs-only-deterministic-routing.md) / [ADR-058](adr/adr-058-post-takt-regate.md) (判定期限の消費者)、push-pipeline-fix-plan §1 計測方法 / T99。 -> -> **実行優先度**: 🚀 Tier 1 — Severity Medium (機能不具合ではないが、改善投資の定量評価が構造的に不可能になっている。判定期限 2026-08-15 が消費者として実在) / Effort S。 - -#### 作業計画 - -- [ ] 記録スキーマを決める (ADR-055 amendment か新 record kind か。メタデータのみ原則との整合、bookmark 名の扱いを含む)。 -- [ ] `main.rs` で stage 計測を蓄積し、pipeline 終了時に 1 行 append する (fail-open。exit 7 等の中断経路でも書く — 中断頻度自体が観測対象)。 -- [ ] 回帰テスト: base_dir 注入 (lib-telemetry の `record_to` 同型) で「1 run = 1 行」「中断時も書かれる」「kill-switch で書かれない」を assert。 -- [ ] 集計手順を ADR-057/058 の判定手順に接続する (判定期限 2026-08-15 で実際に使う形にする。手動コンソール保存への依存を撤去)。 -- [ ] 本エントリ削除 + todo-summary.md 行削除。 - -#### 完了基準 - -- `pnpm push` 1 回につき 1 行の JSONL が残り、stage 別 elapsed / docs_only / post_takt_regate 判定 / total_secs が事後に集計できること。 -- ADR-057/058 の効果検証手順がコンソール出力の手動保存に依存しないこと。 -- 変更範囲: 計測点は `src/cli-push-runner/src/log.rs` の `timed()` に一元化済みのため、`log.rs` 自体および各呼び出し元 (quality_gate / pr_size / docs_only skip / post_takt_regate 等の stage 計測箇所) への変更は不要であり、`main.rs` (収集 struct の蓄積・pipeline 終了時の書き出し) と `.claude/telemetry/` (出力先) のみが変更対象であることを実装 PR で確認・明記すること。 - ---- - ## 既知課題 (記録のみ、本セッションで未対応) (現時点で本ファイルへの既知課題は無し。docs/todo10.md / todo9.md 末尾を参照。) diff --git a/src/cli-push-runner/Cargo.toml b/src/cli-push-runner/Cargo.toml index 280fbcfd..02be386a 100644 --- a/src/cli-push-runner/Cargo.toml +++ b/src/cli-push-runner/Cargo.toml @@ -10,6 +10,7 @@ toml = "0.8" lib-docs-policy = { path = "../lib-docs-policy" } lib-jj-helpers = { path = "../lib-jj-helpers" } lib-subprocess = { path = "../lib-subprocess" } +lib-telemetry = { path = "../lib-telemetry" } [dev-dependencies] tempfile = "3" diff --git a/src/cli-push-runner/src/log.rs b/src/cli-push-runner/src/log.rs index 2e4016e5..1eeccc7a 100644 --- a/src/cli-push-runner/src/log.rs +++ b/src/cli-push-runner/src/log.rs @@ -1,5 +1,3 @@ -use std::time::Instant; - pub(crate) fn log_stage(stage: &str, message: &str) { eprintln!("[push-runner] [{}] {}", stage, message); } @@ -23,13 +21,10 @@ fn format_stage_elapsed(stage: &str, secs: f64) -> String { format!("stage={} elapsed={:.1}s", stage, secs) } -/// `f` の実行時間を計測し、成否によらず所要時間を記録して結果をそのまま返す。 -/// 中断で終わった stage も計測対象に残すため、記録は `f` の戻り値を見ずに行う。 -pub(crate) fn timed(stage: &str, f: impl FnOnce() -> T) -> T { - let start = Instant::now(); - let result = f(); - log_info(&format_stage_elapsed(stage, start.elapsed().as_secs_f64())); - result +/// stage の所要時間を contract 書式で stderr に 1 行出力する (T0)。計測は +/// `RunMetrics::timed` (R3) が行い、そこから本関数で stderr 行を出しつつ JSONL へ永続化する。 +pub(crate) fn log_stage_elapsed(stage: &str, secs: f64) { + log_info(&format_stage_elapsed(stage, secs)); } #[cfg(test)] @@ -43,9 +38,4 @@ mod tests { "stage=quality_gate elapsed=312.0s" ); } - - #[test] - fn timed_returns_inner_value() { - assert_eq!(timed("test", || 42), 42); - } } diff --git a/src/cli-push-runner/src/main.rs b/src/cli-push-runner/src/main.rs index 712b5f0f..fc640519 100644 --- a/src/cli-push-runner/src/main.rs +++ b/src/cli-push-runner/src/main.rs @@ -25,13 +25,15 @@ mod config; mod log; +mod metrics; mod runner; mod stages; use std::time::Instant; use config::{load_config, resolve_takt_workflow}; -use log::{log_info, timed}; +use log::log_info; +use metrics::RunMetrics; use stages::{ run_bookmark_check, run_diff, run_docs_only_routing, run_lint_screen, run_post_takt_regate, run_pr_size_check, run_push, run_quality_gate, run_scratch_file_warning, run_takt, DiffResult, @@ -123,22 +125,27 @@ fn start_pipeline(config: &config::Config) -> String { /// takt (AI レビュー → fix loop) と post-takt re-gate (T12) を実行する。 /// diff が空 (`DiffGate::SkipTakt`) の場合は両方 skip する。 -/// Ok(()) で続行、Err(exit_code) で pipeline 中断。 +/// Ok(()) で続行、Err(exit_code) で pipeline 中断。metrics に takt workflow と +/// re-gate 判定を記録する (R3)。 fn run_takt_and_regate( config: &config::Config, workflow: &str, diff_gate: &DiffGate, + metrics: &mut RunMetrics, ) -> Result<(), i32> { let DiffGate::RunTakt { pre_diff } = diff_gate else { return Ok(()); }; - if !timed("takt", || run_takt(&config.takt, workflow)) { + metrics.set_takt_workflow(workflow); + if !metrics.timed("takt", || run_takt(&config.takt, workflow)) { log_info("パイプライン中断: takt ワークフロー失敗。"); return Err(EXIT_TAKT_FAILURE); } - if !timed("post_takt_regate", || { + let regate = metrics.timed("post_takt_regate", || { run_post_takt_regate(config, pre_diff.as_deref()) - }) { + }); + metrics.set_regate_verdict(regate.telemetry_verdict()); + if !regate.proceed { log_info( "パイプライン中断: post-takt re-gate 失敗。takt fix がテスト / lint を壊した \ 可能性があります。問題を修正して再実行してください。", @@ -148,9 +155,27 @@ fn run_takt_and_regate( Ok(()) } +/// pipeline を実行し、成否によらず per-run メトリクスを 1 行 JSONL へ永続化して exit code を +/// 返す (R3)。書き出しは全終了経路で 1 回だけ通る本関数に集約し、中断 run +/// (config エラー / 各種 exit code) も観測対象に残す (中断頻度自体が計測対象)。 fn run_pipeline() -> i32 { let start = Instant::now(); + let mut metrics = RunMetrics::new(); + + let code = run_stages(&mut metrics); + + let elapsed = start.elapsed(); + metrics.finish(code, elapsed); + if code == EXIT_SUCCESS { + log_info(&format!("パイプライン完了 ({:.0}s)", elapsed.as_secs_f64())); + } + metrics.record(); + code +} +/// 各 stage を順に実行し、計測を `metrics` に蓄積して exit code を返す。早期 return も +/// すべて呼び出し側 `run_pipeline` に戻り、そこで 1 回だけメトリクスが書かれる。 +fn run_stages(metrics: &mut RunMetrics) -> i32 { let config = match load_config() { Ok(c) => c, Err(e) => { @@ -163,38 +188,38 @@ fn run_pipeline() -> i32 { let workflow = start_pipeline(&config); - let detected_bookmarks = match timed("pre_checks", || run_pre_checks(&config)) { + let detected_bookmarks = match metrics.timed("pre_checks", || run_pre_checks(&config)) { Ok(bookmarks) => bookmarks, Err(code) => return code, }; + metrics.set_bookmarks(&detected_bookmarks); - let skip_groups = timed("docs_only_routing", || { + let skip_groups = metrics.timed("docs_only_routing", || { run_docs_only_routing(config.docs_only_routing.as_ref()) }); + metrics.set_skipped_groups(&skip_groups); - if !timed("quality_gate", || { + if !metrics.timed("quality_gate", || { run_quality_gate(&config.quality_gate, &skip_groups) }) { log_info("パイプライン中断: quality_gate 失敗。問題を修正して再実行してください。"); return EXIT_QUALITY_GATE_FAILURE; } - let diff_gate = match timed("diff", || run_diff_and_lint_screen(&config)) { + let diff_gate = match metrics.timed("diff", || run_diff_and_lint_screen(&config)) { Ok(gate) => gate, Err(code) => return code, }; - if let Err(code) = run_takt_and_regate(&config, &workflow, &diff_gate) { + if let Err(code) = run_takt_and_regate(&config, &workflow, &diff_gate, metrics) { return code; } - if !timed("push", || run_push(&config.push, &detected_bookmarks)) { + if !metrics.timed("push", || run_push(&config.push, &detected_bookmarks)) { log_info("パイプライン中断: push 失敗。"); return EXIT_PUSH_FAILURE; } - let elapsed = start.elapsed(); - log_info(&format!("パイプライン完了 ({:.0}s)", elapsed.as_secs_f64())); EXIT_SUCCESS } diff --git a/src/cli-push-runner/src/metrics.rs b/src/cli-push-runner/src/metrics.rs new file mode 100644 index 00000000..dc0dd44a --- /dev/null +++ b/src/cli-push-runner/src/metrics.rs @@ -0,0 +1,279 @@ +//! push run per-run メトリクスの収集と JSONL 永続化 (R3、todo 順位 325 / ADR-055)。 +//! +//! ## 何を解決するか +//! +//! T0 (PR #278) が追加した `stage= elapsed=<秒>s` ログは stderr のみで非永続のため、 +//! quality_gate の所要・docs_only skip の発火・post_takt_regate の判定・パイプライン総時間は +//! セッションが閉じると消失していた。ADR-057/058 の採否判定 (期限 2026-08-15) と after 計測が +//! 「push 時のコンソール出力を手動保存する」運用に依存していた。本 module は run 終了時 +//! (中断経路含む) に 1 行の JSONL を `.claude/telemetry/push-runs-*.jsonl` へ append し、 +//! 決定論 stage 層を機械集計可能にする。 +//! +//! ## 別 record kind (別ファイル) を選んだ理由 +//! +//! 器は lib-telemetry (ADR-055) を再利用するが、firing (`firings-*.jsonl`) とは**別 prefix** +//! `push-runs-*.jsonl` に書く。firing 集計 (WP-12 step 2 が `firings-*.jsonl` を glob 走査) に +//! 異なる shape の行を混ぜないため。opt-in (`[telemetry] enabled`) / kill-switch +//! (`CLAUDE_TELEMETRY_DISABLE`) / fail-open / per-pid×日次 partition の既存原則に相乗りする。 +//! +//! ## プライバシー (ADR-055 § プライバシー) +//! +//! 記録はメタデータのみ。ファイルパス・編集内容・コマンド本文は載せない。bookmark 名は +//! branch 識別子 (= メタデータ) であり session_id と同性質のため run の識別鍵として載せる。 +//! +//! ## 収集フィールドと deferred +//! +//! - **収集**: os / exit_code / total_secs / docs_only / skipped_groups / post_takt_regate 判定 / +//! takt_workflow / bookmarks / stage 別 elapsed。 +//! - **deferred (別 PR)**: takt run slug (`run_cmd_inherit` が takt 出力を捕捉しないため +//! `.takt/runs/` との join 鍵を clean に取得できない) と pr_size 行数 +//! (`run_pr_size_check` が総行数を返さない。完了基準の必須項目ではない)。 + +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use crate::log::log_stage_elapsed; + +/// telemetry の partition file prefix。firing (`firings-*`) と別ファイルにして ADR-055 の +/// firing 集計 (glob `firings-*.jsonl`) を汚さない。 +const PUSH_RUN_PREFIX: &str = "push-runs"; + +/// takt / post_takt_regate stage に到達しなかった run の post_takt_regate 判定既定値。 +const REGATE_NOT_RUN: &str = "not_run"; + +/// pipeline 1 run 分の計測を蓄積し、終了時に 1 行 JSONL として永続化する収集 struct。 +/// +/// stage 計測は [`RunMetrics::timed`] に一元化し (T0 の stderr contract を出しつつ elapsed を +/// 蓄積)、その他のフィールドは各 stage の戻り値を main.rs が set する。書き出しは run の全 +/// 終了経路 (成功 / 各種 exit code) で 1 回だけ行われる (main.rs `run_pipeline`)。 +pub(crate) struct RunMetrics { + stages: Vec<(&'static str, f64)>, + bookmarks: Vec, + skipped_groups: Vec, + takt_workflow: Option, + post_takt_regate: &'static str, + exit_code: i32, + total_secs: f64, +} + +impl RunMetrics { + pub(crate) fn new() -> Self { + Self { + stages: Vec::new(), + bookmarks: Vec::new(), + skipped_groups: Vec::new(), + takt_workflow: None, + post_takt_regate: REGATE_NOT_RUN, + exit_code: 0, + total_secs: 0.0, + } + } + + /// stage の実行時間を計測し、T0 の stderr contract 行を出しつつ elapsed を蓄積して結果を + /// そのまま返す。中断で終わった stage も計測対象に残すため、記録は `f` の戻り値を見ずに行う + /// (log::timed から移設。計測点を本メソッドに一元化した = 完了基準の「timed() に一元化」)。 + pub(crate) fn timed(&mut self, stage: &'static str, f: impl FnOnce() -> T) -> T { + let start = Instant::now(); + let result = f(); + let secs = start.elapsed().as_secs_f64(); + log_stage_elapsed(stage, secs); + self.stages.push((stage, secs)); + result + } + + pub(crate) fn set_bookmarks(&mut self, bookmarks: &[String]) { + self.bookmarks = bookmarks.to_vec(); + } + + /// docs_only routing が skip した group を記録する。空でなければ docs_only run と判定する + /// (routing が有効かつ PR 範囲が docs-only のときのみ非空になる)。 + pub(crate) fn set_skipped_groups(&mut self, groups: &[String]) { + self.skipped_groups = groups.to_vec(); + } + + pub(crate) fn set_takt_workflow(&mut self, workflow: &str) { + self.takt_workflow = Some(workflow.to_string()); + } + + pub(crate) fn set_regate_verdict(&mut self, verdict: &'static str) { + self.post_takt_regate = verdict; + } + + /// pipeline 終了時に exit code と総所要時間を確定する。 + pub(crate) fn finish(&mut self, exit_code: i32, total: Duration) { + self.exit_code = exit_code; + self.total_secs = total.as_secs_f64(); + } + + fn to_record(&self) -> RunRecord<'_> { + RunRecord { + os: std::env::consts::OS, + exit_code: self.exit_code, + total_secs: round1(self.total_secs), + docs_only: !self.skipped_groups.is_empty(), + skipped_groups: &self.skipped_groups, + bookmarks: &self.bookmarks, + takt_workflow: self.takt_workflow.as_deref(), + post_takt_regate: self.post_takt_regate, + stages: self + .stages + .iter() + .map(|(stage, secs)| (*stage, round1(*secs))) + .collect(), + } + } + + /// prod 入口: exe 隣 `.claude/telemetry/push-runs-*.jsonl` へ 1 行 append する + /// (fail-open / opt-in、lib-telemetry の gate に相乗り)。 + pub(crate) fn record(&self) { + lib_telemetry::record_metric(PUSH_RUN_PREFIX, &self.to_record()); + } + + /// test 注入版: base_dir / now を渡して gate 込みで書く (lib-telemetry の `record_gated_to` + /// 同型の副作用注入。ADR-055 § 副作用注入)。 + #[cfg(test)] + fn record_to(&self, base_dir: &std::path::Path, now_epoch: u64) { + lib_telemetry::record_metric_gated_to(base_dir, PUSH_RUN_PREFIX, &self.to_record(), now_epoch); + } +} + +/// JSONL 1 行の serde 表現 (`ts` は lib-telemetry が書き込み時に差し込む)。stage 別 elapsed は +/// JSON object (stage 名 → 秒) として書き、集計側が stage 名で引けるようにする。 +#[derive(serde::Serialize)] +struct RunRecord<'a> { + os: &'a str, + exit_code: i32, + total_secs: f64, + docs_only: bool, + skipped_groups: &'a [String], + bookmarks: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + takt_workflow: Option<&'a str>, + post_takt_regate: &'a str, + stages: BTreeMap<&'a str, f64>, +} + +/// 秒を 0.1s 精度に丸める。stderr contract (`{:.1}s`) と JSONL の値を揃え、float 誤差を落とす。 +fn round1(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + /// 2026-04-01T12:00:00Z の epoch 秒 (lib-telemetry のテストと同値)。 + const T_2026_04_01_1200: u64 = 1_775_044_800; + + fn enabled_base(dir: &Path) { + fs::write( + dir.join("hooks-config.toml"), + "[telemetry]\nenabled = true\n", + ) + .unwrap(); + } + + fn read_push_run(base: &Path) -> String { + let pid = std::process::id(); + let path = base + .join("telemetry") + .join(format!("push-runs-2026-04-01-{pid}.jsonl")); + fs::read_to_string(path).unwrap_or_default() + } + + #[test] + fn timed_returns_inner_value_and_records_stage() { + let mut metrics = RunMetrics::new(); + let value = metrics.timed("quality_gate", || 42); + assert_eq!(value, 42); + assert_eq!(metrics.stages.len(), 1); + assert_eq!(metrics.stages[0].0, "quality_gate"); + } + + #[test] + fn record_writes_one_line_with_core_fields() { + let dir = tempfile::tempdir().unwrap(); + enabled_base(dir.path()); + + let mut metrics = RunMetrics::new(); + metrics.timed("quality_gate", || ()); + metrics.set_skipped_groups(&["rust-lint-test".to_string()]); + metrics.set_takt_workflow("pre-push-review-refute"); + metrics.set_regate_verdict("no_change"); + metrics.finish(0, Duration::from_secs_f64(168.24)); + metrics.record_to(dir.path(), T_2026_04_01_1200); + + let content = read_push_run(dir.path()); + assert_eq!(content.matches('\n').count(), 1, "1 run = 1 行"); + let v: serde_json::Value = serde_json::from_str(content.trim_end()).unwrap(); + assert_eq!(v["ts"], "2026-04-01T12:00:00Z"); + assert_eq!(v["exit_code"], 0); + assert_eq!(v["total_secs"], 168.2); + assert_eq!(v["docs_only"], true); + assert_eq!(v["skipped_groups"][0], "rust-lint-test"); + assert_eq!(v["takt_workflow"], "pre-push-review-refute"); + assert_eq!(v["post_takt_regate"], "no_change"); + assert!(v["stages"]["quality_gate"].is_number()); + } + + /// 中断 run (bookmark 未設定で exit 7) でも stage 途中まで + exit code が書かれる。 + #[test] + fn record_writes_on_aborted_run() { + let dir = tempfile::tempdir().unwrap(); + enabled_base(dir.path()); + + let mut metrics = RunMetrics::new(); + metrics.timed("pre_checks", || ()); + metrics.finish(7, Duration::from_secs_f64(1.5)); + metrics.record_to(dir.path(), T_2026_04_01_1200); + + let v: serde_json::Value = + serde_json::from_str(read_push_run(dir.path()).trim_end()).unwrap(); + assert_eq!(v["exit_code"], 7, "中断経路でも exit code が残る"); + assert_eq!( + v["post_takt_regate"], "not_run", + "takt 未到達 run は regate not_run" + ); + assert!( + v["stages"].get("push").is_none(), + "未実行 stage は stages に現れない" + ); + } + + /// kill-switch (config OFF) では 1 行も書かれない。 + #[test] + fn record_noop_when_disabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("hooks-config.toml"), + "[telemetry]\nenabled = false\n", + ) + .unwrap(); + + let mut metrics = RunMetrics::new(); + metrics.finish(0, Duration::from_secs_f64(1.0)); + metrics.record_to(dir.path(), T_2026_04_01_1200); + + assert!(!dir.path().join("telemetry").exists(), "OFF では書かれない"); + } + + #[test] + fn takt_workflow_omitted_when_takt_skipped() { + let dir = tempfile::tempdir().unwrap(); + enabled_base(dir.path()); + + let mut metrics = RunMetrics::new(); + metrics.finish(0, Duration::from_secs_f64(1.0)); + metrics.record_to(dir.path(), T_2026_04_01_1200); + + let v: serde_json::Value = + serde_json::from_str(read_push_run(dir.path()).trim_end()).unwrap(); + assert!( + v.get("takt_workflow").is_none(), + "takt skip (diff 空等) では takt_workflow を載せない" + ); + assert_eq!(v["docs_only"], false, "skip group 無しは docs_only=false"); + } +} diff --git a/src/cli-push-runner/src/stages/post_takt_regate.rs b/src/cli-push-runner/src/stages/post_takt_regate.rs index cf1d4cbb..44375dea 100644 --- a/src/cli-push-runner/src/stages/post_takt_regate.rs +++ b/src/cli-push-runner/src/stages/post_takt_regate.rs @@ -43,7 +43,7 @@ use crate::stages::quality_gate::run_quality_gate; const OVERRIDE_ENV_VAR: &str = "POST_TAKT_REGATE_DISABLE"; /// re-gate 要否の判定結果。skip 系 3 種と実行系 2 種に分かれる。 -#[derive(Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum RegateDecision { /// config で無効 (ADR-039 opt-in: section 不在 / enabled != true) Disabled, @@ -126,13 +126,38 @@ fn apply_regate_decision(decision: RegateDecision, quality_gate: &QualityGateCon } } +/// re-gate stage の結果。`proceed` は push 続行可否 (main.rs の制御フロー)、`decision` は +/// telemetry (R3) が skip / run-pass / block を判別するための判定 (R3 で追加)。bool 単独では +/// 「無変更 skip」と「変更あり pass」を区別できず ADR-058 の採否判定に必要な信号が落ちるため、 +/// stage 内部で確定済みの `RegateDecision` を呼び出し側へ surface する。 +pub(crate) struct RegateOutcome { + pub(crate) decision: RegateDecision, + pub(crate) proceed: bool, +} + +impl RegateOutcome { + /// telemetry 用の判定文字列。skip 系 (gate 未実行) と run 系 (実行して pass / block) を + /// 区別する。ADR-058 の「fix 発生 run での block/pass 実績 vs 無変更 skip の実測」に対応。 + pub(crate) fn telemetry_verdict(&self) -> &'static str { + match (self.decision, self.proceed) { + (RegateDecision::Disabled, _) => "disabled", + (RegateDecision::OverrideSkipped, _) => "override_skipped", + (RegateDecision::NoChange, _) => "no_change", + (RegateDecision::Changed, true) => "changed_pass", + (RegateDecision::Changed, false) => "changed_block", + (RegateDecision::Indeterminate, true) => "indeterminate_pass", + (RegateDecision::Indeterminate, false) => "indeterminate_block", + } + } +} + /// post-takt re-gate stage の入口。takt 実行後に呼ばれ、fix が作業コピーを書き換えた -/// 場合のみ quality_gate を再実行する。戻り値 `false` で pipeline を中断 +/// 場合のみ quality_gate を再実行する。`proceed == false` で pipeline を中断 /// (main.rs で EXIT_QUALITY_GATE_FAILURE)。 /// /// `pre_diff` は Stage 1.5 が保持した takt 起動前の diff snapshot (`[diff]` 未設定 / /// 読込失敗時は None → Indeterminate = fail-closed)。 -pub(crate) fn run_post_takt_regate(config: &Config, pre_diff: Option<&str>) -> bool { +pub(crate) fn run_post_takt_regate(config: &Config, pre_diff: Option<&str>) -> RegateOutcome { let enabled = config .post_takt_regate .as_ref() @@ -143,7 +168,8 @@ pub(crate) fn run_post_takt_regate(config: &Config, pre_diff: Option<&str>) -> b fetch_post_diff(config.diff.as_ref()) }); - apply_regate_decision(decision, &config.quality_gate) + let proceed = apply_regate_decision(decision, &config.quality_gate); + RegateOutcome { decision, proceed } } #[cfg(test)] @@ -235,20 +261,28 @@ command = "echo push" #[test] fn regate_blocks_when_change_detected_and_gate_fails() { let config = config_with(true, "exit 1", "echo changed"); + let outcome = run_post_takt_regate(&config, Some("stale-pre-snapshot")); assert!( - !run_post_takt_regate(&config, Some("stale-pre-snapshot")), - "変化検出 + gate FAIL → block (false)。fix が壊した回帰を push 前に遮断する" + !outcome.proceed, + "変化検出 + gate FAIL → block (proceed=false)。fix が壊した回帰を push 前に遮断する" + ); + assert_eq!( + outcome.telemetry_verdict(), + "changed_block", + "telemetry は変更あり block を区別する (ADR-058 判定信号)" ); } - /// 変化ありでも gate が通れば push 続行 (return true)。 + /// 変化ありでも gate が通れば push 続行 (proceed=true)。 #[test] fn regate_passes_when_change_detected_and_gate_passes() { let config = config_with(true, "echo ok", "echo changed"); + let outcome = run_post_takt_regate(&config, Some("stale-pre-snapshot")); assert!( - run_post_takt_regate(&config, Some("stale-pre-snapshot")), - "変化検出 + gate PASS → push 続行 (true)" + outcome.proceed, + "変化検出 + gate PASS → push 続行 (proceed=true)" ); + assert_eq!(outcome.telemetry_verdict(), "changed_pass"); } /// 変化なしなら gate を**実行しない**: 失敗する gate を設定しても true を返す @@ -265,9 +299,15 @@ command = "echo push" let pre = capture_diff_snapshot(&diff_cfg).expect("pre snapshot 取得"); let config = config_with(true, "exit 1", "echo unchanged"); + let outcome = run_post_takt_regate(&config, Some(&pre)); assert!( - run_post_takt_regate(&config, Some(&pre)), - "無変更 (pre == post) は gate を実行せず skip (true)。失敗 gate でも走らない証跡" + outcome.proceed, + "無変更 (pre == post) は gate を実行せず skip (proceed=true)。失敗 gate でも走らない証跡" + ); + assert_eq!( + outcome.telemetry_verdict(), + "no_change", + "telemetry は無変更 skip を run 系と区別する" ); } @@ -276,10 +316,12 @@ command = "echo push" #[test] fn regate_disabled_config_skips_entirely() { let config = config_with(false, "exit 1", "echo changed"); + let outcome = run_post_takt_regate(&config, Some("stale-pre-snapshot")); assert!( - run_post_takt_regate(&config, Some("stale-pre-snapshot")), + outcome.proceed, "enabled = false は re-gate を完全 skip (opt-in OFF)" ); + assert_eq!(outcome.telemetry_verdict(), "disabled"); } /// section 不在も OFF レーン (default OFF)。 @@ -310,8 +352,8 @@ command = "echo push" "section 不在は None (default OFF)" ); assert!( - run_post_takt_regate(&config, Some("stale-pre-snapshot")), - "section 不在は re-gate を skip (true)" + run_post_takt_regate(&config, Some("stale-pre-snapshot")).proceed, + "section 不在は re-gate を skip (proceed=true)" ); } } diff --git a/src/lib-telemetry/src/lib.rs b/src/lib-telemetry/src/lib.rs index e3da5048..50ea2ae1 100644 --- a/src/lib-telemetry/src/lib.rs +++ b/src/lib-telemetry/src/lib.rs @@ -32,6 +32,11 @@ use std::sync::{Mutex, OnceLock}; /// telemetry 書き込み先ディレクトリ名 (base_dir 配下)。 const TELEMETRY_DIR: &str = "telemetry"; +/// 発火イベント (firing) の partition file prefix。集計 (WP-12 step 2) は +/// `firings-*.jsonl` を glob 走査する前提。push-run 等の別 record kind は別 prefix を使い、 +/// この firing 集計を汚さない。 +const FIRINGS_PREFIX: &str = "firings"; + /// 緊急停止用 env の名前 (kill-switch)。truthy 値で telemetry を完全無効化する。 const KILL_SWITCH_ENV: &str = "CLAUDE_TELEMETRY_DISABLE"; @@ -149,25 +154,121 @@ pub fn record_to(base_dir: &Path, firing: &Firing, now_epoch: u64) -> io::Result decision: firing.decision.as_str(), session_id: firing.session_id, }; - let mut line = + let line = serde_json::to_string(&record).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - line.push('\n'); + append_partitioned(base_dir, FIRINGS_PREFIX, &line, now_epoch) +} +/// `file_prefix` が partition ファイル名の構成要素として安全かを判定する。空でなく、ASCII +/// 英数字と `-` `_` のみを許可する。公開 API ([`record_metric_to`] 系) 由来の任意文字列が +/// `../` や絶対パス・path separator によって `telemetry` ディレクトリ外へ書き込むのを防ぐ +/// (defense-in-depth。現 caller は全て定数だが、`pub` API のため入力を検証する)。 +fn is_safe_file_prefix(file_prefix: &str) -> bool { + !file_prefix.is_empty() + && file_prefix + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// 汎用 partition writer: `base_dir/telemetry/--.jsonl` へ +/// 改行 1 個付きで 1 行 append する。firing / push-run 等の record kind 間で per-process(pid) +/// と日次(date) の partition・ディレクトリ生成・[`WRITE_LOCK`] 直列化を共有する +/// (ADR-055 § Windows 並行書き込み安全性)。`line` は改行なしで渡す (本関数が付与する)。 +/// +/// `file_prefix` が [`is_safe_file_prefix`] を満たさない場合は書き込まず `InvalidInput` を返す +/// (path traversal 防止)。prod 入口はこの Err を握りつぶす (fail-open) ため、不正 prefix でも +/// telemetry 外への書き込みも panic も起きない。 +fn append_partitioned( + base_dir: &Path, + file_prefix: &str, + line: &str, + now_epoch: u64, +) -> io::Result<()> { + if !is_safe_file_prefix(file_prefix) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsafe telemetry file_prefix: {file_prefix:?}"), + )); + } + let ts = epoch_secs_to_iso8601(now_epoch); let date = ts.get(..10).unwrap_or(ts.as_str()); let pid = std::process::id(); let path = base_dir .join(TELEMETRY_DIR) - .join(format!("firings-{date}-{pid}.jsonl")); + .join(format!("{file_prefix}-{date}-{pid}.jsonl")); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } + let mut buf = String::with_capacity(line.len() + 1); + buf.push_str(line.trim_end_matches('\n')); + buf.push('\n'); + let _guard = WRITE_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); let mut file = OpenOptions::new().append(true).create(true).open(&path)?; - file.write_all(line.as_bytes())?; + file.write_all(buf.as_bytes())?; Ok(()) } +/// 純粋 writer (opt-in 判定なし): 任意の `Serialize` 値を JSON object 化し `ts` (UTC ISO 8601) +/// を差し込んで `base_dir/telemetry/-*.jsonl` へ 1 行 append する。firing 以外の +/// record kind (例 push-run メトリクス、R3) が同じ partition / lock 基盤を再利用するための +/// 汎用版。base_dir / now は注入 (テストが temp dir へ確定的に書ける)。**プライバシー原則 +/// (メタデータのみ、パス・コマンド本文を載せない) の遵守は呼び出し側 record の責務**。 +/// +/// `record` が JSON object にならない場合 (配列 / スカラ) は `ts` を差し込めないため、値を +/// そのまま書く (fail-open の一貫、実際の record は必ず object)。 +pub fn record_metric_to( + base_dir: &Path, + file_prefix: &str, + record: &T, + now_epoch: u64, +) -> io::Result<()> { + let line = metric_line(record, now_epoch)?; + append_partitioned(base_dir, file_prefix, &line, now_epoch) +} + +/// `record` を JSON 化し `ts` を差し込んだ 1 行の JSON 文字列を返す。 +fn metric_line(record: &T, now_epoch: u64) -> io::Result { + let mut value = + serde_json::to_value(record).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + if let serde_json::Value::Object(map) = &mut value { + map.insert( + "ts".to_string(), + serde_json::Value::String(epoch_secs_to_iso8601(now_epoch)), + ); + } + serde_json::to_string(&value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +/// gate 込み汎用 metric writer (OnceLock キャッシュ不使用): `base_dir` で opt-in を評価し、 +/// 有効なら [`record_metric_to`] を呼ぶ。テストが temp dir を変えながら gate 挙動を検証する +/// ための版。fail-open: 書き込み失敗は握りつぶす。firing の [`record_gated_to`] と同形。 +pub fn record_metric_gated_to( + base_dir: &Path, + file_prefix: &str, + record: &T, + now_epoch: u64, +) { + if !telemetry_enabled(base_dir) { + return; + } + let _ = record_metric_to(base_dir, file_prefix, record, now_epoch); +} + +/// prod 入口 (汎用 metric): 実行中 exe 隣の `.claude/` を解決 → opt-in 判定 (1 プロセス 1 回 +/// キャッシュ) → 1 行 append。fail-open のため exe 解決失敗・config 欠落・書き込み失敗はすべて +/// 黙って無視し never panic。firing の [`record`] と同じ経路を任意 record kind に開く。 +pub fn record_metric(file_prefix: &str, record: &T) { + let Some(base_dir) = exe_dir() else { + return; + }; + if !enabled_cached(&base_dir) { + return; + } + let _ = record_metric_to(&base_dir, file_prefix, record, utc_now_epoch_secs()); +} + /// gate 込み writer (OnceLock キャッシュ不使用): `base_dir` で opt-in を評価し、有効なら /// [`record_to`] を呼ぶ。テストが temp dir を変えながら gate 挙動を検証するための版。 /// fail-open: 書き込み失敗は握りつぶす。 @@ -473,6 +574,124 @@ mod tests { assert!(record_to(&file_as_base, &sample("git"), T_2026_04_01_1200).is_err()); } + /// 当該プロセスの任意 prefix ファイル内容を読む (テストプロセスは単一 pid)。 + fn read_partition(base: &Path, prefix: &str, now: u64) -> String { + let iso = epoch_secs_to_iso8601(now); + let date = iso.get(..10).unwrap_or(iso.as_str()); + let pid = std::process::id(); + let path = base + .join(TELEMETRY_DIR) + .join(format!("{prefix}-{date}-{pid}.jsonl")); + fs::read_to_string(path).unwrap_or_default() + } + + #[test] + fn record_metric_to_writes_one_line_with_ts_injected() { + let dir = tempfile::tempdir().unwrap(); + let record = serde_json::json!({ "os": "windows", "exit_code": 0 }); + record_metric_to(dir.path(), "push-runs", &record, T_2026_04_01_1200).unwrap(); + let content = read_partition(dir.path(), "push-runs", T_2026_04_01_1200); + assert_eq!(content.matches('\n').count(), 1); + let v: serde_json::Value = serde_json::from_str(content.trim_end()).unwrap(); + assert_eq!( + v["ts"], "2026-04-01T12:00:00Z", + "ts は record 本体ではなく writer が差し込む" + ); + assert_eq!(v["os"], "windows"); + assert_eq!(v["exit_code"], 0); + } + + #[test] + fn record_metric_to_uses_separate_file_from_firings() { + let dir = tempfile::tempdir().unwrap(); + record_to(dir.path(), &sample("git"), T_2026_04_01_1200).unwrap(); + record_metric_to( + dir.path(), + "push-runs", + &serde_json::json!({ "exit_code": 7 }), + T_2026_04_01_1200, + ) + .unwrap(); + assert_eq!( + read_partition(dir.path(), "firings", T_2026_04_01_1200) + .lines() + .count(), + 1, + "firing 集計 (firings-*.jsonl glob) に push-run 行が混ざらない" + ); + assert_eq!( + read_partition(dir.path(), "push-runs", T_2026_04_01_1200) + .lines() + .count(), + 1 + ); + } + + #[test] + fn record_metric_gated_to_noop_when_disabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("hooks-config.toml"), + "[telemetry]\nenabled = false\n", + ) + .unwrap(); + record_metric_gated_to( + dir.path(), + "push-runs", + &serde_json::json!({ "exit_code": 0 }), + T_2026_04_01_1200, + ); + assert!(!dir.path().join(TELEMETRY_DIR).exists()); + } + + #[test] + fn append_partitioned_rejects_unsafe_prefix() { + let dir = tempfile::tempdir().unwrap(); + let record = serde_json::json!({ "x": 1 }); + for bad in ["../evil", "a/b", "..", "abs\\path", "", "a b", "pre.fix"] { + assert!( + record_metric_to(dir.path(), bad, &record, T_2026_04_01_1200).is_err(), + "unsafe prefix {bad:?} は拒否されるべき (path traversal 防止)" + ); + } + assert!( + !dir.path().join(TELEMETRY_DIR).exists(), + "unsafe prefix では telemetry ディレクトリも作られない" + ); + } + + #[test] + fn append_partitioned_accepts_safe_prefixes() { + let dir = tempfile::tempdir().unwrap(); + let record = serde_json::json!({ "x": 1 }); + for good in ["push-runs", "firings", "abc_123", "a"] { + record_metric_to(dir.path(), good, &record, T_2026_04_01_1200) + .unwrap_or_else(|e| panic!("safe prefix {good:?} は通るべき: {e}")); + } + } + + #[test] + fn record_metric_gated_to_writes_when_enabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("hooks-config.toml"), + "[telemetry]\nenabled = true\n", + ) + .unwrap(); + record_metric_gated_to( + dir.path(), + "push-runs", + &serde_json::json!({ "exit_code": 0 }), + T_2026_04_01_1200, + ); + assert_eq!( + read_partition(dir.path(), "push-runs", T_2026_04_01_1200) + .lines() + .count(), + 1 + ); + } + #[test] fn concurrent_record_to_no_interleaving() { let dir = tempfile::tempdir().unwrap();