From ff89015294856131fe071270535b7c06da8365bf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 15:24:34 +0000 Subject: [PATCH 01/11] =?UTF-8?q?Docs:=20#2338=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=82=92=E6=9B=B8=E3=81=8D=E3=80=81=E7=94=A8=E8=AA=9E?= =?UTF-8?q?=E9=9B=86=E3=81=B8=203=20=E8=AA=9E=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 回の起動を実行の識別子 1 つで 1 つの記録から追える条件、標準エラーへの診断の経路を 1 つにする条件、 止まった理由を切らずに残す条件、子の課題 8 件の判定の記録を、課題の本文と写しに書いた。 用語集へ「起点の実行」「子の実行」「診断」を足した。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 + docs/glossary/glossary.json | 21 +++ issues/issue-2338-requirements.md | 214 ++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 issues/issue-2338-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index c35de13bf..9231cd887 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -336,6 +336,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 実行の識別子 | `run_id` | 1 回の入口の実行に実行の核が振る識別子。記録・持ち主の記録・耐久の記録をつなぐ(#2335) | — | — | — | | 実行の文脈 | `run_context` | 実行の核が本体へ渡す 1 つの値。解決した入力・実行の識別子・期限・記録の書き口・再試行・型の読み書きを持つ(#2335) | — | — | — | | 実行の記録 | `run_log` | 1 回の起動(子の実行を含む)の始まりから終わりまでを、実行の識別子つきで書く JSON Lines のファイル(#2335) | — | — | — | +| 起点の実行 | — | 親の実行の識別子を持たない実行。実行の記録のファイル名になる(#2338) | — | — | — | +| 子の実行 | — | 実行の核を通る入口が子プロセスとして起こした、核を通る入口の実行。親の実行の識別子を持ち、親と同じ実行の記録へ書く(#2338) | — | — | — | +| 診断 | — | スクリプトが人へ向けて標準エラーへ出す文(警告・進み具合・失敗の理由)。結果 JSON とは別の出力で、実行の記録にも残す(#2338) | — | — | — | | 上限の宣言 | `limits_declaration` | 上限の秒・再試行の回数・同じ原因の上限を、方針の名前ごとに持つ 1 つのファイル(plugins/ndf/contracts/limits.json。#2335) | — | — | — | | 名前の対応表 | `names` | 同じ意味の値の正しい名前と、読むだけ受ける旧い名前の対応(plugins/ndf/contracts/names.json。#2335) | — | — | — | | 受け渡しのファイル | `handoff_file` | あるエントリポイントが書き、別のエントリポイントか Skill の手順が読むファイル(状態・記録・結果)。1 つのエントリポイントの中で作って消す一時ファイルは含まない | — | — | `docs/specifications/ndf-entrypoint-contracts.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 76f889e08..084c3646a 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4865,6 +4865,27 @@ "pending_source": "issues/issue-2335-design.md", "code": "run_log" }, + { + "term": "起点の実行", + "context": "ndf-workflow", + "meaning": "親の実行の識別子を持たない実行。実行の記録のファイル名になる(#2338)", + "source": "", + "pending_source": "issues/issue-2338-requirements.md" + }, + { + "term": "子の実行", + "context": "ndf-workflow", + "meaning": "実行の核を通る入口が子プロセスとして起こした、核を通る入口の実行。親の実行の識別子を持ち、親と同じ実行の記録へ書く(#2338)", + "source": "", + "pending_source": "issues/issue-2338-requirements.md" + }, + { + "term": "診断", + "context": "ndf-workflow", + "meaning": "スクリプトが人へ向けて標準エラーへ出す文(警告・進み具合・失敗の理由)。結果 JSON とは別の出力で、実行の記録にも残す(#2338)", + "source": "", + "pending_source": "issues/issue-2338-requirements.md" + }, { "term": "上限の宣言", "context": "ndf-workflow", diff --git a/issues/issue-2338-requirements.md b/issues/issue-2338-requirements.md new file mode 100644 index 000000000..089c63d7c --- /dev/null +++ b/issues/issue-2338-requirements.md @@ -0,0 +1,214 @@ +# #2338: 1 回の実行を識別子 1 つで追える記録とログにそろえる + +正は課題の本文(#2338)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +大方針 #2335(異常系・ログ・回復とデータ・入出力の形を 1 つの実行の核へ集める)の柱の 1 本(柱の記号: P2)。#2335 の設計(`issues/issue-2335-design.md`)が決めた実行の記録の形・置き場・寿命を前提にし、この課題はそれを満たしたと言える条件を決める。 + +## 依頼(原文) + +> 異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない + +(書き手: 利用者、日付: 2026-11-01。#2335 の「調べ方」の節が引用した利用者の指摘) + +> #2338。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +(書き手: AI の起動指示、日付: 2026-11-04) + +この課題の初版の本文(「何をするか」「受け入れ条件」1〜4「進め方」)は、起票者(課題を書いた AI)が 2026-11-01 の組み直しで書いた文で、利用者の指示の原文として扱わない。初版から読み取った条件は「起票者の推定」に並べる。 + +**解釈**: 利用者が求めているのは、ログ出力の不統一から生まれる不具合を 1 件ずつでなくまとめて無くすことである。この柱では、1 回の起動で何が起き、なぜ止まったかを、1 つの記録から後で確かめられる状態にする。 + +## 起票者の推定 + +題・初版の本文のうち、上の利用者の原文に無く、起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 実行の識別子は実行の核が振る | 初版「何をするか」1 文目 | 受け入れ条件 1・前提 1 | +| S2 | 開始・終了・判断・失敗を 1 つの形の JSON Lines へ書く | 初版「何をするか」1 文目 | 受け入れ条件 1・1c | +| S3 | 標準エラーへの診断も同じ関数を通し、`print(..., file=sys.stderr)` などの直書きを残さない | 初版「何をするか」2 文目・受け入れ条件 2 | 受け入れ条件 2・2a(シェルは核が受ける形にした。前提 4) | +| S4 | 止まった理由は切らずに残す | 初版「何をするか」3 文目・受け入れ条件 3 | 受け入れ条件 3・3a | +| S5 | 使用量の帳簿もこの記録から数える | 初版「何をするか」4 文目 | 前提 3(帳簿は今の置き場に残し、行に実行の識別子を足してつなぐ。帳簿の集計を実行の記録から数え直さない)・受け入れ条件 1d | +| S6 | 子の課題は 1 件ずつ直さず、柱を入れた後に再現の手順を 1 回ずつ通して閉じるか残すかを決める | 初版「進め方」・受け入れ条件 4 | 受け入れ条件 4・境界の「行わない」 | +| S7 | 設計は #2335 の設計の後に、柱ごとに行う | 初版「進め方」 | 前提 1 | + +## 目的 + +- NDF のスクリプトの 1 回の起動について、始まり・判断・診断・失敗・終わりと止まった理由を、実行の識別子 1 つで 1 つのファイルから追える状態にする +- 標準エラーへの診断を書く経路を 1 つにし、記録に残らない診断と、途中で切れた理由を無くす +- 記録とログの不統一が原因の課題(#2335 の型 B)を、仕組みを入れた後にまとめて確かめて閉じる + +## 前提 + +- 前提 1: 柱 P1(#2337 結果の契約と核のスケルトン)がマージされ、入口が実行の核(`lib/run_core.py` の `main`)を通っている。この課題の実装は P1 のマージの後に始める。2026-11-04 時点で #2337 は未完了(`gh issue view 2337` の state が OPEN)で、`plugins/ndf/scripts/lib/` に `run_core.py` は無い(`ls plugins/ndf/scripts/lib/ | grep run_` の結果は `run_metrics.py`・`run_owner.py`・`run_state.py`・`run_state_kinds.py` の 4 件) +- 前提 2: 実行の記録の行の形・置き場・寿命は #2335 の設計に従う。行は `run_id`・`parent_id`・`at`・`event`・`entry`・`fields` を持ち、置き場は `${NDF_STATE_DIR:-~/.local/state/ndf}/runs/<リポジトリ>//<起点の実行の識別子>.jsonl`、寿命は 30 日である(`issues/issue-2335-design.md` の「実行の記録の行」「非機能の実現方式」、決定 6) +- 前提 3: 使用量の帳簿・プランの状態の記録(`progress.jsonl`)・問い合わせの記録(`ask.jsonl`)は、今の置き場と形のまま業務のデータとして残し、行に実行の識別子を足して実行の記録とつなぐ(#2335 決定 6)。帳簿の集計を実行の記録から数え直す変更はこの課題に含めない +- 前提 4: シェルの入口は、#2335 の設計どおり実行の核が子として起こし直す(`rc_enter`)。シェルの本体が標準エラーへ書いた行は、核が受けて実行の記録へ診断として書く。シェルの本体の `>&2` は書き換えの対象にしない +- 前提 5: 対象は `plugins/ndf/scripts` と `plugins/ndf/skills/*/scripts` の、テスト・実験版(`plugins/ndf/scripts/experimental/`)・hook を除く入口とステップである(#2335 の対象範囲) +- 前提 6: 実行の記録へ書けないとき(始まりでも途中でも)、核は標準エラーへ理由を出し、結果を `stopped` にする(#2335 の不変条件 I3) +- 前提 7: SIGKILL で終わった実行には終わりの行が無い。終わりの行が無いことを「途中で強制終了した」と読む +- 前提 8: 1 スプリントで出す入口の移し替えは 3 本の Pull Request までとし、残りは同じ柱の次のスプリントへ送る(`issues/issue-2335-design-pillars.md` の「移す順序と大きさ」) + +## 対象範囲 + +含む: +- 実行の記録の書き口(`lib/run_log.py`)と、核からの呼び出し(始まり・判断・診断・失敗・終わり) +- 子の実行が親の実行の識別子を受けて同じファイルへ書くこと +- 標準エラーへの診断の経路を 1 つにすること(Python は実行の文脈の書き口、シェルは核が受ける)と、直書きを落とす構造チェック +- 止まった理由を切る処理を外すこと +- 使用量の帳簿とプランの状態の記録の行へ実行の識別子を足すこと +- 記録の寿命(30 日)の掃除 +- 子の課題 8 件(#1687・#1839・#1893・#1979・#2059・#2132・#2205・#2300)の再現の手順を通し、閉じるか残すかを本文へ記録すること + +含まない: +- hook を核へ移すこと、hook が書く問い合わせの記録の形を変えること(#2335 の対象範囲の外) +- 使用量の帳簿・プランの状態の記録・問い合わせの記録を実行の記録へ吸い込むこと(前提 3) +- 子の課題を 1 件ずつ直す Pull Request +- 人へ見せる Markdown の報告の形(柱 P7 #2340) +- 持ち主の記録の取り合い(柱 P3 #2342)と、入力の出所の記録(柱 P8 #2341)。どちらも実行の記録の書き口を使うが、行を書く処理はそれぞれの柱が足す +- 実行の記録を読んで集計する道具(集計の手段は子の課題の判断で決める) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 実行の識別子を振った | 核の起動。環境変数 `NDF_RUN_ID` があれば親の識別子として受ける | — | 入力の解決(#2335 の E2)の後 | +| E2 | 記録の置き場を決め、始まりの行を書いた | E1 | 書けない: 標準エラーへ理由を出して `stopped`(前提 6) | E1 | +| E3 | 30 日を超えた日付のディレクトリを消した | 核の起動 | 消せない: 診断を書いて続ける(前提として受け入れ条件 1e) | E2 | +| E4 | 本体が判断・診断を書いた | 本体の `ctx.log` | 書けない: 前提 6 | E2 | +| E5 | シェルの本体が標準エラーへ書いた行を、診断として記録へ書いた | シェルの本体の出力 | 書けない: 前提 6 | E2 | +| E6 | 子の実行が親の識別子を持ち、同じファイルへ行を書いた | 核を通る入口が別の入口を子プロセスで起こした | 書けない: 前提 6。並行に書いた行が混ざる: 受け入れ条件 1b | 親の E2 | +| E7 | 使用量の帳簿・プランの状態の記録の行へ実行の識別子を書いた | 帳簿・状態の記録の追記 | 識別子が無い(核の外の起動): 空のまま書く | E1 | +| E8 | 本体が止まった理由(例外・`stopped`・SIGTERM・時間切れ)を核が受けた | 本体の終わり方 | — | E2 | +| E9 | 終わりの行(結果 JSON の全体と終了コード)を書いた | E8 か正常の終わり | SIGKILL: 書かれない(前提 7) | E8 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 実行の核 | 入口・ステップが必ず通る共通の実行の仕組み(#2335) | +| 実行の識別子 | 1 回の入口の実行に実行の核が振る識別子(`run_id`) | +| 実行の記録 | 1 回の起動(子の実行を含む)の始まりから終わりまでを、実行の識別子つきで書く JSON Lines のファイル | +| 実行の文脈 | 実行の核が本体へ渡す 1 つの値。記録の書き口(`ctx.log`)を持つ | +| 起点の実行 | 親の実行の識別子を持たない実行。実行の記録のファイル名になる | +| 子の実行 | 核を通る入口が子プロセスとして起こした、核を通る入口の実行。親の実行の識別子を持つ | +| 診断 | スクリプトが人へ向けて標準エラーへ出す文(警告・進み具合・失敗の理由)。結果 JSON とは別の出力 | +| 使用量の帳簿 | NDF が起動したエージェントの呼び出しごとの使用量を追記する JSON Lines | + +## 受け入れ条件 + +### 1 つの記録で追える + +- [ ] 1. 核を通る入口を正常・例外・SIGTERM・時間切れの 4 つの終わり方で起動すると、どの場合も実行の記録の 1 ファイルに、同じ実行の識別子を持つ行が始まりの行(`event: start`)から終わりの行(`event: end`)まで並ぶ。すべての行が `run_id`・`at`(UTC の ISO 8601)・`event`・`entry`・`fields` を持つ +- [ ] 1a. 核を通る入口が別の入口を子プロセスで起こすと、子の行は親と同じファイルに書かれ、子の行の `parent_id` が親の `run_id` と一致する +- [ ] 1b. 子の実行が親と並行に同じファイルへ書いても、ファイルのすべての行が 1 行ずつ JSON として読める +- [ ] 1c. 本体が下した判断(`event: decision`)と、止まった理由(例外の理由・`stopped` の `summary` と `gaps`)が行として残る。終わりの行の `fields` は、標準出力へ出した結果 JSON と終了コードを持つ +- [ ] 1d. 核を通る起動の中で追記された使用量の帳簿の行とプランの状態の記録(`progress.jsonl`)の行が、その実行の識別子を持つ。核の外の起動で書いた行は、実行の識別子を持たないまま今と同じ形で読める +- [ ] 1e. 核の起動が、30 日を超えた日付のディレクトリを実行の記録の置き場から消す。消せなかったときは診断を書いて本体を続ける +- [ ] 1f. 実行の記録の置き場を書けない場所にして起動すると、標準エラーに書けなかった理由が出て、結果が `stopped` になる + +### 診断の経路が 1 つ + +- [ ] 2. 対象(前提 5)の Python の入口とステップで、標準エラーへの出力が実行の文脈の書き口(`ctx.log("diag", ...)` に当たる関数)1 つを通る。`print(..., file=sys.stderr)` と `sys.stderr.write` の直書きを構造チェック(`scripts/check-script-structure.py`)が落とす。2026-11-04 の時点の直書きは 131 か所(`print(..., file=sys.stderr)` 126 か所・63 ファイル、`sys.stderr.write` 5 か所。下の「調べ方」のコマンド) +- [ ] 2a. 書き口を通した診断は、標準エラーと実行の記録(`event: diag`)の両方に出る。シェルの本体が標準エラーへ書いた行も、実行の記録に `event: diag` で残る +- [ ] 2b. 移す途中は、まだ直書きを持つファイルを例外リストに載せ、例外リストに行を足す変更を構造チェックが落とす(#2335 の受け入れ条件 1a と同じ扱い) + +### 止まった理由が切れない + +- [ ] 3. 本体が渡した止まった理由の文字列(`summary`・`gaps`・例外の理由)が、結果 JSON と実行の記録の終わりの行に、途中で切られずに同じ文字列で残る。2026-11-04 の時点で理由・出力を先頭 N 字で切っている 10 か所(下の「調べ方」のコマンド)を、実行の記録へ全文を書いた後に人へ見せる表示だけを短くする形にする +- [ ] 3a. 外部コマンドの失敗の出力のように 1 行の上限(未決 1)を超える理由は、全文を記録からたどれる(全文の置き場のパスと長さが行に残る) + +### 子の課題 + +- [ ] 4. 子の課題 8 件(#1687・#1839・#1893・#1979・#2059・#2132・#2205・#2300)それぞれについて、柱の仕組みを入れた後に再現の手順を 1 回通し、閉じた(解消を確かめた)か残した(理由つき)かをこの課題の本文の「子の課題の判定」の節へ記録する + +### 起きてはいけないこと + +- [ ] 5. 実行の記録に、秘密(トークン・鍵・認証情報)の値が書かれない。秘密を入れた環境変数で起動し、本体がその値を診断と例外の理由へ入れても、ファイルに値が出ない(#2335 の受け入れ条件 13・不変条件 I9) +- [ ] 6. 利用者の端末に出る診断の文が、移す前と同じ文で標準エラーへ出る(記録へ書くことで端末の出力が消えない) +- [ ] 7. 既存の全体テストが、この柱の各 Pull Request のマージの前後で通る + +## 子の課題の見込み + +実装の前の見込みで、判定は受け入れ条件 4 の再現の手順で決める。 + +| 課題 | 内容 | 見込み | +| --- | --- | --- | +| #1687 | 設計のプランの途中で、作成者 `t `・題 `tmp` のコミットが入り、worker の報告に出ない | 記録から作った操作をたどれるかを確かめる。試しのコミットを止める検査は核の外で、残る見込み | +| #1839 | cross-refactoring の見送った提案が起票されない | 見送りの判断が記録に残るかを確かめる。起票の経路は核の外で、残る見込み | +| #1893 | 後片付けを終えても工程表の「後片付け」の行が未チェック | 進捗記録(`projects-sync.sh`)は課題の本文への記録で、実行の記録とは別。残る見込み | +| #1979 | 使用量の帳簿の数え方の誤り | 帳簿の数え方の誤りで、前提 3 により残る見込み | +| #2059 | 先に別の警告を出すと、非致命の拒否の件数の警告が出ない | 診断が種類ごとに記録へ残るかを確かめる。解消の候補 | +| #2132 | 開発ループの周回の `created[]` を書く経路が無い | 起票の判断が記録に残るかを確かめる。記録の項目の書き手は核の外で、残る見込み | +| #2205 | cross-refactoring の効果を版ごとに集計するスクリプト | 記録が子の実行までつながれば集計の元が 1 つになる。集計のスクリプト自体は残る見込み | +| #2300 | 検査の記録の集計で、落ちた工程が「不明」になる | 落ちたステップが記録に残るかを確かめる。解消の候補(ほかの 2 点は残る見込み) | + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 1 回の起動を実行の識別子 1 つで、始まりから終わりまで 1 ファイルから追える(受け入れ条件 1)。記録の寿命は 30 日(前提 2) | +| 移行性 | 移す対象: Python の入口の標準エラーへの直書き。順序: #2335 の部品の順(supervise のプラン・判定・キュー → cross-refactoring → 課題を閉じる処理 → リリース → 外部 CLI の監視 → 使用量の帳簿 → 残り)。並走: 例外リストに載った入口は直書きのまま動く。切り戻し: Pull Request ごとの revert で前の形へ戻る。実行の識別子を持たない既存の帳簿と状態の記録の行は、そのまま読める(受け入れ条件 1d) | +| セキュリティ | 受け入れ条件 5。秘密の扱い(C1)と認証・認可(C2)は変えない | +| システム環境 | Claude Code と Codex の両方の配布で同じ核が動く。シェルの入口は bash で動く | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。結果 JSON の鍵は足すだけで、標準エラーの診断の文は変えない(受け入れ条件 6) | +| データ | 実行の記録のファイルを状態の置き場(`${NDF_STATE_DIR}/runs/`)に新しく書く。使用量の帳簿とプランの状態の記録の行に、任意の鍵として実行の識別子を足す | +| 既存の振る舞い | 記録の置き場へ書けない場合は、今は動く入口が `stopped` で止まる(受け入れ条件 1f)。理由を短く切って表示していた箇所で、記録には全文が残る | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4`(全体テスト)。核の終わり方(正常・例外・SIGTERM・時間切れ)ごとに、1 ファイルに `start` から `end` まで同じ `run_id` が並ぶことを見るテスト。子の実行を並行に起こし、全行が JSON として読めることを見るテスト。置き場を書けない場所にして `stopped` を見るテスト。秘密を入れた環境変数で起動し、ファイルに値が無いことを見るテスト | +| 静的解析・構造の検査 | `python3 scripts/check-script-structure.py`(CI のジョブ `script-structure`)。直書きの検査と例外リストの増加の検査をここへ足す | +| 配布物の検証 | `claude plugin validate .` | +| 手動確認(マージ前) | 子の課題 8 件の再現の手順を 1 回ずつ通し、結果を受け入れ条件 4 の節へ書く | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 書き口は `plugins/ndf/scripts/lib/run_log.py` に置く。既定で働くため実験版に置かない。`factory/` を参照しない(`AGENTS.md` の「マーケットプレイスの構造」、#2335 の設計の「置き場所」) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」と、外部コマンドを書く前に実行して確かめる決まり | +| テスト戦略 | 書き口と核の終わり方は単体テストで担保する。入口の移し替えは構造チェックで担保する。子の課題は再現の手順で確かめる。`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 実行の記録へ書く前に秘密を伏せる。移した入口を例外リストから消す。柱の Pull Request ごとに全体テストと構造チェックを通す | +| 確認してから行う(承認ゲート 1・2 で人が見る) | 標準エラーの診断の文を変える変更。使用量の帳簿とプランの状態の記録の形を変える変更 | +| 行わない | 子の課題を 1 件ずつ直す Pull Request。hook を核へ移す。例外リストに行を足す。帳簿を実行の記録へ吸い込む | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 実行の記録の 1 行の大きさの上限と、上限を超える理由の全文の置き場(受け入れ条件 3a) | この柱の設計 | 設計 Pull Request | +| 2. 並行に書く子の実行の行が混ざらない書き方(1 行の追記の単位・排他の要否。受け入れ条件 1b) | この柱の設計 | 設計 Pull Request | +| 3. hook が書く問い合わせの記録(`ask.jsonl`)の行に実行の識別子が入るか。核が起動した claude の hook が環境変数 `NDF_RUN_ID` を受け取れるかは未確認 | この柱の設計(実際に起動して確かめる) | 設計 Pull Request | +| 4. 本体の途中で記録へ書けなくなったとき、本体を止めるか、終わりまで続けて結果だけを `stopped` にするか(前提 6 の具体化) | この柱の設計 | 設計 Pull Request | + +## 調べ方 + +2026-11-04 に、このリポジトリの develop(729dcbe40)で打った。 + +```bash +# 受け入れ条件 2 の数(テストと実験版を除く) +P="plugins/ndf/scripts plugins/ndf/skills" +grep -rEn 'file=sys\.stderr' $P --include=*.py | grep -v '/tests/' | grep -v experimental | wc -l # 126 +grep -rEl 'file=sys\.stderr' $P --include=*.py | grep -v '/tests/' | grep -v experimental | wc -l # 63 +grep -rEn 'sys\.stderr\.write' $P --include=*.py | grep -v '/tests/' | grep -v experimental | wc -l # 5 +grep -rEn '>&2' $P --include=*.sh | grep -v '/tests/' | grep -v experimental | wc -l # 203(24 ファイル。前提 4 により対象外) + +# 受け入れ条件 3 の数(理由・出力を先頭 N 字で切る箇所) +grep -rEn '(reason|summary|stderr|tail|message|detail|err)[a-z_]*\)?\[ ?:-? ?[0-9]{2,4}\]' $P --include=*.py \ + | grep -v /tests/ | grep -v experimental | wc -l # 10 +``` + +切る箇所の 10 件は `project-mvv.py:184`・`supervise_lib/actuals.py:46`・`supervise_lib/engine.py:422`・`supervise_lib/slow.py:202`・`merged_lib/merge.py:317`・`lib/sprint_loop_gate.py:127`・`lib/sprint_loop_gate.py:128`・`lib/monitor_loop.py:120`・`lib/monitor_loop.py:240`・`release-steps.py:141`(いずれも `plugins/ndf/scripts/` の下)。この正規表現に当たらない書き方(変数名が違う・`textwrap.shorten` など)で切る箇所は数えておらず、未確認として残る。 From b328276799b9eb0d1f0c2a0ad24c34c1d4103271 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 15:37:19 +0000 Subject: [PATCH 02/11] =?UTF-8?q?Docs:=20#2338=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E6=9B=B8=E3=81=8D=E3=80=81?= =?UTF-8?q?=E7=94=A8=E8=AA=9E=E9=9B=86=E3=81=B8=E3=80=8C=E5=85=A8=E6=96=87?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=80=8D=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 回の起動を実行の識別子 1 つで 1 つの記録から追うための書き口・核からの呼び出し・子の実行への受け渡し・ 診断の経路・止まった理由を切る 10 か所の扱い・帳簿とプランの状態の記録への識別子の追加・移す順序を決めた。 要求の未決 4 件(1 行の上限・並行の書き込み・問い合わせの記録・途中で書けないとき)は決定 1〜4 で決めた。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 1 + docs/glossary/glossary.json | 7 + issues/issue-2338-design-decisions.md | 132 ++++++++ issues/issue-2338-design.md | 415 ++++++++++++++++++++++++++ 4 files changed, 555 insertions(+) create mode 100644 issues/issue-2338-design-decisions.md create mode 100644 issues/issue-2338-design.md diff --git a/docs/glossary.md b/docs/glossary.md index 9231cd887..6e1857290 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -339,6 +339,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 起点の実行 | — | 親の実行の識別子を持たない実行。実行の記録のファイル名になる(#2338) | — | — | — | | 子の実行 | — | 実行の核を通る入口が子プロセスとして起こした、核を通る入口の実行。親の実行の識別子を持ち、親と同じ実行の記録へ書く(#2338) | — | — | — | | 診断 | — | スクリプトが人へ向けて標準エラーへ出す文(警告・進み具合・失敗の理由)。結果 JSON とは別の出力で、実行の記録にも残す(#2338) | — | — | — | +| 全文ファイル | — | 実行の記録の 1 行の上限を超える文字列を、切らずに全文で置くファイル。実行の記録と同じ日付のディレクトリに置き、行にはパス・文字数・sha256 を残す(#2338) | — | — | — | | 上限の宣言 | `limits_declaration` | 上限の秒・再試行の回数・同じ原因の上限を、方針の名前ごとに持つ 1 つのファイル(plugins/ndf/contracts/limits.json。#2335) | — | — | — | | 名前の対応表 | `names` | 同じ意味の値の正しい名前と、読むだけ受ける旧い名前の対応(plugins/ndf/contracts/names.json。#2335) | — | — | — | | 受け渡しのファイル | `handoff_file` | あるエントリポイントが書き、別のエントリポイントか Skill の手順が読むファイル(状態・記録・結果)。1 つのエントリポイントの中で作って消す一時ファイルは含まない | — | — | `docs/specifications/ndf-entrypoint-contracts.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 084c3646a..47c47b7d8 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4886,6 +4886,13 @@ "source": "", "pending_source": "issues/issue-2338-requirements.md" }, + { + "term": "全文ファイル", + "context": "ndf-workflow", + "meaning": "実行の記録の 1 行の上限を超える文字列を、切らずに全文で置くファイル。実行の記録と同じ日付のディレクトリに置き、行にはパス・文字数・sha256 を残す(#2338)", + "source": "", + "pending_source": "issues/issue-2338-design.md" + }, { "term": "上限の宣言", "context": "ndf-workflow", diff --git a/issues/issue-2338-design-decisions.md b/issues/issue-2338-design-decisions.md new file mode 100644 index 000000000..b79a35f8c --- /dev/null +++ b/issues/issue-2338-design-decisions.md @@ -0,0 +1,132 @@ +# #2338 の設計: 決定の記録 + +設計文書の本体は [issue-2338-design.md](issue-2338-design.md) にある。 + +## 決定の記録 + +### 決定 1: 並行に書く子の実行の行が混ざらないよう、1 行を排他の中で 1 回の書き込みで書く + +記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取ってから、1 行を `os.write` の 1 回で書く。 +Linux の通常のファイルへの `O_APPEND` の書き込みは 1 回なら他と混ざりにくいが、書き込みが途中で分かれる場合(シグナル・容量)を +排他なしでは防げない。排他を置けば、行の大きさに関わらず混ざらないことを書き口だけで保証できる。 + +`filelock`(extras の `locks`)は採らない。書き口は extras の無い環境でも診断を残す必要があり(`deps.py` の診断も書き口を通る)、 +標準ライブラリの `fcntl` で足りる。PIPE_BUF(4,096 バイト)以下なら排他なしで済む前提も採らない。通常のファイルへの書き込みには +その保証が無く、上限を 4 KiB にすると使用量の帳簿の最長の行(1,929 文字)の 2 倍程度しか余裕が無い。 + +根拠: Value 7 / Value 6(MVV 版 5) + +### 決定 2: 理由を切らずに 1 行を読める大きさに保つため、1 行の上限を 64 KiB とし、超える分は長い文字列から全文ファイルへ移す + +上限は、手元で測った行の最大(プランの状態の記録で 570 バイト、使用量の帳簿で 1,929 文字)の 30 倍以上に置いた。通常の行は全文ファイルを +作らずに済み、外部コマンドの失敗の出力のような長い理由だけが移る。移す単位は行ではなく文字列にし、移した値の位置へパス・文字数・sha256 を +置く。行の残りの鍵(`event`・`run_id`・止まった理由以外の値)は行に残り、`grep` と `jq` で行を絞れる。 + +行全体を全文ファイルへ移すのは、文字列を移しても上限を超える行(短い値を多数持つ行)に限る。既定で行全体を移すと、どの行も 1 行で +何が起きたかを読める性質が失われる。上限を置かない形は採らない。外部コマンドの出力の長さには上限が無く、記録の 1 ファイルを読むツール +(`jq`・エディタ)が重くなる。 + +上限の値は書き口の名前付きの定数に置き、上限の宣言へ移すのは柱 P4(#2343)である。 + +根拠: Value 1 / Value 4(MVV 版 5) + +### 決定 3: 途中で記録へ書けなくなっても外の世界を中途半端に残さないよう、本体は終わりまで動かし、結果だけを `stopped` にする + +本体の途中で止めると、push の後・Pull Request を作る前のような半端な状態が残り、利用者が手で戻すことになる。 +終わりまで動かして結果を `stopped` にすれば、呼ぶ側は止まったことを知り、本体の結果は `end` の行(書ければ)と標準エラーの理由から読める。 +理由は標準エラーへ 1 回だけ出し、以後の診断は標準エラーにだけ出る(端末の文は変わらない)。 + +始まりの行を書けないときは本体を動かさない(要求の前提 6)。この時点ではまだ外の世界を変えていないためである。 + +次の書き込みで例外を投げて本体を止める形は採らない。記録の失敗が、記録とは関係の無い外の操作を途中で止めることになる。 + +根拠: 上位の原則(人を守る) / Value 2(MVV 版 5) + +### 決定 4: hook の変更を #2335 の対象範囲の外に保つため、問い合わせの記録(`ask.jsonl`)の行へ実行の識別子を足さない + +核が起動した claude の hook へ環境変数 `NDF_RUN_ID` が届くことは、2026-11-04 に Claude Code 2.1.318 で確かめた(設計文書の「あるべき姿の根拠」)。 +足すこと自体はできるが、問い合わせの記録を書くのは hook で、hook の書く記録の形を変えることは要求の「含まない」にある。 +使用量の帳簿は hook(`usage-subagent.py`)も書くが、帳簿の行を作る `UsageRecord` はライブラリにあり、その既定値として環境変数を読むだけで +hook の本体は変わらない。 + +問い合わせの記録への追加は、hook を核へ移す変更(#2335 の対象範囲の外)で扱う。範囲外の課題としては起票しない +(利用者へ届いた不具合ではなく、識別子が無くても時刻で突き合わせられるため)。 + +根拠: Value 3(MVV 版 5) + +### 決定 5: 子の実行が親と別のリポジトリや日付で動いても同じファイルへ書けるよう、ファイルの絶対パスを `NDF_RUN_LOG` で渡す + +置き場のパスは起点の実行のリポジトリと UTC の日付を含む。子が自分のリポジトリと日付からパスを作ると、別のリポジトリで動く子(リリースの +配布先)と、UTC の 0 時をまたいだ子が別のファイルへ書く。親が自分の識別子(`NDF_RUN_ID`)とファイルのパス(`NDF_RUN_LOG`)の 2 つを渡せば、 +子は計算せずに同じファイルへ書ける。 + +`NDF_RUN_ID` から起点の識別子をたどってパスを作る形は採らない。識別子の連鎖(子の子)をたどるために起点の識別子をもう 1 つ渡すことになり、 +リポジトリの違いは解けない。受け継いだファイルが無いときは起点の実行として自分のファイルを作る。古い環境変数が残った端末で +存在しないファイルへ書こうとして止まるより、記録を残す方を選ぶ。 + +根拠: Value 6(MVV 版 5) + +### 決定 6: hook や移す前の入口が他人の記録へ書かないよう、`run_log.diag` は自分のプロセスで記録を開いたときだけ記録へ書く + +ライブラリの関数は hook からも呼ばれる。hook は核が起動した claude の下で動くと `NDF_RUN_LOG` を受け継ぐが、hook 自身は核を通らず実行の識別子を +持たない。環境変数だけで書くと、識別子の無い行が親の記録へ混ざり、I1 が破れる。プロセスの中で `open_run` が開いた記録があるときだけ書けば、 +核の外では標準エラーへ書くだけになり、移す前の入口とも並んで動く。 + +根拠: Value 7(MVV 版 5) + +### 決定 7: 診断の経路を 1 つにして種類と項目を残せるよう、Python は書き口の関数へ置き換え、シェルは核が標準エラーをパイプで受ける + +Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブジェクトへ差し替える形は採らない。書き込みの単位が行にならず(`print` の +`end` と区切りが別の書き込みになる)、診断の種類と項目を付けられず、`argparse` や DBOS の出力まで混ざる。差し替えで足りるなら構造チェックが +要らないことになり、要求の受け入れ条件 2 の「1 つの関数を通る」が検査できなくなる。 + +シェルの本体の `>&2` は 203 か所・24 ファイルあり、要求の前提 4 のとおり書き換えない。核は `exec` で起こし直した子の標準エラーをパイプで受け、 +1 行ずつ標準エラーへ出してから記録へ書く。スクリプトに標準エラーを端末か判定する箇所が無い(設計文書の「あるべき姿の根拠」)ため、 +パイプにしても振る舞いは変わらない。 + +根拠: Value 5 / Value 7(MVV 版 5) + +### 決定 8: 任意の形の認証情報も記録へ残さないよう、トークンの形の置き換えに加え、秘密と宣言した環境変数の値を伏せる + +`secret_redact` の置き換えは決まった形(GitHub のトークン・URL の資格情報・認証ヘッダー)だけを伏せ、`AI_GATEWAY_API_KEY` のような +任意の形の値は伏せない。環境変数の契約の行に `secret: true` を足し、核がその空でない値を書く前に置き換える。空の値は照合しない +(空文字列で置き換えると全ての文字の間に伏せ字が入る。#2335 の I9 と同じ扱い)。 + +`secret_redact.redact_output` は短く縮める処理も兼ねる(末尾 5 行・500 字)。記録は切らない(受け入れ条件 3)ため、伏せる処理と縮める処理を +分け、記録は伏せる処理だけを通す。入力の宣言の `secret`(柱 P8)が入った後は、核がその値も同じ置き換えへ足す。 + +根拠: C1 / 上位の原則(人を守る)(MVV 版 5) + +### 決定 9: 止まった理由を失わずに表示の短さを保つため、行き先が結果 JSON か例外なら切らず、人へ見せる表示なら全文を記録へ書いてから表示だけを切る + +受け入れ条件 3 は、結果 JSON と記録の `end` の行に同じ文字列を残すことを求める。結果 JSON へ行く文字列を切らなければ、`end` の行は結果 JSON の +全体を持つため両方がそろう。端末・プランの状態の記録・周回の記録の文を切らずに長くすると、利用者の端末の文が変わり(受け入れ条件 6)、 +状態の記録の読み手(`supervise.py` の進捗の要約)の表示も崩れる。そこで表示は今の長さのまま残し、全文は記録に置く。 + +すべての箇所で切るのをやめる形は採らない。表示の文が変わり、受け入れ条件 6 に反する。 + +根拠: Value 1 / Value 8(MVV 版 5) + +### 決定 10: 既存の読み手を書き直さないよう、帳簿とプランの状態の記録には任意の鍵 `run_id` を足すだけにし、版を上げない + +鍵を足すだけなら、今の読み手(見積・使用量の集計・進捗の要約)は未知の鍵を読み飛ばし、識別子の無い古い行もそのまま読める。版を上げると +移行関数が要り、柱 P6(#2339)の型の部品より先に版を動かすことになる。値は書き手の引数ではなく、`UsageRecord` の既定値と `progress_write` の中で +環境変数 `NDF_RUN_ID` から取る。書き手の呼び出し元(`UsageRecord` を作る 2 か所と `progress_write` の呼び出し 15 か所。2026-11-04 に `grep -rn` で数えた)を書き換えずに済む。 + +根拠: Value 6 / Value 7(MVV 版 5) + +### 決定 11: 記録の寿命を耐久の記録と 1 つの値にするため、`KEEP_DAYS` を `durable_keys.py` へ移し、日付のディレクトリの名前で判定して消す + +`durable.py` は最上位で DBOS を読むため、書き口から import すると 1 入口の起動が約 390 ms 増える(#2335 の実測)。値を DBOS を読まない +`durable_keys.py` へ移し、両方がそこから引く。消す判定はディレクトリの名前の UTC の日付で行い、更新時刻を見ない。長く動いた起点の実行の +ファイルは更新時刻が新しく、更新時刻で判定すると日付のディレクトリが消えずに残る。掃除は起点の実行の起動でだけ行い、子の実行では行わない。 + +根拠: Value 5 / Value 7(MVV 版 5) + +### 決定 12: 1 スプリントで人が読み切れる量に収めるため、移す Pull Request を部品の順に 6 本へ分け、スプリント 1 は核の段と G1・G2 にする + +対象は 60 ファイル・125 か所で、1 本にすると差分が大きく、壊れたときに 1 回の revert で戻す範囲も大きい。#2335 の部品の順(supervise → +cross-refactoring → 課題を閉じる処理・リリース → 外部 CLI の監視 → 使用量の帳簿 → 残り)で 6 つに分け、1 スプリントは 3 本までとする +(要求の前提 8)。子の課題 8 件の判定は、すべてを移した後に 1 回だけ行う。 + +根拠: Value 2(MVV 版 5) diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md new file mode 100644 index 000000000..31f81881c --- /dev/null +++ b/issues/issue-2338-design.md @@ -0,0 +1,415 @@ +# NDF のスクリプト: 1 回の起動を実行の識別子 1 つで、1 つの記録から始まりから終わりまで追えるようにする(#2338) + +## 目的 + +- **今どうなっているか**: スクリプトは標準エラーへ診断を直に書き(Python の直書きは 131 か所)、どこにも残らない。止まった理由は 10 か所で先頭 N 字に切られる。子プロセスで動いた入口の記録は親の記録とつながらない +- **誰に効くか**: NDF でスプリントを回す利用者と conductor、振り返りで止まった原因を探す人 +- **変えると何が成り立つか**: 1 回の起動(子の実行を含む)の始まり・判断・診断・失敗・終わりと止まった理由の全文を、実行の識別子 1 つで 1 つのファイルから読める。記録とログの不統一が原因の課題(#2335 の型 B)を、1 件ずつ直さずにまとめて確かめられる + +## 適用範囲 + +- **働く範囲**: NDF の配布物(`plugins/ndf/scripts` と `plugins/ndf/skills/*/scripts`。テスト・`experimental/`・hook の入口を除く)。配布先のリポジトリでも同じ書き口が動く +- **プロジェクトごとに違うもの**: 記録の置き場の根は環境変数 `NDF_STATE_DIR` で受ける(既定 `~/.local/state/ndf`)。リポジトリごとの値は置き場のパスの 1 段(`__`)にだけ現れ、書き口の中に ai-plugins の値を置かない +- **当たるモード**: `standard`。柱 P1(#2337)の核のスケルトンに段を足す柱で、実装は核の段の Pull Request 1 本と入口を移す Pull Request 6 本(3 スプリント)に分かれる + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 利用者の指摘(2026-11-01)「異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない」 | 利用者の指示の原文 | 診断の経路を 1 つにし、型としてまとめて防ぐこと | +| 手元のプランの状態の記録 293 ファイル・1,778 行の長さ: 中央値 199 バイト・99 パーセンタイル 507 バイト・最大 570 バイト。使用量の帳簿の最長の行は 1,929 文字 | 実測(2026-11-04、`LC_ALL=C awk '{print length($0)}'`) | 1 行の上限 64 KiB は今の最長の行の 30 倍以上の余裕を持ち、通常の行は全文ファイルへ逃がさずに済むこと(決定 2) | +| `NDF_RUN_ID=20261104T000000-test-123456 claude -p ... --settings ` を起動し、hook が書いたファイルに `run=20261104T000000-test-123456` が出た(Claude Code 2.1.318) | 実測(2026-11-04) | 核が起動した claude の hook は環境変数 `NDF_RUN_ID` を受け取れること(要求の未決 3。決定 4) | +| `plugins/ndf/scripts` で `grep -rn "isatty\|\[ -t [012] \]"` の結果は 6 件で、すべて標準入力か標準出力の判定。標準エラー(2 番)を端末か判定する箇所は 0 件 | 実測(2026-11-04) | シェルの本体の標準エラーを核がパイプで受けても、本体の振る舞いが変わらないこと(決定 7) | + +要求と受け入れ条件は #2338 の本文にある(写しは [issue-2338-requirements.md](issue-2338-requirements.md) )。この文書は「どう作るか」だけを扱う。 +決定の記録は [issue-2338-design-decisions.md](issue-2338-design-decisions.md) にある。実行の記録の行の形・置き場・寿命は #2335 の設計([issue-2335-design.md](issue-2335-design.md) )が決めたものを前提にし、この文書は書き込みの方式と移し方を決める。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | 実行の記録・実行の識別子・起点の実行・子の実行・診断・全文ファイル・使用量の帳簿 | + +実行の記録の書き口(`lib/run_log.py`)は #2335 が決めた共有カーネル(実行の核と契約の一覧)の一部である。各コンテキストの入口は書き口を直に呼ばず、実行の文脈の `ctx.log` と、ライブラリの関数から呼ぶ `run_log.diag` の 2 つだけを使う。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 実行 | 実行の核(`run_core`)。行を書くのは書き口(`run_log`)だけ | 実行の識別子 | 実行の記録の行 | 全文ファイルの参照・伏せ字にした文字列 | +| 使用量の帳簿 | 帳簿の書き手(`usage_ledger`。持ち主は変えない) | 帳簿のファイル | 帳簿の行 | 実行の識別子(任意の鍵) | +| プランの状態の記録 | `RunState`(`supervise_lib/state.py`。持ち主は変えない) | `progress.jsonl` | 状態の行 | 実行の識別子(任意の鍵) | +| 例外リスト | 構造チェック(読むだけ)。行を消すのは入口を移した Pull Request | `scripts/script-structure-allow/` | 許可の行(検査の種類 `stderr-direct`) | — | + +**帳簿とプランの状態の記録は、実行の記録を識別子で参照するだけである。** 実行の記録の行を写さず、実行の記録も帳簿の行を写さない(要求の前提 3)。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 実行 | 実行の記録の行はすべて `run_id`・`at`(UTC の ISO 8601)・`event`・`entry`・`fields` を持つ。1 つの `run_id` について、`start` の行が最初、`end` の行が最後に来る(SIGKILL を除く) | 書き口が鍵の欠けた行を書かずに例外を投げる。核は `stopped` にする | +| I2 | 実行 | ファイルのすべての行が、それぞれ 1 つの JSON のオブジェクトとして読める。並行に書く子の実行の行が混ざらない | 1 行を排他(`fcntl.flock`)の中で 1 回の `write` で書く。書けなければ I7 | +| I3 | 実行 | 子の実行の行は親と同じファイルに書かれ、`parent_id` が親の `run_id` と一致する | 受け継いだファイルが無いときは起点の実行として自分のファイルを作り、診断を残す(決定 5) | +| I4 | 実行 | 1 行は 64 KiB 以下。超える行は、長い文字列から順に全文ファイルへ移し、行にはパス・文字数・sha256 を残す。文字列を途中で切らない | 移した後も超えるときは、`fields` を全文ファイルへ移した参照だけの行にする | +| I5 | 実行 | 実行の記録と全文ファイルに、秘密の値が書かれない。伏せる対象はトークンの形の文字列と、秘密と宣言した環境変数の空でない値 | 書く前に伏せる(`secret_redact.redact`。短く縮めない) | +| I6 | 実行 | 診断は、渡された表示用の文がそのまま標準エラーへ出る。記録へ書けても書けなくても、端末に出る文は変わらない | 標準エラーへ先に書き、記録へ書くのは後にする | +| I7 | 実行 | 記録へ書けないとき、始まりなら本体を動かさず、途中なら本体を終わりまで動かして、結果を `stopped` にする。どちらも標準エラーへ理由を出す | 核が結果を置き換える(決定 3) | +| I8 | 例外リスト | 検査の種類 `stderr-direct` の許可の行が、ベースブランチより増えない | 構造チェックが落とす(#2335 の I11 と同じ検査) | +| I9 | 使用量の帳簿・プランの状態の記録 | 核を通る起動の中(環境変数 `NDF_RUN_ID` がある)で追記した行は `run_id` を持つ。持たない行も今と同じ形で読める | 鍵を足すだけで、版を上げない(決定 10) | +| I10 | 実行 | 起点の実行の起動で、30 日を超えた日付のディレクトリが記録の置き場から消える。消せなくても本体は動く | 診断を書いて続ける | + +### ドメインイベント + +要求の E1〜E9 を引き継ぐ。 + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 実行の識別子を振った | 実行の核 | 書き口(`run_log.open_run`)・子プロセス(環境変数) | +| E2 | 記録の置き場を決め、始まりの行を書いた | 書き口 | 実行の記録 | +| E3 | 30 日を超えた日付のディレクトリを消した | 書き口(起点の実行だけ) | 記録の置き場 | +| E4 | 本体が判断・診断を書いた | 本体(`ctx.log`)・ライブラリ(`run_log.diag`) | 標準エラー・実行の記録 | +| E5 | シェルの本体が標準エラーへ書いた行を、診断として記録へ書いた | 実行の核(パイプの読み手) | 標準エラー・実行の記録 | +| E6 | 子の実行が親の識別子を持ち、同じファイルへ行を書いた | 子の実行の核 | 実行の記録 | +| E7 | 使用量の帳簿・プランの状態の記録の行へ実行の識別子を書いた | 帳簿の書き手・`RunState` | 帳簿・プランの状態の記録 | +| E8 | 本体が止まった理由を核が受けた | 実行の核 | 書き口(E9) | +| E9 | 終わりの行(結果 JSON の全体と終了コード)を書いた | 書き口 | 実行の記録・呼ぶ側 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 全文ファイル | 実行の記録の 1 行の上限を超える文字列を、切らずに全文で置くファイル。実行の記録と同じ日付のディレクトリに置き、行にはパス・文字数・sha256 を残す | 追加(`ndf-workflow`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 1 回の起動(子の実行を含む)の始まりから終わりまでを、1 つのファイルから実行の識別子で読む | conductor・利用者・振り返り | +| F2 | 診断を端末へ今と同じ文で出し、同じ文を記録にも残す | 利用者(端末)・振り返り(記録) | +| F3 | 止まった理由を切らずに結果 JSON と記録へ残し、人へ見せる表示だけを短くする | conductor・利用者 | +| F4 | 使用量の帳簿・プランの状態の記録の行から、その行を書いた実行の記録へたどる | 見積・使用量の集計・振り返り | +| F5 | 標準エラーへの直書きを構造チェックで落とし、例外リストを減る方向にだけ動かす | CI・入口を移す Pull Request | +| F6 | 30 日を超えた記録を消す | 利用者(状態の置き場の容量) | + +## 構成要素 + +| 要素 | 責務 | 新設 / 変更 | Pull Request | +| --- | --- | --- | --- | +| 書き口(`lib/run_log.py`) | 置き場を決め、行を伏せて排他の中で 1 回で書く。全文ファイルへ逃がす。診断を標準エラーと記録の両方へ出す。寿命の掃除。標準ライブラリだけを使う | 新設 | 核の段 | +| 実行の核(`lib/run_core.py`) | E1 で識別子を振って書き口を開き、`ctx.log` を書き口へつなぐ。E8 で理由を受け、E9 を `finally` と SIGTERM の受け手から呼ぶ。書けなかったら結果を `stopped` にする | 変更(P1 が作るスケルトンへ段を足す) | 核の段 | +| シェルの起こし直し(`run_core.py exec`) | シェルの本体の標準エラーをパイプで受け、1 行ずつ標準エラーへ出してから記録へ `diag` で書く | 変更(P1 が作る `exec` へ足す) | 核の段 | +| 秘密を伏せる部品(`lib/secret_redact.py`) | 伏せる処理(`redact`)と短く縮める処理(`shorten`)を分ける。`redact_output` は 2 つを順に呼ぶ形で残す | 変更 | 核の段 | +| 寿命の値(`lib/durable_keys.py`) | `KEEP_DAYS = 30` を持つ。`durable.py` と書き口がここから引く(`durable.py` は DBOS を読むため、書き口から import しない) | 変更(`durable.py` から移す) | 核の段 | +| 使用量の帳簿(`lib/usage_ledger.py`) | `UsageRecord` に任意の `run_id` を足し、既定値を環境変数 `NDF_RUN_ID` から取る | 変更 | 核の段 | +| プランの状態の記録(`supervise_lib/state.py`) | `progress_write` が書く行に、環境変数 `NDF_RUN_ID` があれば `run_id` を足す | 変更 | 核の段 | +| 環境変数の契約(`plugins/ndf/contracts/env/`) | `NDF_RUN_ID`・`NDF_RUN_LOG`・`NDF_STATE_DIR` の行を足す。秘密を持つ環境変数(`GH_TOKEN`・`GITHUB_TOKEN`・`AI_GATEWAY_API_KEY`・`SLACK_BOT_TOKEN`)の行へ `secret: true` を足す | 変更 | 核の段 | +| 契約の読み手(`scripts/contracts_lib/model.py`) | 環境変数の行の任意の鍵 `secret`(bool)を受けて確かめる | 変更 | 核の段 | +| 構造チェック(`scripts/check-script-structure.py`) | 検査の種類 `stderr-direct` を足す。例外リストの増加は既存の検査(#2335 の `allow-grow`)で落とす | 変更 | 核の段 | +| 例外リスト(`scripts/script-structure-allow/`) | 直書きを持つ 60 ファイルを `stderr-direct` の行として載せ、移した Pull Request が消す | 変更 | 核の段 → 移す 6 本 | +| 理由を切る 10 か所 | 下の「止まった理由を切る箇所の扱い」の表どおりに直す | 変更 | 核の段 | +| 直書きを持つ 60 ファイル | `print(..., file=sys.stderr)`・`sys.stderr.write` を `ctx.log("diag", ...)` か `run_log.diag(...)` へ置き換える | 変更 | 移す 6 本(下の「移す順序」) | + +```mermaid +graph TD + subgraph 入口 + PY[Python の本体] + LIB[ライブラリの関数] + SH[シェルの本体] + end + subgraph 実行の核 + K[核の流れ] + EX[シェルの起こし直し] + end + subgraph 書き口 + RL[run_log] + SR[秘密を伏せる部品] + KD[寿命の値] + end + subgraph 業務のデータ + UL[使用量の帳簿] + ST[プランの状態の記録] + end + PY -->|ctx.log| K + LIB -->|run_log.diag| RL + SH -->|標準エラー| EX + EX --> RL + K --> RL + RL --> SR + RL --> KD + UL -.->|NDF_RUN_ID を読む| K + ST -.->|NDF_RUN_ID を読む| K + CT[環境変数の契約] -->|秘密の名前| K + CK[構造チェック] -->|直書きを落とす| PY + CK --> LIB +``` + +### 配置 + +```mermaid +graph TD + subgraph 利用者の端末か CI + P[起点の実行] -->|NDF_RUN_ID と NDF_RUN_LOG を渡す| C[子の実行] + P -->|NDF_RUN_ID を渡す| A[claude・codex の起動] + A -->|hook と Bash の子| C + P --> E[標準エラー] + C --> E + end + subgraph 状態の置き場 NDF_STATE_DIR + RL[runs/リポジトリ/日付/起点の実行の識別子.jsonl] + FT[runs/リポジトリ/日付/起点の実行の識別子/全文ファイル] + UL[usage/ランタイム/リポジトリ.jsonl] + end + P -->|排他の中で追記| RL + C -->|排他の中で追記| RL + P --> FT + A -->|run_id つきの行| UL +``` + +### 置き場所 + +```text +plugins/ndf/ +├── contracts/env/ +│ ├── NDF_RUN_ID.json # 新設 +│ ├── NDF_RUN_LOG.json # 新設 +│ ├── NDF_STATE_DIR.json # 新設 +│ └── GH_TOKEN.json ほか 3 つ # 変更(secret: true) +└── scripts/ + ├── lib/ + │ ├── run_log.py # 新設 + │ ├── run_core.py # 変更(P1 が新設したものへ段を足す) + │ ├── secret_redact.py # 変更 + │ ├── durable_keys.py # 変更(KEEP_DAYS) + │ ├── durable.py # 変更(KEEP_DAYS を引く) + │ └── usage_ledger.py # 変更 + └── supervise_lib/state.py # 変更 +scripts/ +├── check-script-structure.py # 変更(stderr-direct) +├── contracts_lib/model.py # 変更(secret) +└── script-structure-allow/ # 変更(60 行を足し、移すたびに消す) +``` + +## 構造 + +```mermaid +classDiagram + class RunLog { + +run_id + +parent_id + +entry + +path + +failed + +write(event, fields) + +close(result, code) + } + class RunContext { + +run_id + +parent_id + +log(event, fields) + } + class run_log { + +open_run(entry, root, secrets) RunLog + +current() RunLog + +diag(text, short, fields) + +current_run_id() str + +purge(base, days) + } + RunContext --> "1" RunLog : log を渡す + run_log ..> RunLog : 開いたものを 1 つ持つ +``` + +- **プロセスに開いた記録は 1 つだけ**: `open_run` は開いた `RunLog` をモジュールの変数に持ち、`current()` で返す。核の外のプロセス(hook・テスト・核へ移す前の入口)では `None` で、`diag` は標準エラーへ書くだけになる +- **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種(`start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end`)だけを受け、ほかは `ValueError` +- **`run_log.diag(text, short=None, **fields)`**: 標準エラーへ `short` があれば `short`、無ければ `text` を 1 行で書き、記録へは `{"text": text, **fields}` を書く(`short` があれば `"display": short` も)。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ +- **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前に `failed` を見て結果を `stopped` にする(I7) + +## データ構造 + +### 実行の記録の行 + +鍵の形は #2335 の設計の「実行の記録の行」のとおりで、変えない。この柱は `fields` の中身を決める。 + +| `event` | `fields` | 書く者 | +| --- | --- | --- | +| `start` | `{"argv": [...], "cwd": str, "pid": int, "root": str}`(`argv` は伏せた後の値) | 核(E2) | +| `decision` | 本体が渡す任意のオブジェクト。止まった理由を持つときは `reason` の鍵に全文 | 本体 | +| `diag` | `{"text": str, "display": str?, "source": "py" / "sh", ...}` | `run_log.diag`・シェルの起こし直し | +| `end` | `{"result": <結果 JSON の全体>, "code": int, "how": "return" / "exception" / "sigterm" / "timeout", "error": str?}`(`error` は例外の型と理由の全文) | 核(E9) | + +`input`・`lease`・`ref`・`retry` の中身はそれぞれの柱(P8・P3・P5・P4)が決める。この柱は書き口だけを用意する。 + +**全文ファイルの参照**: 行の中の文字列を置き換える値は `{"$text": "<記録のファイルからの相対パス>", "chars": <文字数>, "sha256": "<全文の sha256>"}` である。全文ファイルの名前は `<起点の実行の識別子>/<実行の識別子>-<行の通し番号>-<鍵のパス>.txt` で、記録と同じ日付のディレクトリに置く。寿命の掃除で日付のディレクトリごと消える。 + +### 使用量の帳簿とプランの状態の記録 + +| ファイル | 足す鍵 | 型 | 空を許すか | 版 | +| --- | --- | --- | --- | --- | +| 使用量の帳簿の行 | `run_id` | str | 許す(核の外で書いた行は鍵を持たない) | `SCHEMA = 2` のまま | +| プランの状態の記録の行 | `run_id` | str | 許す(同上) | 版を持たない(今と同じ) | + +### 環境変数の契約の行 + +| 鍵 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `secret` | bool | 省略を許す(省略は偽) | 真なら、核はこの環境変数の空でない値を記録へ書く前に伏せる | + +`NDF_RUN_ID`(`set_by: ndf`。今の実行の識別子。子は親の識別子として読む)・`NDF_RUN_LOG`(`set_by: ndf`。起点の実行の記録のファイルの絶対パス)・`NDF_STATE_DIR`(`set_by: user`。状態の置き場の根)を足す。 + +### 機能とデータ + +| 機能 | 実行の記録 | 全文ファイル | 使用量の帳簿 | プランの状態の記録 | 例外リスト | +| --- | --- | --- | --- | --- | --- | +| F1 | 作る・読む | 作る・読む | — | — | — | +| F2 | 作る | — | — | — | — | +| F3 | 作る | 作る | — | — | — | +| F4 | 読む | — | 作る(`run_id`) | 作る(`run_id`) | — | +| F5 | — | — | — | — | 読む・消す | +| F6 | 消す | 消す | — | — | — | + +## 入出力の契約 + +| 項目 | 約束 | +| --- | --- | +| 子へ渡す環境変数 | 核は E1 の後に `NDF_RUN_ID` を自分の識別子へ、`NDF_RUN_LOG` を起点の実行の記録のファイルの絶対パスへ書き換える。子の核は起動時の `NDF_RUN_ID` を `parent_id` に取り、`NDF_RUN_LOG` のファイルへ書く | +| 受け継いだファイルが無い | `NDF_RUN_LOG` のファイルが無いか、置き場の外を指すとき、子は起点の実行として自分のファイルを作り、`start` の `fields` に `"inherited_log_missing": <パス>` を書く | +| 標準エラー | 診断の表示用の文を 1 行ずつ出す。文は移す前と同じ(受け入れ条件 6)。記録へ書けなかった理由は 1 回だけ `run_log: 実行の記録へ書けない(<パス>: <理由>)` の形で出す | +| 標準出力・結果 JSON | 変えない。鍵 `run_id` を足すのは P1 の決めたとおり。`summary`・`gaps` は本体が渡した文字列を切らずに出す | +| 終了コード | 変えない。記録へ書けなかったときの `stopped` は終了コード 1、`gaps` に `run-log-unwritable` | + +### 止まった理由を切る箇所の扱い + +**行き先で扱いを分ける。** 結果 JSON・例外の理由へ行く文字列は切らない。人へ見せる表示(端末・プランの状態の記録・周回の記録・報告の文)へ行く文字列は、切る前の全文を実行の記録へ書いてから、表示だけを短くする。 + +| 箇所(`plugins/ndf/scripts/` の下) | 今の行き先 | 扱い | +| --- | --- | --- | +| `project-mvv.py:184` | 結果の `summary`(形の誤りを先頭 10 件まで) | 切らずにすべて並べる | +| `supervise_lib/actuals.py:46` | プランの状態の記録の `reason` | 全文を `decision` で記録へ書き、状態の記録は 300 字のまま | +| `supervise_lib/engine.py:422` | プランの状態の記録の `summary` | 同上 | +| `supervise_lib/slow.py:202` | conductor 向けの注意の文 | 全文を `diag` で記録へ書き、注意の文は 200 字のまま | +| `merged_lib/merge.py:317` | 例外の理由(結果の `summary` になる) | 切らない | +| `lib/sprint_loop_gate.py:127` | 周回の記録の止まった理由 | 全文を `decision` で記録へ書き、周回の記録は 300 字のまま | +| `lib/sprint_loop_gate.py:128` | 結果の `summary` | 切らない | +| `lib/monitor_loop.py:120` | 端末の監視の行 | `run_log.diag(全文, short=先頭 200 字)` | +| `lib/monitor_loop.py:240` | 端末の監視の行 | 同上 | +| `release-steps.py:141` | ステップの結果の文(結果の `summary` になる) | 切らない | + +## 処理の流れ + +```mermaid +sequenceDiagram + participant C as 呼ぶ側 + participant K as 起点の実行の核 + participant W as 書き口 + participant B as 本体 + participant S as 子の実行の核 + C->>K: 起動 + K->>K: 識別子を振る(E1) + K->>W: open_run(E2) + alt 置き場を書けない + W-->>K: 書けない理由 + K-->>C: stopped・1(標準エラーに理由) + end + W->>W: 30 日を超えた日付を消す(E3) + K->>B: 本体を呼ぶ + B->>W: ctx.log / run_log.diag(E4) + B->>S: 子プロセス(NDF_RUN_ID・NDF_RUN_LOG) + S->>W: 同じファイルへ start〜end(E6) + alt 途中で書けなくなる + W->>W: failed を持ち、標準エラーへ 1 回だけ理由 + B->>K: 本体は終わりまで動く + end + B-->>K: 結果・例外・SIGTERM・時間切れ(E8) + K->>W: end(結果 JSON と終了コード、E9) + K-->>C: 結果 JSON 1 行(failed なら stopped) +``` + +図に描かない要素: シェルの起こし直し(E5。`exec` の子の標準エラーを 1 行ずつ `diag` へ渡す読み手のスレッドで、本体の位置に入る)・秘密を伏せる部品と寿命の値(書き口の中で使う)・使用量の帳簿とプランの状態の記録(E7。本体の中で書かれ、環境変数を読むだけ)・環境変数の契約と契約の読み手(E1 の前に核が秘密の名前を読む)・構造チェックと例外リスト(CI で動く)・理由を切る 10 か所と直書きを持つ 60 ファイル(本体の中身)。 + +**1 行の書き方**: 伏せる → JSON にする → 64 KiB を超えたら長い文字列から全文ファイルへ移す → 記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取り、`os.write` を 1 回呼び、排他を返す。全文ファイルは記録の行より先に書く(行が全文ファイルを指した時点で、全文ファイルが在る)。 + +**SIGTERM と時間切れ**: 核の受け手が E9 を呼ぶ。`flock` は同じプロセスでも開き直したファイル記述子どうしで排他になるため、排他を持つ間に受け手が割り込んで同じ排他を取りにいくと止まる。そこで `write` は排他を取る前に `signal.pthread_sigmask` で SIGTERM と時間切れのシグナル(SIGALRM)を止め、排他を返した後に戻す。受け手は書きかけの行が書き終わってから動き、`end` の行は最後に並ぶ。 + +### 書き口の状態 + +```mermaid +stateDiagram-v2 + [*] --> 閉じている: プロセスの起動 + 閉じている --> 開いている: open_run が start を書いた + 閉じている --> 閉じている: open_run が書けない(核が stopped) + 開いている --> 開いている: write・diag + 開いている --> 書けない: write が失敗した + 書けない --> 書けない: write は標準エラーにだけ残す + 開いている --> 終わった: close が end を書いた + 書けない --> 終わった: close(核が stopped にする) + 終わった --> [*] +``` + +## 移す順序 + +直書きを持つ対象のファイルは 60(125 か所)である。数は 2026-11-04 の develop(729dcbe40)で、要求の「調べ方」のコマンドからファイルごとの数を取り、hook の入口(`hooks/*.json` が呼ぶ `context-policy.py`・`hook.py`・`relay.py`・`usage-subagent.py`・`training-optout.py` と、SessionStart が呼ぶ `hook-env.py`。計 6 か所)を除いて数えた。順は #2335 の部品の順で、1 スプリントに 3 本までとする(要求の前提 8)。 + +| スプリント | Pull Request | 対象(`plugins/ndf/scripts/` の下。`skills/` は `plugins/ndf/skills/` の下) | ファイル | か所 | +| --- | --- | --- | --- | --- | +| 1 | 核の段 | 書き口・核・帳簿・状態の記録・契約・構造チェック・例外リスト(60 行)・理由を切る 10 か所 | — | — | +| 1 | G1 supervise のプラン・判定・キュー | `supervise_lib/` の 10 ファイル・`phase-steps.py`・`sprint-close.py`・`lib/loop_drive.py`・`lib/post_queue.py` | 14 | 29 | +| 1 | G2 cross-refactoring と review | `skills/cross-refactoring/scripts/` の 3 ファイル・`refactor-scope.py`・`lib/result_posts.py`・`lib/review_criteria.py`・`lib/parallel_lane.py` | 7 | 16 | +| 2 | G3 課題を閉じる処理・リリース | `release-steps.py`・`release_lib/declared_steps.py`・`merged_lib/checks.py`・`lib/body_findings.py`・`lib/gh_parts.py`・`skills/fix/scripts/fix-steps.py`・`skills/backlog-refinement/scripts/upkeep_gh.py` | 7 | 8 | +| 2 | G4 外部 CLI の監視と中継 | `lib/monitor.py`・`lib/monitor_loop.py`・`lib/monitor_outcome.py`・`lib/proc.py`・`lib/limits.py`・`lib/runtime_policy.py`・`lib/container_reach.py`・`skills/external-ai/scripts/external-ai.py`・`relay_lib/` の 6 ファイル | 14 | 36 | +| 2 | G5 使用量の帳簿 | `lib/usage_capture.py`・`lib/usage_ledger.py`・`lib/transcript_agents.py`・`lib/run_metrics.py`・`lib/whole_runs.py`・`skills/skill-stats/scripts/skill-stats.py`・`parallel-measure.py` | 7 | 18 | +| 3 | G6 残り | `check-trigger.py`・`glossary.py`・`instructions-check.py`・`instructions_lib/report.py`・`lib/deps.py`・`lib/durable.py`・`lib/legacy_names.py`・`lib/step_result.py`・`lib/tool_paths.py`・`lib/worktree_deps.py`・`project-mvv.py` | 11 | 18 | + +数え直すと、G1〜G6 のファイルは 14+7+7+14+7+11=60、か所は 29+16+8+36+18+18=125 で、対象の数と一致する。受け入れ条件 4(子の課題 8 件の判定)は G6 をマージした後に行う。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 運用・保守性 | 1 回の起動を実行の識別子 1 つで、1 ファイルから追う。寿命 30 日 | 起点の実行ごとに 1 ファイル。子は `NDF_RUN_LOG` で同じファイルへ書く。寿命は `durable_keys.KEEP_DAYS` を引き、起点の実行の起動で `runs/*/<日付>` のうち日付が 30 日より前のディレクトリを消す(ディレクトリの名前の日付で判定し、更新時刻を見ない) | 終わり方ごとのテスト・子の実行のテスト・寿命のテスト(テスト設計) | +| 移行性 | 部品の順・例外リストで並走・Pull Request ごとの revert で戻る・識別子の無い行も読める | 書き口は核の外では標準エラーへ書くだけになるため、移した入口と移す前の入口が並んで動く。核の段の Pull Request を revert すると、移した入口の `run_log.diag` も標準エラーだけへ書く | 柱の Pull Request ごとに全体テストと構造チェックを通す | +| セキュリティ | 受け入れ条件 5。C1・C2 は変えない | 書く前に `secret_redact.redact`(トークンの形)と、秘密と宣言した環境変数の空でない値の置き換えを通す。全文ファイルにも同じ処理を通す。秘密の値そのものは記録にも契約にも書かない(契約には `secret: true` だけ) | 秘密を入れた環境変数で起動するテスト | +| システム環境 | Claude Code と Codex の両方で同じ核。シェルは bash | 書き口は標準ライブラリ(`json`・`os`・`fcntl`・`hashlib`)だけを使い、extras の無い環境でも動く | extras を外した環境で診断が出るテスト | + +## 決定の記録 + +決定の記録は [issue-2338-design-decisions.md](issue-2338-design-decisions.md) にある。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 1・I1 | 核を通る入口を正常・例外・SIGTERM・時間切れで終わらせると、1 ファイルに同じ `run_id` で `start` から `end` までが並び、すべての行が 5 つの鍵を持つ | SIGTERM の受け手から E9 を外す/`at` を地方時で書く | +| 1a・I3 | 入口が別の入口を子プロセスで起こすと、子の行が親のファイルに入り、`parent_id` が親の `run_id` | 子へ `NDF_RUN_LOG` を渡さない/子が自分の識別子を `parent_id` に書く | +| 1b・I2 | 子の実行を並行に複数起こし、64 KiB 近い行を書かせても、全行が JSON として読める | 排他を外して 2 回の `write` に分ける | +| 1c | 本体の `decision` と止まった理由が行に残り、`end` の `fields` が標準出力の結果 JSON と終了コードに一致する | `end` に `summary` だけを書く | +| 1d・I9 | 核の中で追記した帳簿の行とプランの状態の記録の行が `run_id` を持ち、核の外で書いた行は鍵を持たずに今の読み手で読める | `run_id` を必須にする/読み手が未知の鍵で落ちる | +| 1e・I10 | 31 日前の日付のディレクトリが消え、29 日前は残る。消せない(権限)ときは `diag` が残り本体の結果が変わらない | 更新時刻で判定する/消せない例外で止まる | +| 1f・I7 | 置き場を書けない場所にすると、標準エラーに理由が出て `stopped`・1。途中で書けなくすると本体は終わりまで動き、結果が `stopped` で `gaps` に `run-log-unwritable` | 書けない例外を握りつぶして `ok` を返す | +| 2・I8 | 対象のファイルに `print(..., file=sys.stderr)` か `sys.stderr.write` を書くと構造チェックが落とす。例外リストに 1 行足すと落とす。書き口の中は落とさない | 検査を `file=sys.stderr` の文字列一致にして `file=sys.stdout if c else sys.stderr` を通す | +| 2a | `run_log.diag` の文が標準エラーと記録の `diag` の両方に出る。シェルの本体の `>&2` の行も `diag`(`source: sh`)で残る | シェルの標準エラーを記録へ書かずに捨てる | +| 2b | 移した Pull Request で例外リストの行が消え、残った行が `unused-allow` で落ちる | 移した後も行を残す | +| 3・I4 | 本体の渡した 10,000 字の `summary` が、結果 JSON と `end` の行に同じ文字列で残る。10 か所の行き先ごとの扱いが表のとおり | 理由を 300 字で切る | +| 3a・I4 | 64 KiB を超える理由は全文ファイルへ移り、行のパス・文字数・sha256 が全文と一致する。全文ファイルが行より先に在る | 全文ファイルを書かずに参照だけ残す/長い文字列を切る | +| 4 | (手動)G6 の後に子の課題 8 件の再現の手順を 1 回ずつ通し、本文の「子の課題の判定」へ書く | — | +| 5・I5 | `secret: true` の環境変数に値を入れ、本体がその値を診断・例外の理由・`summary` へ入れても、記録と全文ファイルに値が無い。トークンの形の文字列も無い。空文字列の値で他の文字列が壊れない | 伏せる処理を全文ファイルに通さない/空の値で置き換える | +| 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。記録の置き場を書けないときも同じ文が出る | 記録へ書いた後でしか標準エラーへ書かない | +| 7 | 柱の Pull Request ごとに `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | — | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2338 | 実装する | — | `plugins/ndf/scripts/lib/run_log.py`、`plugins/ndf/scripts/lib/run_core.py`、`plugins/ndf/scripts/lib/secret_redact.py`、`plugins/ndf/scripts/lib/durable_keys.py`、`plugins/ndf/scripts/lib/durable.py`、`plugins/ndf/scripts/lib/usage_ledger.py`、`plugins/ndf/scripts/supervise_lib/state.py`、`plugins/ndf/contracts/env/`、`scripts/contracts_lib/model.py`、`scripts/check-script-structure.py`、`scripts/script-structure-allow/`、`scripts/tests/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/scripts/project-mvv.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/merged_lib/merge.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/monitor_loop.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/phase-steps.py`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/lib/loop_drive.py`、`plugins/ndf/scripts/lib/post_queue.py`、`plugins/ndf/scripts/refactor-scope.py`、`plugins/ndf/scripts/lib/result_posts.py`、`plugins/ndf/scripts/lib/review_criteria.py`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/skills/cross-refactoring/scripts/`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +触るファイルはスプリント 1(核の段・G1・G2)の分である。G3〜G6 は同じ柱の次のスプリントの設計の結果へ載せる。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| P1 の核の形 | `run_core.py` は 2026-11-04 の時点で無い(#2337 は OPEN)。この設計は #2335 の設計の `RunContext`・`main`・`exec`・SIGTERM の受け手の形を前提にした。P1 の実装で形が変わったときは、核の段の Pull Request で呼ぶ位置だけを合わせる | +| Codex の hook と Bash の子 | `NDF_RUN_ID` が hook へ届くことは Claude Code の hook で確かめた。Codex の hook と、エージェントの Bash ツールが起こす子プロセスへ届くかは未確認。届かないときも、子は起点の実行として自分のファイルを作るだけで、記録は失われない | +| 直書きの別の形 | 検査は `print` の `file` の式に `sys.stderr` を含むものと `sys.stderr.write` を落とす。変数へ入れてから書く形(`err = sys.stderr`)と、`sys.stderr` を参照するほかの 11 か所(`flush`・`argparse` の `print_usage`・`subprocess` の出力先・DBOS のハンドラ)は対象にしない | +| 正規表現に当たらない切る箇所 | 要求の「調べ方」の正規表現に当たらない書き方(`textwrap.shorten`・変数名の違い)で切る箇所は数えていない | +| 問い合わせの記録 | hook が書く `ask.jsonl` の行へ実行の識別子を足すかは、hook の変更として #2335 の対象範囲の外に置いた(決定 4) | From d44b4fc6dcd4866767d279a06ed458c21a021a36 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 15:45:00 +0000 Subject: [PATCH 03/11] =?UTF-8?q?Docs:=20#2338=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99=EF=BC=88=E9=95=B7=E3=81=84=E6=96=87=E3=82=92=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=80=81=E5=88=97=E6=8C=99=E3=82=92=E8=A1=A8=E3=81=A8?= =?UTF-8?q?=E3=83=AA=E3=82=B9=E3=83=88=E3=81=B8=E7=A7=BB=E3=81=97=E3=80=81?= =?UTF-8?q?=E3=80=8C=E6=A0=B8=E3=81=AE=E6=AE=B5=E3=80=8D=E3=82=92=E3=80=8C?= =?UTF-8?q?=E6=A0=B8=E3=81=AE=E6=8B=A1=E5=BC=B5=E3=80=8D=E3=81=B8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内容は足さずに、100 字を超える文の分割・図に描かない要素の列挙の表への移し・1 行の書き方の手順の番号付きリストへの移しを行った。 章の並びは設計テンプレートの順と確定事項を伝える文書の構成に合っているため変えていない。 「段」が層・工程・Pull Request の単位のどれを指すか読み取れなかったため、Pull Request の名前を「核の拡張」へそろえた。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design-decisions.md | 45 +++++++------- issues/issue-2338-design.md | 84 ++++++++++++++++++--------- 2 files changed, 79 insertions(+), 50 deletions(-) diff --git a/issues/issue-2338-design-decisions.md b/issues/issue-2338-design-decisions.md index b79a35f8c..eda4f6937 100644 --- a/issues/issue-2338-design-decisions.md +++ b/issues/issue-2338-design-decisions.md @@ -7,12 +7,12 @@ ### 決定 1: 並行に書く子の実行の行が混ざらないよう、1 行を排他の中で 1 回の書き込みで書く 記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取ってから、1 行を `os.write` の 1 回で書く。 -Linux の通常のファイルへの `O_APPEND` の書き込みは 1 回なら他と混ざりにくいが、書き込みが途中で分かれる場合(シグナル・容量)を -排他なしでは防げない。排他を置けば、行の大きさに関わらず混ざらないことを書き口だけで保証できる。 +Linux の通常のファイルへの `O_APPEND` の書き込みは、1 回なら他と混ざりにくい。 +ただし書き込みが途中で分かれる場合(シグナル・容量)は、排他なしでは防げない。排他を置けば、行の大きさに関わらず混ざらないことを書き口だけで保証できる。 -`filelock`(extras の `locks`)は採らない。書き口は extras の無い環境でも診断を残す必要があり(`deps.py` の診断も書き口を通る)、 +`filelock`(extras の `locks`)は採らない。書き口は extras の無い環境でも診断を残す必要がある(`deps.py` の診断も書き口を通る)。 標準ライブラリの `fcntl` で足りる。PIPE_BUF(4,096 バイト)以下なら排他なしで済む前提も採らない。通常のファイルへの書き込みには -その保証が無く、上限を 4 KiB にすると使用量の帳簿の最長の行(1,929 文字)の 2 倍程度しか余裕が無い。 +その保証が無い。上限を 4 KiB にすると、使用量の帳簿の最長の行(1,929 文字)の 2 倍程度しか余裕が無い。 根拠: Value 7 / Value 6(MVV 版 5) @@ -32,8 +32,8 @@ Linux の通常のファイルへの `O_APPEND` の書き込みは 1 回なら ### 決定 3: 途中で記録へ書けなくなっても外の世界を中途半端に残さないよう、本体は終わりまで動かし、結果だけを `stopped` にする -本体の途中で止めると、push の後・Pull Request を作る前のような半端な状態が残り、利用者が手で戻すことになる。 -終わりまで動かして結果を `stopped` にすれば、呼ぶ側は止まったことを知り、本体の結果は `end` の行(書ければ)と標準エラーの理由から読める。 +本体の途中で止めると、push の後・Pull Request を作る前のような半端な状態が残る。利用者がそれを手で戻すことになる。 +終わりまで動かして結果を `stopped` にすれば、呼ぶ側は止まったことを知る。本体の結果は、`end` の行(書ければ)と標準エラーの理由から読める。 理由は標準エラーへ 1 回だけ出し、以後の診断は標準エラーにだけ出る(端末の文は変わらない)。 始まりの行を書けないときは本体を動かさない(要求の前提 6)。この時点ではまだ外の世界を変えていないためである。 @@ -44,9 +44,9 @@ Linux の通常のファイルへの `O_APPEND` の書き込みは 1 回なら ### 決定 4: hook の変更を #2335 の対象範囲の外に保つため、問い合わせの記録(`ask.jsonl`)の行へ実行の識別子を足さない -核が起動した claude の hook へ環境変数 `NDF_RUN_ID` が届くことは、2026-11-04 に Claude Code 2.1.318 で確かめた(設計文書の「あるべき姿の根拠」)。 -足すこと自体はできるが、問い合わせの記録を書くのは hook で、hook の書く記録の形を変えることは要求の「含まない」にある。 -使用量の帳簿は hook(`usage-subagent.py`)も書くが、帳簿の行を作る `UsageRecord` はライブラリにあり、その既定値として環境変数を読むだけで +核が起動した claude の hook へ環境変数 `NDF_RUN_ID` が届くことは確かめた(2026-11-04、Claude Code 2.1.318。設計文書の「あるべき姿の根拠」)。 +足すこと自体はできる。ただし問い合わせの記録を書くのは hook であり、hook の書く記録の形を変えることは要求の「含まない」にある。 +使用量の帳簿は hook(`usage-subagent.py`)も書く。ただし帳簿の行を作る `UsageRecord` はライブラリにあり、その既定値として環境変数を読むだけなので、 hook の本体は変わらない。 問い合わせの記録への追加は、hook を核へ移す変更(#2335 の対象範囲の外)で扱う。範囲外の課題としては起票しない @@ -56,8 +56,8 @@ hook の本体は変わらない。 ### 決定 5: 子の実行が親と別のリポジトリや日付で動いても同じファイルへ書けるよう、ファイルの絶対パスを `NDF_RUN_LOG` で渡す -置き場のパスは起点の実行のリポジトリと UTC の日付を含む。子が自分のリポジトリと日付からパスを作ると、別のリポジトリで動く子(リリースの -配布先)と、UTC の 0 時をまたいだ子が別のファイルへ書く。親が自分の識別子(`NDF_RUN_ID`)とファイルのパス(`NDF_RUN_LOG`)の 2 つを渡せば、 +置き場のパスは起点の実行のリポジトリと UTC の日付を含む。子が自分のリポジトリと日付からパスを作ると、次の 2 つの子が別のファイルへ書く。別のリポジトリで動く子(リリースの +配布先)と、UTC の 0 時をまたいだ子である。親が自分の識別子(`NDF_RUN_ID`)とファイルのパス(`NDF_RUN_LOG`)の 2 つを渡せば、 子は計算せずに同じファイルへ書ける。 `NDF_RUN_ID` から起点の識別子をたどってパスを作る形は採らない。識別子の連鎖(子の子)をたどるために起点の識別子をもう 1 つ渡すことになり、 @@ -68,17 +68,17 @@ hook の本体は変わらない。 ### 決定 6: hook や移す前の入口が他人の記録へ書かないよう、`run_log.diag` は自分のプロセスで記録を開いたときだけ記録へ書く -ライブラリの関数は hook からも呼ばれる。hook は核が起動した claude の下で動くと `NDF_RUN_LOG` を受け継ぐが、hook 自身は核を通らず実行の識別子を -持たない。環境変数だけで書くと、識別子の無い行が親の記録へ混ざり、I1 が破れる。プロセスの中で `open_run` が開いた記録があるときだけ書けば、 +ライブラリの関数は hook からも呼ばれる。hook は核が起動した claude の下で動くと `NDF_RUN_LOG` を受け継ぐ。一方で hook 自身は核を通らず、実行の識別子を +持たない。環境変数だけで書くと、識別子の無い行が親の記録へ混ざり、I1 が破れる。プロセスの中で `open_run` が開いた記録があるときだけ書く。そうすれば 核の外では標準エラーへ書くだけになり、移す前の入口とも並んで動く。 根拠: Value 7(MVV 版 5) ### 決定 7: 診断の経路を 1 つにして種類と項目を残せるよう、Python は書き口の関数へ置き換え、シェルは核が標準エラーをパイプで受ける -Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブジェクトへ差し替える形は採らない。書き込みの単位が行にならず(`print` の -`end` と区切りが別の書き込みになる)、診断の種類と項目を付けられず、`argparse` や DBOS の出力まで混ざる。差し替えで足りるなら構造チェックが -要らないことになり、要求の受け入れ条件 2 の「1 つの関数を通る」が検査できなくなる。 +Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブジェクトへ差し替える形は採らない。書き込みの単位が行にならない(`print` の +`end` と区切りが別の書き込みになる)。診断の種類と項目を付けられず、`argparse` や DBOS の出力まで混ざる。差し替えで足りるなら構造チェックが +要らないことになる。そうなると、要求の受け入れ条件 2 の「1 つの関数を通る」が検査できない。 シェルの本体の `>&2` は 203 か所・24 ファイルあり、要求の前提 4 のとおり書き換えない。核は `exec` で起こし直した子の標準エラーをパイプで受け、 1 行ずつ標準エラーへ出してから記録へ書く。スクリプトに標準エラーを端末か判定する箇所が無い(設計文書の「あるべき姿の根拠」)ため、 @@ -88,7 +88,7 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ ### 決定 8: 任意の形の認証情報も記録へ残さないよう、トークンの形の置き換えに加え、秘密と宣言した環境変数の値を伏せる -`secret_redact` の置き換えは決まった形(GitHub のトークン・URL の資格情報・認証ヘッダー)だけを伏せ、`AI_GATEWAY_API_KEY` のような +`secret_redact` の置き換えは、決まった形(GitHub のトークン・URL の資格情報・認証ヘッダー)だけを伏せる。`AI_GATEWAY_API_KEY` のような 任意の形の値は伏せない。環境変数の契約の行に `secret: true` を足し、核がその空でない値を書く前に置き換える。空の値は照合しない (空文字列で置き換えると全ての文字の間に伏せ字が入る。#2335 の I9 と同じ扱い)。 @@ -100,7 +100,7 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ ### 決定 9: 止まった理由を失わずに表示の短さを保つため、行き先が結果 JSON か例外なら切らず、人へ見せる表示なら全文を記録へ書いてから表示だけを切る 受け入れ条件 3 は、結果 JSON と記録の `end` の行に同じ文字列を残すことを求める。結果 JSON へ行く文字列を切らなければ、`end` の行は結果 JSON の -全体を持つため両方がそろう。端末・プランの状態の記録・周回の記録の文を切らずに長くすると、利用者の端末の文が変わり(受け入れ条件 6)、 +全体を持つため両方がそろう。端末・プランの状態の記録・周回の記録の文を切らずに長くすると、利用者の端末の文が変わる(受け入れ条件 6)。 状態の記録の読み手(`supervise.py` の進捗の要約)の表示も崩れる。そこで表示は今の長さのまま残し、全文は記録に置く。 すべての箇所で切るのをやめる形は採らない。表示の文が変わり、受け入れ条件 6 に反する。 @@ -109,7 +109,7 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ ### 決定 10: 既存の読み手を書き直さないよう、帳簿とプランの状態の記録には任意の鍵 `run_id` を足すだけにし、版を上げない -鍵を足すだけなら、今の読み手(見積・使用量の集計・進捗の要約)は未知の鍵を読み飛ばし、識別子の無い古い行もそのまま読める。版を上げると +鍵を足すだけなら、今の読み手(見積・使用量の集計・進捗の要約)は未知の鍵を読み飛ばす。識別子の無い古い行もそのまま読める。版を上げると 移行関数が要り、柱 P6(#2339)の型の部品より先に版を動かすことになる。値は書き手の引数ではなく、`UsageRecord` の既定値と `progress_write` の中で 環境変数 `NDF_RUN_ID` から取る。書き手の呼び出し元(`UsageRecord` を作る 2 か所と `progress_write` の呼び出し 15 か所。2026-11-04 に `grep -rn` で数えた)を書き換えずに済む。 @@ -123,10 +123,9 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ 根拠: Value 5 / Value 7(MVV 版 5) -### 決定 12: 1 スプリントで人が読み切れる量に収めるため、移す Pull Request を部品の順に 6 本へ分け、スプリント 1 は核の段と G1・G2 にする +### 決定 12: 1 スプリントで人が読み切れる量に収めるため、移す Pull Request を部品の順に 6 本へ分け、スプリント 1 は核の拡張と G1・G2 にする -対象は 60 ファイル・125 か所で、1 本にすると差分が大きく、壊れたときに 1 回の revert で戻す範囲も大きい。#2335 の部品の順(supervise → -cross-refactoring → 課題を閉じる処理・リリース → 外部 CLI の監視 → 使用量の帳簿 → 残り)で 6 つに分け、1 スプリントは 3 本までとする -(要求の前提 8)。子の課題 8 件の判定は、すべてを移した後に 1 回だけ行う。 +対象は 60 ファイル・125 か所である。1 本にすると差分が大きく、壊れたときに 1 回の revert で戻す範囲も大きい。#2335 の部品の順で 6 つに分け、1 スプリントは 3 本までとする(要求の前提 8)。 +部品の順は supervise → cross-refactoring → 課題を閉じる処理・リリース → 外部 CLI の監視 → 使用量の帳簿 → 残り である。子の課題 8 件の判定は、すべてを移した後に 1 回だけ行う。 根拠: Value 2(MVV 版 5) diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index 31f81881c..949c29332 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -9,8 +9,8 @@ ## 適用範囲 - **働く範囲**: NDF の配布物(`plugins/ndf/scripts` と `plugins/ndf/skills/*/scripts`。テスト・`experimental/`・hook の入口を除く)。配布先のリポジトリでも同じ書き口が動く -- **プロジェクトごとに違うもの**: 記録の置き場の根は環境変数 `NDF_STATE_DIR` で受ける(既定 `~/.local/state/ndf`)。リポジトリごとの値は置き場のパスの 1 段(`__`)にだけ現れ、書き口の中に ai-plugins の値を置かない -- **当たるモード**: `standard`。柱 P1(#2337)の核のスケルトンに段を足す柱で、実装は核の段の Pull Request 1 本と入口を移す Pull Request 6 本(3 スプリント)に分かれる +- **プロジェクトごとに違うもの**: 記録の置き場の根は環境変数 `NDF_STATE_DIR` で受ける(既定 `~/.local/state/ndf`)。リポジトリごとの値は置き場のパスの 1 階層(`__`)にだけ現れ、書き口の中に ai-plugins の値を置かない +- **当たるモード**: `standard`。柱 P1(#2337)の核のスケルトンを拡張する柱で、実装は核の拡張の Pull Request 1 本と入口を移す Pull Request 6 本(3 スプリント)に分かれる ## あるべき姿の根拠 @@ -21,8 +21,9 @@ | `NDF_RUN_ID=20261104T000000-test-123456 claude -p ... --settings ` を起動し、hook が書いたファイルに `run=20261104T000000-test-123456` が出た(Claude Code 2.1.318) | 実測(2026-11-04) | 核が起動した claude の hook は環境変数 `NDF_RUN_ID` を受け取れること(要求の未決 3。決定 4) | | `plugins/ndf/scripts` で `grep -rn "isatty\|\[ -t [012] \]"` の結果は 6 件で、すべて標準入力か標準出力の判定。標準エラー(2 番)を端末か判定する箇所は 0 件 | 実測(2026-11-04) | シェルの本体の標準エラーを核がパイプで受けても、本体の振る舞いが変わらないこと(決定 7) | -要求と受け入れ条件は #2338 の本文にある(写しは [issue-2338-requirements.md](issue-2338-requirements.md) )。この文書は「どう作るか」だけを扱う。 -決定の記録は [issue-2338-design-decisions.md](issue-2338-design-decisions.md) にある。実行の記録の行の形・置き場・寿命は #2335 の設計([issue-2335-design.md](issue-2335-design.md) )が決めたものを前提にし、この文書は書き込みの方式と移し方を決める。 +要求と受け入れ条件は #2338 の本文にある(コピーは [issue-2338-requirements.md](issue-2338-requirements.md) )。この文書は「どう作るか」だけを扱う。 +決定の記録は [issue-2338-design-decisions.md](issue-2338-design-decisions.md) にある。 +実行の記録の行の形・置き場・寿命は、#2335 の設計([issue-2335-design.md](issue-2335-design.md) )が決めたものを前提にする。この文書は書き込みの方式と移し方を決める。 ## ドメインモデル @@ -97,18 +98,18 @@ | 要素 | 責務 | 新設 / 変更 | Pull Request | | --- | --- | --- | --- | -| 書き口(`lib/run_log.py`) | 置き場を決め、行を伏せて排他の中で 1 回で書く。全文ファイルへ逃がす。診断を標準エラーと記録の両方へ出す。寿命の掃除。標準ライブラリだけを使う | 新設 | 核の段 | -| 実行の核(`lib/run_core.py`) | E1 で識別子を振って書き口を開き、`ctx.log` を書き口へつなぐ。E8 で理由を受け、E9 を `finally` と SIGTERM の受け手から呼ぶ。書けなかったら結果を `stopped` にする | 変更(P1 が作るスケルトンへ段を足す) | 核の段 | -| シェルの起こし直し(`run_core.py exec`) | シェルの本体の標準エラーをパイプで受け、1 行ずつ標準エラーへ出してから記録へ `diag` で書く | 変更(P1 が作る `exec` へ足す) | 核の段 | -| 秘密を伏せる部品(`lib/secret_redact.py`) | 伏せる処理(`redact`)と短く縮める処理(`shorten`)を分ける。`redact_output` は 2 つを順に呼ぶ形で残す | 変更 | 核の段 | -| 寿命の値(`lib/durable_keys.py`) | `KEEP_DAYS = 30` を持つ。`durable.py` と書き口がここから引く(`durable.py` は DBOS を読むため、書き口から import しない) | 変更(`durable.py` から移す) | 核の段 | -| 使用量の帳簿(`lib/usage_ledger.py`) | `UsageRecord` に任意の `run_id` を足し、既定値を環境変数 `NDF_RUN_ID` から取る | 変更 | 核の段 | -| プランの状態の記録(`supervise_lib/state.py`) | `progress_write` が書く行に、環境変数 `NDF_RUN_ID` があれば `run_id` を足す | 変更 | 核の段 | -| 環境変数の契約(`plugins/ndf/contracts/env/`) | `NDF_RUN_ID`・`NDF_RUN_LOG`・`NDF_STATE_DIR` の行を足す。秘密を持つ環境変数(`GH_TOKEN`・`GITHUB_TOKEN`・`AI_GATEWAY_API_KEY`・`SLACK_BOT_TOKEN`)の行へ `secret: true` を足す | 変更 | 核の段 | -| 契約の読み手(`scripts/contracts_lib/model.py`) | 環境変数の行の任意の鍵 `secret`(bool)を受けて確かめる | 変更 | 核の段 | -| 構造チェック(`scripts/check-script-structure.py`) | 検査の種類 `stderr-direct` を足す。例外リストの増加は既存の検査(#2335 の `allow-grow`)で落とす | 変更 | 核の段 | -| 例外リスト(`scripts/script-structure-allow/`) | 直書きを持つ 60 ファイルを `stderr-direct` の行として載せ、移した Pull Request が消す | 変更 | 核の段 → 移す 6 本 | -| 理由を切る 10 か所 | 下の「止まった理由を切る箇所の扱い」の表どおりに直す | 変更 | 核の段 | +| 書き口(`lib/run_log.py`) | 置き場を決め、行を伏せて排他の中で 1 回で書く。全文ファイルへ逃がす。診断を標準エラーと記録の両方へ出す。寿命の掃除。標準ライブラリだけを使う | 新設 | 核の拡張 | +| 実行の核(`lib/run_core.py`) | E1 で識別子を振って書き口を開き、`ctx.log` を書き口へつなぐ。E8 で理由を受け、E9 を `finally` と SIGTERM の受け手から呼ぶ。書けなかったら結果を `stopped` にする | 変更(P1 が作るスケルトンを拡張する) | 核の拡張 | +| シェルの起こし直し(`run_core.py exec`) | シェルの本体の標準エラーをパイプで受け、1 行ずつ標準エラーへ出してから記録へ `diag` で書く | 変更(P1 が作る `exec` へ足す) | 核の拡張 | +| 秘密を伏せる部品(`lib/secret_redact.py`) | 伏せる処理(`redact`)と短く縮める処理(`shorten`)を分ける。`redact_output` は 2 つを順に呼ぶ形で残す | 変更 | 核の拡張 | +| 寿命の値(`lib/durable_keys.py`) | `KEEP_DAYS = 30` を持つ。`durable.py` と書き口がここから引く(`durable.py` は DBOS を読むため、書き口から import しない) | 変更(`durable.py` から移す) | 核の拡張 | +| 使用量の帳簿(`lib/usage_ledger.py`) | `UsageRecord` に任意の `run_id` を足し、既定値を環境変数 `NDF_RUN_ID` から取る | 変更 | 核の拡張 | +| プランの状態の記録(`supervise_lib/state.py`) | `progress_write` が書く行に、環境変数 `NDF_RUN_ID` があれば `run_id` を足す | 変更 | 核の拡張 | +| 環境変数の契約(`plugins/ndf/contracts/env/`) | `NDF_RUN_ID`・`NDF_RUN_LOG`・`NDF_STATE_DIR` の行を足す。秘密を持つ環境変数(`GH_TOKEN`・`GITHUB_TOKEN`・`AI_GATEWAY_API_KEY`・`SLACK_BOT_TOKEN`)の行へ `secret: true` を足す | 変更 | 核の拡張 | +| 契約の読み手(`scripts/contracts_lib/model.py`) | 環境変数の行の任意の鍵 `secret`(bool)を受けて確かめる | 変更 | 核の拡張 | +| 構造チェック(`scripts/check-script-structure.py`) | 検査の種類 `stderr-direct` を足す。例外リストの増加は既存の検査(#2335 の `allow-grow`)で落とす | 変更 | 核の拡張 | +| 例外リスト(`scripts/script-structure-allow/`) | 直書きを持つ 60 ファイルを `stderr-direct` の行として載せ、移した Pull Request が消す | 変更 | 核の拡張 → 移す 6 本 | +| 理由を切る 10 か所 | 下の「止まった理由を切る箇所の扱い」の表どおりに直す | 変更 | 核の拡張 | | 直書きを持つ 60 ファイル | `print(..., file=sys.stderr)`・`sys.stderr.write` を `ctx.log("diag", ...)` か `run_log.diag(...)` へ置き換える | 変更 | 移す 6 本(下の「移す順序」) | ```mermaid @@ -179,7 +180,7 @@ plugins/ndf/ └── scripts/ ├── lib/ │ ├── run_log.py # 新設 - │ ├── run_core.py # 変更(P1 が新設したものへ段を足す) + │ ├── run_core.py # 変更(P1 が新設したものを拡張する) │ ├── secret_redact.py # 変更 │ ├── durable_keys.py # 変更(KEEP_DAYS) │ ├── durable.py # 変更(KEEP_DAYS を引く) @@ -221,8 +222,8 @@ classDiagram ``` - **プロセスに開いた記録は 1 つだけ**: `open_run` は開いた `RunLog` をモジュールの変数に持ち、`current()` で返す。核の外のプロセス(hook・テスト・核へ移す前の入口)では `None` で、`diag` は標準エラーへ書くだけになる -- **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種(`start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end`)だけを受け、ほかは `ValueError` -- **`run_log.diag(text, short=None, **fields)`**: 標準エラーへ `short` があれば `short`、無ければ `text` を 1 行で書き、記録へは `{"text": text, **fields}` を書く(`short` があれば `"display": short` も)。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ +- **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種だけを受け、ほかは `ValueError`。8 種は `start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end` である +- **`run_log.diag(text, short=None, **fields)`**: 標準エラーへは `short` があれば `short`、無ければ `text` を 1 行で書く。記録へは `{"text": text, **fields}` を書き、`short` があれば `"display": short` も書く。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ - **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前に `failed` を見て結果を `stopped` にする(I7) ## データ構造 @@ -240,7 +241,13 @@ classDiagram `input`・`lease`・`ref`・`retry` の中身はそれぞれの柱(P8・P3・P5・P4)が決める。この柱は書き口だけを用意する。 -**全文ファイルの参照**: 行の中の文字列を置き換える値は `{"$text": "<記録のファイルからの相対パス>", "chars": <文字数>, "sha256": "<全文の sha256>"}` である。全文ファイルの名前は `<起点の実行の識別子>/<実行の識別子>-<行の通し番号>-<鍵のパス>.txt` で、記録と同じ日付のディレクトリに置く。寿命の掃除で日付のディレクトリごと消える。 +**全文ファイルの参照**: 行の中の文字列を置き換える値は次の形である。 + +```json +{"$text": "<記録のファイルからの相対パス>", "chars": <文字数>, "sha256": "<全文の sha256>"} +``` + +全文ファイルの名前は `<起点の実行の識別子>/<実行の識別子>-<行の通し番号>-<鍵のパス>.txt` で、記録と同じ日付のディレクトリに置く。寿命の掃除で日付のディレクトリごと消える。 ### 使用量の帳簿とプランの状態の記録 @@ -325,9 +332,26 @@ sequenceDiagram K-->>C: 結果 JSON 1 行(failed なら stopped) ``` -図に描かない要素: シェルの起こし直し(E5。`exec` の子の標準エラーを 1 行ずつ `diag` へ渡す読み手のスレッドで、本体の位置に入る)・秘密を伏せる部品と寿命の値(書き口の中で使う)・使用量の帳簿とプランの状態の記録(E7。本体の中で書かれ、環境変数を読むだけ)・環境変数の契約と契約の読み手(E1 の前に核が秘密の名前を読む)・構造チェックと例外リスト(CI で動く)・理由を切る 10 か所と直書きを持つ 60 ファイル(本体の中身)。 +図に描かない要素は次のとおりである。 + +| 要素 | 図のどこで動くか | +| --- | --- | +| シェルの起こし直し(E5) | 本体の位置に入る。`exec` の子の標準エラーを 1 行ずつ `diag` へ渡す読み手のスレッド | +| 秘密を伏せる部品・寿命の値 | 書き口の中で使う | +| 使用量の帳簿・プランの状態の記録(E7) | 本体の中で書かれ、環境変数を読むだけ | +| 環境変数の契約・契約の読み手 | E1 の前に、核が秘密の名前を読む | +| 構造チェック・例外リスト | CI で動く | +| 理由を切る 10 か所・直書きを持つ 60 ファイル | 本体の中身 | + +**1 行の書き方**: 次の順で書く。 + +1. 伏せる +2. JSON にする +3. 64 KiB を超えたら、長い文字列から全文ファイルへ移す +4. 記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取る +5. `os.write` を 1 回呼び、排他を返す -**1 行の書き方**: 伏せる → JSON にする → 64 KiB を超えたら長い文字列から全文ファイルへ移す → 記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取り、`os.write` を 1 回呼び、排他を返す。全文ファイルは記録の行より先に書く(行が全文ファイルを指した時点で、全文ファイルが在る)。 +全文ファイルは記録の行より先に書く。行が全文ファイルを指した時点で、全文ファイルが在る。 **SIGTERM と時間切れ**: 核の受け手が E9 を呼ぶ。`flock` は同じプロセスでも開き直したファイル記述子どうしで排他になるため、排他を持つ間に受け手が割り込んで同じ排他を取りにいくと止まる。そこで `write` は排他を取る前に `signal.pthread_sigmask` で SIGTERM と時間切れのシグナル(SIGALRM)を止め、排他を返した後に戻す。受け手は書きかけの行が書き終わってから動き、`end` の行は最後に並ぶ。 @@ -348,11 +372,17 @@ stateDiagram-v2 ## 移す順序 -直書きを持つ対象のファイルは 60(125 か所)である。数は 2026-11-04 の develop(729dcbe40)で、要求の「調べ方」のコマンドからファイルごとの数を取り、hook の入口(`hooks/*.json` が呼ぶ `context-policy.py`・`hook.py`・`relay.py`・`usage-subagent.py`・`training-optout.py` と、SessionStart が呼ぶ `hook-env.py`。計 6 か所)を除いて数えた。順は #2335 の部品の順で、1 スプリントに 3 本までとする(要求の前提 8)。 +直書きを持つ対象のファイルは 60(125 か所)である。数は 2026-11-04 の develop(729dcbe40)で、要求の「調べ方」のコマンドからファイルごとに取った。 +hook の入口の 6 か所は、次のとおり数から除いた。 + +- `hooks/*.json` が呼ぶ 5 つ: `context-policy.py`・`hook.py`・`relay.py`・`usage-subagent.py`・`training-optout.py` +- SessionStart が呼ぶ 1 つ: `hook-env.py` + +順は #2335 の部品の順で、1 スプリントに 3 本までとする(要求の前提 8)。 | スプリント | Pull Request | 対象(`plugins/ndf/scripts/` の下。`skills/` は `plugins/ndf/skills/` の下) | ファイル | か所 | | --- | --- | --- | --- | --- | -| 1 | 核の段 | 書き口・核・帳簿・状態の記録・契約・構造チェック・例外リスト(60 行)・理由を切る 10 か所 | — | — | +| 1 | 核の拡張 | 書き口・核・帳簿・状態の記録・契約・構造チェック・例外リスト(60 行)・理由を切る 10 か所 | — | — | | 1 | G1 supervise のプラン・判定・キュー | `supervise_lib/` の 10 ファイル・`phase-steps.py`・`sprint-close.py`・`lib/loop_drive.py`・`lib/post_queue.py` | 14 | 29 | | 1 | G2 cross-refactoring と review | `skills/cross-refactoring/scripts/` の 3 ファイル・`refactor-scope.py`・`lib/result_posts.py`・`lib/review_criteria.py`・`lib/parallel_lane.py` | 7 | 16 | | 2 | G3 課題を閉じる処理・リリース | `release-steps.py`・`release_lib/declared_steps.py`・`merged_lib/checks.py`・`lib/body_findings.py`・`lib/gh_parts.py`・`skills/fix/scripts/fix-steps.py`・`skills/backlog-refinement/scripts/upkeep_gh.py` | 7 | 8 | @@ -367,7 +397,7 @@ stateDiagram-v2 | 大項目 | 要求の条件 | 実現方式 | 確かめ方 | | --- | --- | --- | --- | | 運用・保守性 | 1 回の起動を実行の識別子 1 つで、1 ファイルから追う。寿命 30 日 | 起点の実行ごとに 1 ファイル。子は `NDF_RUN_LOG` で同じファイルへ書く。寿命は `durable_keys.KEEP_DAYS` を引き、起点の実行の起動で `runs/*/<日付>` のうち日付が 30 日より前のディレクトリを消す(ディレクトリの名前の日付で判定し、更新時刻を見ない) | 終わり方ごとのテスト・子の実行のテスト・寿命のテスト(テスト設計) | -| 移行性 | 部品の順・例外リストで並走・Pull Request ごとの revert で戻る・識別子の無い行も読める | 書き口は核の外では標準エラーへ書くだけになるため、移した入口と移す前の入口が並んで動く。核の段の Pull Request を revert すると、移した入口の `run_log.diag` も標準エラーだけへ書く | 柱の Pull Request ごとに全体テストと構造チェックを通す | +| 移行性 | 部品の順・例外リストで並走・Pull Request ごとの revert で戻る・識別子の無い行も読める | 書き口は核の外では標準エラーへ書くだけになるため、移した入口と移す前の入口が並んで動く。核の拡張の Pull Request を revert すると、移した入口の `run_log.diag` も標準エラーだけへ書く | 柱の Pull Request ごとに全体テストと構造チェックを通す | | セキュリティ | 受け入れ条件 5。C1・C2 は変えない | 書く前に `secret_redact.redact`(トークンの形)と、秘密と宣言した環境変数の空でない値の置き換えを通す。全文ファイルにも同じ処理を通す。秘密の値そのものは記録にも契約にも書かない(契約には `secret: true` だけ) | 秘密を入れた環境変数で起動するテスト | | システム環境 | Claude Code と Codex の両方で同じ核。シェルは bash | 書き口は標準ライブラリ(`json`・`os`・`fcntl`・`hashlib`)だけを使い、extras の無い環境でも動く | extras を外した環境で診断が出るテスト | @@ -402,13 +432,13 @@ stateDiagram-v2 | --- | --- | --- | --- | | #2338 | 実装する | — | `plugins/ndf/scripts/lib/run_log.py`、`plugins/ndf/scripts/lib/run_core.py`、`plugins/ndf/scripts/lib/secret_redact.py`、`plugins/ndf/scripts/lib/durable_keys.py`、`plugins/ndf/scripts/lib/durable.py`、`plugins/ndf/scripts/lib/usage_ledger.py`、`plugins/ndf/scripts/supervise_lib/state.py`、`plugins/ndf/contracts/env/`、`scripts/contracts_lib/model.py`、`scripts/check-script-structure.py`、`scripts/script-structure-allow/`、`scripts/tests/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/scripts/project-mvv.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/merged_lib/merge.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/monitor_loop.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/phase-steps.py`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/lib/loop_drive.py`、`plugins/ndf/scripts/lib/post_queue.py`、`plugins/ndf/scripts/refactor-scope.py`、`plugins/ndf/scripts/lib/result_posts.py`、`plugins/ndf/scripts/lib/review_criteria.py`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/skills/cross-refactoring/scripts/`、`docs/glossary/glossary.json`、`docs/glossary.md` | -触るファイルはスプリント 1(核の段・G1・G2)の分である。G3〜G6 は同じ柱の次のスプリントの設計の結果へ載せる。 +触るファイルはスプリント 1(核の拡張・G1・G2)の分である。G3〜G6 は同じ柱の次のスプリントの設計の結果へ載せる。 ## 未確認のまま残ること | 項目 | 内容 | | --- | --- | -| P1 の核の形 | `run_core.py` は 2026-11-04 の時点で無い(#2337 は OPEN)。この設計は #2335 の設計の `RunContext`・`main`・`exec`・SIGTERM の受け手の形を前提にした。P1 の実装で形が変わったときは、核の段の Pull Request で呼ぶ位置だけを合わせる | +| P1 の核の形 | `run_core.py` は 2026-11-04 の時点で無い(#2337 は OPEN)。この設計は #2335 の設計の `RunContext`・`main`・`exec`・SIGTERM の受け手の形を前提にした。P1 の実装で形が変わったときは、核の拡張の Pull Request で呼ぶ位置だけを合わせる | | Codex の hook と Bash の子 | `NDF_RUN_ID` が hook へ届くことは Claude Code の hook で確かめた。Codex の hook と、エージェントの Bash ツールが起こす子プロセスへ届くかは未確認。届かないときも、子は起点の実行として自分のファイルを作るだけで、記録は失われない | | 直書きの別の形 | 検査は `print` の `file` の式に `sys.stderr` を含むものと `sys.stderr.write` を落とす。変数へ入れてから書く形(`err = sys.stderr`)と、`sys.stderr` を参照するほかの 11 か所(`flush`・`argparse` の `print_usage`・`subprocess` の出力先・DBOS のハンドラ)は対象にしない | | 正規表現に当たらない切る箇所 | 要求の「調べ方」の正規表現に当たらない書き方(`textwrap.shorten`・変数名の違い)で切る箇所は数えていない | From 670a3ac91f5aa8d0d1cd661349db3828d0cf33fa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 16:16:11 +0000 Subject: [PATCH 04/11] =?UTF-8?q?Docs:=20#2338=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=A7=E3=80=81=E3=82=B7=E3=82=B0=E3=83=8A=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E5=8F=97=E3=81=91=E6=89=8B=E3=83=BB=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=AE=E7=BD=AE=E3=81=8D=E5=A0=B4=E3=83=BB=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=AE=E5=A4=B1=E6=95=97=E6=99=82=E3=81=AE=E7=B5=90?= =?UTF-8?q?=E6=9E=9C=E3=83=BB=E6=A8=99=E6=BA=96=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E3=83=91=E3=82=A4=E3=83=97=E5=8C=96=E3=81=AE=E6=89=B1?= =?UTF-8?q?=E3=81=84=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - シグナルの受け手は主スレッドで動くため、排他の区間の中では end を書かずに保留の印を残し、排他を返した直後に書く - ルートの conftest.py でテストの実行中だけ NDF_STATE_DIR を一時ディレクトリへ向ける - 途中で記録へ書けなくなったときは、本体の元の結果 JSON を original_result に入れて出す - シェルの標準エラーをパイプにしても端末の文が変わらないことは未確認として載せ、核の拡張で比べて確かめる Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design-decisions.md | 10 +++++++--- issues/issue-2338-design.md | 23 +++++++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/issues/issue-2338-design-decisions.md b/issues/issue-2338-design-decisions.md index eda4f6937..dec985136 100644 --- a/issues/issue-2338-design-decisions.md +++ b/issues/issue-2338-design-decisions.md @@ -33,7 +33,9 @@ Linux の通常のファイルへの `O_APPEND` の書き込みは、1 回なら ### 決定 3: 途中で記録へ書けなくなっても外の世界を中途半端に残さないよう、本体は終わりまで動かし、結果だけを `stopped` にする 本体の途中で止めると、push の後・Pull Request を作る前のような半端な状態が残る。利用者がそれを手で戻すことになる。 -終わりまで動かして結果を `stopped` にすれば、呼ぶ側は止まったことを知る。本体の結果は、`end` の行(書ければ)と標準エラーの理由から読める。 +終わりまで動かして結果を `stopped` にすれば、呼ぶ側は止まったことを知る。途中で書けなくなったときは `end` の行も書けないため、核は本体の元の結果 JSON を +`stopped` の結果の鍵 `original_result` へ入れて標準出力へ出す。本体が済ませた外の操作の結果(作った Pull Request の番号など)が呼ぶ側に届き、 +呼ぶ側が再実行して同じものを二重に作ることを防ぐ。 理由は標準エラーへ 1 回だけ出し、以後の診断は標準エラーにだけ出る(端末の文は変わらない)。 始まりの行を書けないときは本体を動かさない(要求の前提 6)。この時点ではまだ外の世界を変えていないためである。 @@ -81,8 +83,10 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ 要らないことになる。そうなると、要求の受け入れ条件 2 の「1 つの関数を通る」が検査できない。 シェルの本体の `>&2` は 203 か所・24 ファイルあり、要求の前提 4 のとおり書き換えない。核は `exec` で起こし直した子の標準エラーをパイプで受け、 -1 行ずつ標準エラーへ出してから記録へ書く。スクリプトに標準エラーを端末か判定する箇所が無い(設計文書の「あるべき姿の根拠」)ため、 -パイプにしても振る舞いは変わらない。 +1 行ずつ標準エラーへ出してから記録へ書く。確かめたのは、スクリプト自身に標準エラーを端末か判定する箇所が無いこと(設計文書の「あるべき姿の根拠」)までである。 +シェルの本体が呼ぶ外部コマンド(git・gh・uv・claude など)は、標準エラーが端末かで進捗表示・色・対話の出力を変えることがある。 +行単位で転送するため、改行の無い進捗の表示も遅れる。端末の文が変わらないこと(受け入れ条件 6)は未確認で、設計文書の「未確認のまま残ること」に載せ、 +核の拡張の Pull Request で代表的なシェルの入口の出力を比べて確かめる。 根拠: Value 5 / Value 7(MVV 版 5) diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index 949c29332..b2bea487e 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -19,7 +19,7 @@ | 利用者の指摘(2026-11-01)「異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない」 | 利用者の指示の原文 | 診断の経路を 1 つにし、型としてまとめて防ぐこと | | 手元のプランの状態の記録 293 ファイル・1,778 行の長さ: 中央値 199 バイト・99 パーセンタイル 507 バイト・最大 570 バイト。使用量の帳簿の最長の行は 1,929 文字 | 実測(2026-11-04、`LC_ALL=C awk '{print length($0)}'`) | 1 行の上限 64 KiB は今の最長の行の 30 倍以上の余裕を持ち、通常の行は全文ファイルへ逃がさずに済むこと(決定 2) | | `NDF_RUN_ID=20261104T000000-test-123456 claude -p ... --settings ` を起動し、hook が書いたファイルに `run=20261104T000000-test-123456` が出た(Claude Code 2.1.318) | 実測(2026-11-04) | 核が起動した claude の hook は環境変数 `NDF_RUN_ID` を受け取れること(要求の未決 3。決定 4) | -| `plugins/ndf/scripts` で `grep -rn "isatty\|\[ -t [012] \]"` の結果は 6 件で、すべて標準入力か標準出力の判定。標準エラー(2 番)を端末か判定する箇所は 0 件 | 実測(2026-11-04) | シェルの本体の標準エラーを核がパイプで受けても、本体の振る舞いが変わらないこと(決定 7) | +| `plugins/ndf/scripts` で `grep -rn "isatty\|\[ -t [012] \]"` の結果は 6 件で、すべて標準入力か標準出力の判定。標準エラー(2 番)を端末か判定する箇所は 0 件 | 実測(2026-11-04) | スクリプト自身は標準エラーが端末かで振る舞いを変えないこと。シェルの本体が呼ぶ外部コマンドの振る舞いは示さない(決定 7・「未確認のまま残ること」) | 要求と受け入れ条件は #2338 の本文にある(コピーは [issue-2338-requirements.md](issue-2338-requirements.md) )。この文書は「どう作るか」だけを扱う。 決定の記録は [issue-2338-design-decisions.md](issue-2338-design-decisions.md) にある。 @@ -56,7 +56,7 @@ | I4 | 実行 | 1 行は 64 KiB 以下。超える行は、長い文字列から順に全文ファイルへ移し、行にはパス・文字数・sha256 を残す。文字列を途中で切らない | 移した後も超えるときは、`fields` を全文ファイルへ移した参照だけの行にする | | I5 | 実行 | 実行の記録と全文ファイルに、秘密の値が書かれない。伏せる対象はトークンの形の文字列と、秘密と宣言した環境変数の空でない値 | 書く前に伏せる(`secret_redact.redact`。短く縮めない) | | I6 | 実行 | 診断は、渡された表示用の文がそのまま標準エラーへ出る。記録へ書けても書けなくても、端末に出る文は変わらない | 標準エラーへ先に書き、記録へ書くのは後にする | -| I7 | 実行 | 記録へ書けないとき、始まりなら本体を動かさず、途中なら本体を終わりまで動かして、結果を `stopped` にする。どちらも標準エラーへ理由を出す | 核が結果を置き換える(決定 3) | +| I7 | 実行 | 記録へ書けないとき、始まりなら本体を動かさず、途中なら本体を終わりまで動かして、結果を `stopped` にし、本体の元の結果 JSON を `original_result` の鍵に入れて標準出力へ出す。どちらも標準エラーへ理由を出す | 核が結果を置き換える(決定 3) | | I8 | 例外リスト | 検査の種類 `stderr-direct` の許可の行が、ベースブランチより増えない | 構造チェックが落とす(#2335 の I11 と同じ検査) | | I9 | 使用量の帳簿・プランの状態の記録 | 核を通る起動の中(環境変数 `NDF_RUN_ID` がある)で追記した行は `run_id` を持つ。持たない行も今と同じ形で読める | 鍵を足すだけで、版を上げない(決定 10) | | I10 | 実行 | 起点の実行の起動で、30 日を超えた日付のディレクトリが記録の置き場から消える。消せなくても本体は動く | 診断を書いて続ける | @@ -107,6 +107,7 @@ | プランの状態の記録(`supervise_lib/state.py`) | `progress_write` が書く行に、環境変数 `NDF_RUN_ID` があれば `run_id` を足す | 変更 | 核の拡張 | | 環境変数の契約(`plugins/ndf/contracts/env/`) | `NDF_RUN_ID`・`NDF_RUN_LOG`・`NDF_STATE_DIR` の行を足す。秘密を持つ環境変数(`GH_TOKEN`・`GITHUB_TOKEN`・`AI_GATEWAY_API_KEY`・`SLACK_BOT_TOKEN`)の行へ `secret: true` を足す | 変更 | 核の拡張 | | 契約の読み手(`scripts/contracts_lib/model.py`) | 環境変数の行の任意の鍵 `secret`(bool)を受けて確かめる | 変更 | 核の拡張 | +| テストの状態の置き場(ルートの `conftest.py`) | session の autouse の fixture で、テストの実行中だけ `NDF_STATE_DIR` を一時ディレクトリへ向ける。全体テストが実行した人の置き場へ記録を書かず、寿命の掃除も本物の置き場で動かさない(`NDF_CONTEXT_DIR`・`NDF_METRICS_DIR` と同じ形) | 変更 | 核の拡張 | | 構造チェック(`scripts/check-script-structure.py`) | 検査の種類 `stderr-direct` を足す。例外リストの増加は既存の検査(#2335 の `allow-grow`)で落とす | 変更 | 核の拡張 | | 例外リスト(`scripts/script-structure-allow/`) | 直書きを持つ 60 ファイルを `stderr-direct` の行として載せ、移した Pull Request が消す | 変更 | 核の拡張 → 移す 6 本 | | 理由を切る 10 か所 | 下の「止まった理由を切る箇所の扱い」の表どおりに直す | 変更 | 核の拡張 | @@ -284,6 +285,7 @@ classDiagram | 標準エラー | 診断の表示用の文を 1 行ずつ出す。文は移す前と同じ(受け入れ条件 6)。記録へ書けなかった理由は 1 回だけ `run_log: 実行の記録へ書けない(<パス>: <理由>)` の形で出す | | 標準出力・結果 JSON | 変えない。鍵 `run_id` を足すのは P1 の決めたとおり。`summary`・`gaps` は本体が渡した文字列を切らずに出す | | 終了コード | 変えない。記録へ書けなかったときの `stopped` は終了コード 1、`gaps` に `run-log-unwritable` | +| 途中で書けなくなったときの結果 JSON | 核は本体の元の結果 JSON(本体が例外で終わったときは `null`)を、`stopped` の結果の鍵 `original_result` へ切らずに入れて標準出力へ出す。本体が済ませた外の操作の結果(作った Pull Request の番号など)を、呼ぶ側が記録を読まずに受け取れる。始まりの行を書けないときは本体を動かさないため、鍵を持たない | ### 止まった理由を切る箇所の扱い @@ -329,7 +331,7 @@ sequenceDiagram end B-->>K: 結果・例外・SIGTERM・時間切れ(E8) K->>W: end(結果 JSON と終了コード、E9) - K-->>C: 結果 JSON 1 行(failed なら stopped) + K-->>C: 結果 JSON 1 行(failed なら stopped で original_result に元の結果) ``` 図に描かない要素は次のとおりである。 @@ -353,7 +355,13 @@ sequenceDiagram 全文ファイルは記録の行より先に書く。行が全文ファイルを指した時点で、全文ファイルが在る。 -**SIGTERM と時間切れ**: 核の受け手が E9 を呼ぶ。`flock` は同じプロセスでも開き直したファイル記述子どうしで排他になるため、排他を持つ間に受け手が割り込んで同じ排他を取りにいくと止まる。そこで `write` は排他を取る前に `signal.pthread_sigmask` で SIGTERM と時間切れのシグナル(SIGALRM)を止め、排他を返した後に戻す。受け手は書きかけの行が書き終わってから動き、`end` の行は最後に並ぶ。 +**SIGTERM と時間切れ**: CPython の Python で書いたシグナルの受け手は、どのスレッドがシグナルを受けても主スレッドで動く。`signal.pthread_sigmask` は呼んだスレッドにしか効かないため、受け手が主スレッドの排他の区間の中で動くことを防げない。核の `exec` の読み手のスレッドと、G4 で移す監視・中継のスレッドがこの条件を満たす。`flock` は同じプロセスでも開き直したファイル記述子どうしで排他になるため、受け手が排他の区間の中で `end` を書きにいくと、自分自身を待って止まる。そこで次の形にする。 + +1. 書き口はプロセスに 1 つの `threading.Lock` でスレッドどうしの書き込みを順に並べ、その中で `flock` を取る。主スレッドが排他の区間にいる間は、スレッドごとの印(`threading.local`)を立てる +2. 核の受け手(SIGTERM と時間切れのシグナル SIGALRM)は、主スレッドが排他の区間にいなければ、その場で E9 を呼んで `end` を書く +3. 主スレッドが排他の区間にいれば、受け手は `end` を書かずに、終わり方(`sigterm` / `timeout`)を保留の印として残して戻る。`write` は排他を返した直後に保留の印を見て、印があれば E9 を呼ぶ +4. ほかのスレッドが排他を持つ間に受け手が動いたときは、そのスレッドが排他を返すのを待ってから書く(待つ相手が別のスレッドのため止まらない) +5. `close` が `end` を書いた後の `write` は、記録へ書かずに捨てる。読み手のスレッドが後から書いても、`end` の行は最後に並ぶ ### 書き口の状態 @@ -410,12 +418,13 @@ hook の入口の 6 か所は、次のとおり数から除いた。 | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | | 1・I1 | 核を通る入口を正常・例外・SIGTERM・時間切れで終わらせると、1 ファイルに同じ `run_id` で `start` から `end` までが並び、すべての行が 5 つの鍵を持つ | SIGTERM の受け手から E9 を外す/`at` を地方時で書く | +| 1・I1 | 読み手のスレッドが書き続ける間に、主スレッドが排他の区間にいる時点で SIGTERM と SIGALRM を送っても、入口が時間内に終わり、`end` の行が最後に並ぶ | 受け手が排他の区間の中で `end` を書きにいく/`end` の後の `write` を記録へ書く | | 1a・I3 | 入口が別の入口を子プロセスで起こすと、子の行が親のファイルに入り、`parent_id` が親の `run_id` | 子へ `NDF_RUN_LOG` を渡さない/子が自分の識別子を `parent_id` に書く | | 1b・I2 | 子の実行を並行に複数起こし、64 KiB 近い行を書かせても、全行が JSON として読める | 排他を外して 2 回の `write` に分ける | | 1c | 本体の `decision` と止まった理由が行に残り、`end` の `fields` が標準出力の結果 JSON と終了コードに一致する | `end` に `summary` だけを書く | | 1d・I9 | 核の中で追記した帳簿の行とプランの状態の記録の行が `run_id` を持ち、核の外で書いた行は鍵を持たずに今の読み手で読める | `run_id` を必須にする/読み手が未知の鍵で落ちる | | 1e・I10 | 31 日前の日付のディレクトリが消え、29 日前は残る。消せない(権限)ときは `diag` が残り本体の結果が変わらない | 更新時刻で判定する/消せない例外で止まる | -| 1f・I7 | 置き場を書けない場所にすると、標準エラーに理由が出て `stopped`・1。途中で書けなくすると本体は終わりまで動き、結果が `stopped` で `gaps` に `run-log-unwritable` | 書けない例外を握りつぶして `ok` を返す | +| 1f・I7 | 置き場を書けない場所にすると、標準エラーに理由が出て `stopped`・1。途中で書けなくすると本体は終わりまで動き、結果が `stopped` で `gaps` に `run-log-unwritable`、`original_result` に本体の元の結果 JSON がそのまま入る | 書けない例外を握りつぶして `ok` を返す/`stopped` に置き換えるときに本体の結果を捨てる | | 2・I8 | 対象のファイルに `print(..., file=sys.stderr)` か `sys.stderr.write` を書くと構造チェックが落とす。例外リストに 1 行足すと落とす。書き口の中は落とさない | 検査を `file=sys.stderr` の文字列一致にして `file=sys.stdout if c else sys.stderr` を通す | | 2a | `run_log.diag` の文が標準エラーと記録の `diag` の両方に出る。シェルの本体の `>&2` の行も `diag`(`source: sh`)で残る | シェルの標準エラーを記録へ書かずに捨てる | | 2b | 移した Pull Request で例外リストの行が消え、残った行が `unused-allow` で落ちる | 移した後も行を残す | @@ -424,13 +433,14 @@ hook の入口の 6 か所は、次のとおり数から除いた。 | 4 | (手動)G6 の後に子の課題 8 件の再現の手順を 1 回ずつ通し、本文の「子の課題の判定」へ書く | — | | 5・I5 | `secret: true` の環境変数に値を入れ、本体がその値を診断・例外の理由・`summary` へ入れても、記録と全文ファイルに値が無い。トークンの形の文字列も無い。空文字列の値で他の文字列が壊れない | 伏せる処理を全文ファイルに通さない/空の値で置き換える | | 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。記録の置き場を書けないときも同じ文が出る | 記録へ書いた後でしか標準エラーへ書かない | +| 1e | 全体テストの後に、テストの外の `NDF_STATE_DIR`(未設定なら `~/.local/state/ndf`)の `runs/` の中身が増えも減りもしない | ルートの `conftest.py` から `NDF_STATE_DIR` の fixture を外す | | 7 | 柱の Pull Request ごとに `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | — | ## 設計の結果 | 課題 | 扱い | 取り込み先 | 触るファイル | | --- | --- | --- | --- | -| #2338 | 実装する | — | `plugins/ndf/scripts/lib/run_log.py`、`plugins/ndf/scripts/lib/run_core.py`、`plugins/ndf/scripts/lib/secret_redact.py`、`plugins/ndf/scripts/lib/durable_keys.py`、`plugins/ndf/scripts/lib/durable.py`、`plugins/ndf/scripts/lib/usage_ledger.py`、`plugins/ndf/scripts/supervise_lib/state.py`、`plugins/ndf/contracts/env/`、`scripts/contracts_lib/model.py`、`scripts/check-script-structure.py`、`scripts/script-structure-allow/`、`scripts/tests/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/scripts/project-mvv.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/merged_lib/merge.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/monitor_loop.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/phase-steps.py`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/lib/loop_drive.py`、`plugins/ndf/scripts/lib/post_queue.py`、`plugins/ndf/scripts/refactor-scope.py`、`plugins/ndf/scripts/lib/result_posts.py`、`plugins/ndf/scripts/lib/review_criteria.py`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/skills/cross-refactoring/scripts/`、`docs/glossary/glossary.json`、`docs/glossary.md` | +| #2338 | 実装する | — | `conftest.py`、`plugins/ndf/scripts/lib/run_log.py`、`plugins/ndf/scripts/lib/run_core.py`、`plugins/ndf/scripts/lib/secret_redact.py`、`plugins/ndf/scripts/lib/durable_keys.py`、`plugins/ndf/scripts/lib/durable.py`、`plugins/ndf/scripts/lib/usage_ledger.py`、`plugins/ndf/scripts/supervise_lib/state.py`、`plugins/ndf/contracts/env/`、`scripts/contracts_lib/model.py`、`scripts/check-script-structure.py`、`scripts/script-structure-allow/`、`scripts/tests/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/scripts/project-mvv.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/merged_lib/merge.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/monitor_loop.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/phase-steps.py`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/lib/loop_drive.py`、`plugins/ndf/scripts/lib/post_queue.py`、`plugins/ndf/scripts/refactor-scope.py`、`plugins/ndf/scripts/lib/result_posts.py`、`plugins/ndf/scripts/lib/review_criteria.py`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/skills/cross-refactoring/scripts/`、`docs/glossary/glossary.json`、`docs/glossary.md` | 触るファイルはスプリント 1(核の拡張・G1・G2)の分である。G3〜G6 は同じ柱の次のスプリントの設計の結果へ載せる。 @@ -441,5 +451,6 @@ hook の入口の 6 か所は、次のとおり数から除いた。 | P1 の核の形 | `run_core.py` は 2026-11-04 の時点で無い(#2337 は OPEN)。この設計は #2335 の設計の `RunContext`・`main`・`exec`・SIGTERM の受け手の形を前提にした。P1 の実装で形が変わったときは、核の拡張の Pull Request で呼ぶ位置だけを合わせる | | Codex の hook と Bash の子 | `NDF_RUN_ID` が hook へ届くことは Claude Code の hook で確かめた。Codex の hook と、エージェントの Bash ツールが起こす子プロセスへ届くかは未確認。届かないときも、子は起点の実行として自分のファイルを作るだけで、記録は失われない | | 直書きの別の形 | 検査は `print` の `file` の式に `sys.stderr` を含むものと `sys.stderr.write` を落とす。変数へ入れてから書く形(`err = sys.stderr`)と、`sys.stderr` を参照するほかの 11 か所(`flush`・`argparse` の `print_usage`・`subprocess` の出力先・DBOS のハンドラ)は対象にしない | +| シェルの標準エラーをパイプにした影響 | シェルの本体が呼ぶ外部コマンド(git・gh・uv・claude など)は、標準エラーが端末かで進捗表示・色・対話の出力を変えることがある。行単位で転送するため、改行の無い進捗の表示も遅れる。`plugins/ndf/scripts` と `plugins/ndf/skills` のシェルの本体に `read -p` は 0 件(2026-11-04、`grep -nE 'read +-[a-z]*p'`)。受け入れ条件 6 を満たすかは、核の拡張の Pull Request で、代表的なシェルの入口を標準エラーが端末のときとパイプのときで動かし、出力を比べて確かめる | | 正規表現に当たらない切る箇所 | 要求の「調べ方」の正規表現に当たらない書き方(`textwrap.shorten`・変数名の違い)で切る箇所は数えていない | | 問い合わせの記録 | hook が書く `ask.jsonl` の行へ実行の識別子を足すかは、hook の変更として #2335 の対象範囲の外に置いた(決定 4) | From 215950750bd7a53d1d885d1fd0bb885ff9986205 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 16:21:09 +0000 Subject: [PATCH 05/11] =?UTF-8?q?Docs:=20#2338=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=A7=E3=80=81=E3=82=B7=E3=82=B0=E3=83=8A=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E5=8D=B0=E3=82=92=E7=AB=8B=E3=81=A6=E3=82=8B=E6=99=82?= =?UTF-8?q?=E7=82=B9=E3=83=BB=E4=BF=9D=E7=95=99=E3=81=AE=E7=B5=8C=E8=B7=AF?= =?UTF-8?q?=E3=81=AE=E7=B5=82=E3=82=8F=E3=82=89=E3=81=9B=E6=96=B9=E3=83=BB?= =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4=E3=83=B3=E5=81=B4=E3=81=AE?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E7=8A=B6=E6=85=8B=E3=81=AE?= =?UTF-8?q?=E7=BD=AE=E3=81=8D=E5=A0=B4=E3=82=92=E6=B1=BA=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index b2bea487e..1afb36cba 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -107,7 +107,8 @@ | プランの状態の記録(`supervise_lib/state.py`) | `progress_write` が書く行に、環境変数 `NDF_RUN_ID` があれば `run_id` を足す | 変更 | 核の拡張 | | 環境変数の契約(`plugins/ndf/contracts/env/`) | `NDF_RUN_ID`・`NDF_RUN_LOG`・`NDF_STATE_DIR` の行を足す。秘密を持つ環境変数(`GH_TOKEN`・`GITHUB_TOKEN`・`AI_GATEWAY_API_KEY`・`SLACK_BOT_TOKEN`)の行へ `secret: true` を足す | 変更 | 核の拡張 | | 契約の読み手(`scripts/contracts_lib/model.py`) | 環境変数の行の任意の鍵 `secret`(bool)を受けて確かめる | 変更 | 核の拡張 | -| テストの状態の置き場(ルートの `conftest.py`) | session の autouse の fixture で、テストの実行中だけ `NDF_STATE_DIR` を一時ディレクトリへ向ける。全体テストが実行した人の置き場へ記録を書かず、寿命の掃除も本物の置き場で動かさない(`NDF_CONTEXT_DIR`・`NDF_METRICS_DIR` と同じ形) | 変更 | 核の拡張 | +| テストの状態の置き場(ルートの `conftest.py`) | session の autouse の fixture で、テストの実行中だけ `NDF_STATE_DIR` を一時ディレクトリへ向ける。全体テストが実行した人の置き場へ記録を書かず、寿命の掃除も本物の置き場で動かさない(`NDF_CONTEXT_DIR`・`NDF_METRICS_DIR` と同じ形)。プラグインの外のテスト(`scripts/tests/` など)に効く | 変更 | 核の拡張 | +| テストの状態の置き場(`plugins/ndf/conftest.py`) | テストごとの autouse の fixture(`_isolated_usage_dirs`)で、`NDF_USAGE_DIR` などと並べて `NDF_STATE_DIR` をテストごとの一時ディレクトリへ向ける。ルートの `conftest.py` はプラグインのキャッシュへ配られないため、配布先でプラグインのテストを実行しても本物の `runs/` へ書かず、寿命の掃除も本物の置き場で動かさない | 変更 | 核の拡張 | | 構造チェック(`scripts/check-script-structure.py`) | 検査の種類 `stderr-direct` を足す。例外リストの増加は既存の検査(#2335 の `allow-grow`)で落とす | 変更 | 核の拡張 | | 例外リスト(`scripts/script-structure-allow/`) | 直書きを持つ 60 ファイルを `stderr-direct` の行として載せ、移した Pull Request が消す | 変更 | 核の拡張 → 移す 6 本 | | 理由を切る 10 か所 | 下の「止まった理由を切る箇所の扱い」の表どおりに直す | 変更 | 核の拡張 | @@ -357,9 +358,11 @@ sequenceDiagram **SIGTERM と時間切れ**: CPython の Python で書いたシグナルの受け手は、どのスレッドがシグナルを受けても主スレッドで動く。`signal.pthread_sigmask` は呼んだスレッドにしか効かないため、受け手が主スレッドの排他の区間の中で動くことを防げない。核の `exec` の読み手のスレッドと、G4 で移す監視・中継のスレッドがこの条件を満たす。`flock` は同じプロセスでも開き直したファイル記述子どうしで排他になるため、受け手が排他の区間の中で `end` を書きにいくと、自分自身を待って止まる。そこで次の形にする。 -1. 書き口はプロセスに 1 つの `threading.Lock` でスレッドどうしの書き込みを順に並べ、その中で `flock` を取る。主スレッドが排他の区間にいる間は、スレッドごとの印(`threading.local`)を立てる -2. 核の受け手(SIGTERM と時間切れのシグナル SIGALRM)は、主スレッドが排他の区間にいなければ、その場で E9 を呼んで `end` を書く -3. 主スレッドが排他の区間にいれば、受け手は `end` を書かずに、終わり方(`sigterm` / `timeout`)を保留の印として残して戻る。`write` は排他を返した直後に保留の印を見て、印があれば E9 を呼ぶ +1. 書き口はプロセスに 1 つの `threading.Lock` でスレッドどうしの書き込みを順に並べ、その中で `flock` を取る。主スレッドが排他の区間にいる間は、スレッドごとの印(`threading.local`)を立てる。印は `Lock` を取る前に立て、`Lock` を返した後に下ろす。`Lock` を取ってから印を立てるまでと、印を下ろしてから `Lock` を返すまでの隙間を作らないためである(隙間で受け手が動くと、印が無いと見て項目 2 の経路で同じ `Lock` を取りにいき、主スレッドが自分を待って止まる) +2. 核の受け手(SIGTERM と時間切れのシグナル SIGALRM)は、主スレッドが排他の区間にいなければ、その場で E9 を呼んで `end` を書き、続けて終わらせ方の関数を呼ぶ +3. 主スレッドが排他の区間にいれば、受け手は `end` を書かずに、終わり方(`sigterm` / `timeout`)を保留の印として残して戻る。`write` は排他を返して印を下ろした後に保留の印を見て、印があれば E9 を呼び、続けて項目 2 と同じ終わらせ方の関数を呼ぶ。`write` は本体へ戻らない + - **終わらせ方の関数**: 項目 2 と 3 が共有する 1 つの関数にする。結果を #2335 の失敗の形(`stopped`・終了コード 1・`gaps` に `interrupted` か `timeout`)にし、核だけが捕まえる例外(`BaseException` から派生させ、本体の `except Exception` に捕まらない)を投げる。核の `main` がこの例外を捕まえ、`end` の `result`・`code` と同じ結果 JSON を標準出力へ出し、同じ終了コードで終わる。`end` の `how` が `sigterm` / `timeout` のときに、標準出力と終了コードが本体の値にならない(1c) + - E9 はプロセスで 1 回だけ `end` を書く。2 回目の呼び出しは何も書かずに戻る(保留の印を残した後に項目 2 の経路でも呼ばれる場合を含む) 4. ほかのスレッドが排他を持つ間に受け手が動いたときは、そのスレッドが排他を返すのを待ってから書く(待つ相手が別のスレッドのため止まらない) 5. `close` が `end` を書いた後の `write` は、記録へ書かずに捨てる。読み手のスレッドが後から書いても、`end` の行は最後に並ぶ @@ -419,6 +422,7 @@ hook の入口の 6 か所は、次のとおり数から除いた。 | --- | --- | --- | | 1・I1 | 核を通る入口を正常・例外・SIGTERM・時間切れで終わらせると、1 ファイルに同じ `run_id` で `start` から `end` までが並び、すべての行が 5 つの鍵を持つ | SIGTERM の受け手から E9 を外す/`at` を地方時で書く | | 1・I1 | 読み手のスレッドが書き続ける間に、主スレッドが排他の区間にいる時点で SIGTERM と SIGALRM を送っても、入口が時間内に終わり、`end` の行が最後に並ぶ | 受け手が排他の区間の中で `end` を書きにいく/`end` の後の `write` を記録へ書く | +| 1・1c・I1 | 主スレッドが排他の区間に入る直前と出た直後に SIGTERM を送っても止まらずに終わる。保留の経路で終わったときも、本体は `write` の後の処理へ進まず、標準出力の結果 JSON(`stopped`・`gaps` に `interrupted`)と終了コード 1 が `end` の `result`・`code` に一致する | 印を `Lock` を取った後に立てる/保留の経路で E9 の後に `write` から本体へ戻る | | 1a・I3 | 入口が別の入口を子プロセスで起こすと、子の行が親のファイルに入り、`parent_id` が親の `run_id` | 子へ `NDF_RUN_LOG` を渡さない/子が自分の識別子を `parent_id` に書く | | 1b・I2 | 子の実行を並行に複数起こし、64 KiB 近い行を書かせても、全行が JSON として読める | 排他を外して 2 回の `write` に分ける | | 1c | 本体の `decision` と止まった理由が行に残り、`end` の `fields` が標準出力の結果 JSON と終了コードに一致する | `end` に `summary` だけを書く | @@ -434,13 +438,14 @@ hook の入口の 6 か所は、次のとおり数から除いた。 | 5・I5 | `secret: true` の環境変数に値を入れ、本体がその値を診断・例外の理由・`summary` へ入れても、記録と全文ファイルに値が無い。トークンの形の文字列も無い。空文字列の値で他の文字列が壊れない | 伏せる処理を全文ファイルに通さない/空の値で置き換える | | 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。記録の置き場を書けないときも同じ文が出る | 記録へ書いた後でしか標準エラーへ書かない | | 1e | 全体テストの後に、テストの外の `NDF_STATE_DIR`(未設定なら `~/.local/state/ndf`)の `runs/` の中身が増えも減りもしない | ルートの `conftest.py` から `NDF_STATE_DIR` の fixture を外す | +| 1e | `plugins/ndf/` だけを根にして(ルートの `conftest.py` が読まれない、プラグインのキャッシュと同じ条件で)核を通すテストを実行した後も、テストの外の `NDF_STATE_DIR` の `runs/` の中身が増えも減りもしない | `plugins/ndf/conftest.py` から `NDF_STATE_DIR` の行を外す | | 7 | 柱の Pull Request ごとに `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | — | ## 設計の結果 | 課題 | 扱い | 取り込み先 | 触るファイル | | --- | --- | --- | --- | -| #2338 | 実装する | — | `conftest.py`、`plugins/ndf/scripts/lib/run_log.py`、`plugins/ndf/scripts/lib/run_core.py`、`plugins/ndf/scripts/lib/secret_redact.py`、`plugins/ndf/scripts/lib/durable_keys.py`、`plugins/ndf/scripts/lib/durable.py`、`plugins/ndf/scripts/lib/usage_ledger.py`、`plugins/ndf/scripts/supervise_lib/state.py`、`plugins/ndf/contracts/env/`、`scripts/contracts_lib/model.py`、`scripts/check-script-structure.py`、`scripts/script-structure-allow/`、`scripts/tests/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/scripts/project-mvv.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/merged_lib/merge.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/monitor_loop.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/phase-steps.py`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/lib/loop_drive.py`、`plugins/ndf/scripts/lib/post_queue.py`、`plugins/ndf/scripts/refactor-scope.py`、`plugins/ndf/scripts/lib/result_posts.py`、`plugins/ndf/scripts/lib/review_criteria.py`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/skills/cross-refactoring/scripts/`、`docs/glossary/glossary.json`、`docs/glossary.md` | +| #2338 | 実装する | — | `conftest.py`、`plugins/ndf/conftest.py`、`plugins/ndf/scripts/lib/run_log.py`、`plugins/ndf/scripts/lib/run_core.py`、`plugins/ndf/scripts/lib/secret_redact.py`、`plugins/ndf/scripts/lib/durable_keys.py`、`plugins/ndf/scripts/lib/durable.py`、`plugins/ndf/scripts/lib/usage_ledger.py`、`plugins/ndf/scripts/supervise_lib/state.py`、`plugins/ndf/contracts/env/`、`scripts/contracts_lib/model.py`、`scripts/check-script-structure.py`、`scripts/script-structure-allow/`、`scripts/tests/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/scripts/project-mvv.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/merged_lib/merge.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/monitor_loop.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/phase-steps.py`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/lib/loop_drive.py`、`plugins/ndf/scripts/lib/post_queue.py`、`plugins/ndf/scripts/refactor-scope.py`、`plugins/ndf/scripts/lib/result_posts.py`、`plugins/ndf/scripts/lib/review_criteria.py`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/skills/cross-refactoring/scripts/`、`docs/glossary/glossary.json`、`docs/glossary.md` | 触るファイルはスプリント 1(核の拡張・G1・G2)の分である。G3〜G6 は同じ柱の次のスプリントの設計の結果へ載せる。 From ed103c1ae9a55b6a2659cd03c6e1cc692e2c9608 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 22:17:24 +0000 Subject: [PATCH 06/11] =?UTF-8?q?Docs:=20#2338=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=A7=E3=80=81=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?=E7=8A=B6=E6=85=8B=E3=81=AE=E7=BD=AE=E3=81=8D=E5=A0=B4=E3=81=8C?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=81=AE=E6=B5=81=E3=82=8C=E3=81=AE=E3=81=A9?= =?UTF-8?q?=E3=81=93=E3=81=A7=E5=8B=95=E3=81=8F=E3=81=8B=E3=82=92=E5=9B=B3?= =?UTF-8?q?=E3=81=AB=E6=8F=8F=E3=81=8B=E3=81=AA=E3=81=84=E8=A6=81=E7=B4=A0?= =?UTF-8?q?=E3=81=AE=E8=A1=A8=E3=81=B8=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index 1afb36cba..42ee37a5c 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -344,6 +344,7 @@ sequenceDiagram | 使用量の帳簿・プランの状態の記録(E7) | 本体の中で書かれ、環境変数を読むだけ | | 環境変数の契約・契約の読み手 | E1 の前に、核が秘密の名前を読む | | 構造チェック・例外リスト | CI で動く | +| テストの状態の置き場(ルートと `plugins/ndf/` の `conftest.py`) | テストの実行中だけ、起動の前に `NDF_STATE_DIR` を一時ディレクトリへ向ける。図の流れは変わらず、置き場の根だけが変わる | | 理由を切る 10 か所・直書きを持つ 60 ファイル | 本体の中身 | **1 行の書き方**: 次の順で書く。 From bbbbc77df161f13fea55c989c4a870a887eb8477 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 22:21:48 +0000 Subject: [PATCH 07/11] =?UTF-8?q?Docs:=20#2338=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99=EF=BC=88=E3=82=A8=E3=83=B3=E3=83=88=E3=83=AA=E3=83=9D?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=88=E3=81=B8=E3=81=9D=E3=82=8D=E3=81=88?= =?UTF-8?q?=E3=80=81=E5=87=A6=E7=90=86=E3=81=AE=E6=B5=81=E3=82=8C=E3=82=92?= =?UTF-8?q?=E5=B0=8F=E8=A6=8B=E5=87=BA=E3=81=97=E3=81=B8=E5=88=86=E3=81=91?= =?UTF-8?q?=E3=80=81=E9=95=B7=E3=81=84=E6=96=87=E3=82=92=E5=88=86=E3=81=91?= =?UTF-8?q?=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内容は足さずに、次を行った。 - 説明文の「入口」を、用語集の語「エントリポイント」へそろえた - 足す環境変数 3 つを括弧に積んだ 1 文(121 字)を表へ移した - 処理の流れの「1 行の書き方」「SIGTERM と時間切れ」を小見出しにした(章の固有部分 62 行 → 42 行) - 100 字を超える文(129 字・111 字・102 字)と、決定の記録の長い文 5 つを分けた 章の並びは設計テンプレートの順と確定事項を伝える文書の構成に合っているため変えていない。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design-decisions.md | 20 +++++------ issues/issue-2338-design.md | 48 ++++++++++++++++----------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/issues/issue-2338-design-decisions.md b/issues/issue-2338-design-decisions.md index dec985136..c8a99af22 100644 --- a/issues/issue-2338-design-decisions.md +++ b/issues/issue-2338-design-decisions.md @@ -33,9 +33,9 @@ Linux の通常のファイルへの `O_APPEND` の書き込みは、1 回なら ### 決定 3: 途中で記録へ書けなくなっても外の世界を中途半端に残さないよう、本体は終わりまで動かし、結果だけを `stopped` にする 本体の途中で止めると、push の後・Pull Request を作る前のような半端な状態が残る。利用者がそれを手で戻すことになる。 -終わりまで動かして結果を `stopped` にすれば、呼ぶ側は止まったことを知る。途中で書けなくなったときは `end` の行も書けないため、核は本体の元の結果 JSON を -`stopped` の結果の鍵 `original_result` へ入れて標準出力へ出す。本体が済ませた外の操作の結果(作った Pull Request の番号など)が呼ぶ側に届き、 -呼ぶ側が再実行して同じものを二重に作ることを防ぐ。 +終わりまで動かして結果を `stopped` にすれば、呼ぶ側は止まったことを知る。途中で書けなくなったときは、`end` の行も書けない。そのため核は本体の元の結果 JSON を +`stopped` の結果の鍵 `original_result` へ入れ、標準出力へ出す。本体が済ませた外の操作の結果(作った Pull Request の番号など)が呼ぶ側に届く。 +これで、呼ぶ側が再実行して同じものを二重に作ることを防ぐ。 理由は標準エラーへ 1 回だけ出し、以後の診断は標準エラーにだけ出る(端末の文は変わらない)。 始まりの行を書けないときは本体を動かさない(要求の前提 6)。この時点ではまだ外の世界を変えていないためである。 @@ -48,7 +48,7 @@ Linux の通常のファイルへの `O_APPEND` の書き込みは、1 回なら 核が起動した claude の hook へ環境変数 `NDF_RUN_ID` が届くことは確かめた(2026-11-04、Claude Code 2.1.318。設計文書の「あるべき姿の根拠」)。 足すこと自体はできる。ただし問い合わせの記録を書くのは hook であり、hook の書く記録の形を変えることは要求の「含まない」にある。 -使用量の帳簿は hook(`usage-subagent.py`)も書く。ただし帳簿の行を作る `UsageRecord` はライブラリにあり、その既定値として環境変数を読むだけなので、 +使用量の帳簿は hook(`usage-subagent.py`)も書く。ただし帳簿の行を作る `UsageRecord` はライブラリにあり、その既定値として環境変数を読むだけである。 hook の本体は変わらない。 問い合わせの記録への追加は、hook を核へ移す変更(#2335 の対象範囲の外)で扱う。範囲外の課題としては起票しない @@ -68,11 +68,11 @@ hook の本体は変わらない。 根拠: Value 6(MVV 版 5) -### 決定 6: hook や移す前の入口が他人の記録へ書かないよう、`run_log.diag` は自分のプロセスで記録を開いたときだけ記録へ書く +### 決定 6: hook や移す前のエントリポイントが他人の記録へ書かないよう、`run_log.diag` は自分のプロセスで記録を開いたときだけ記録へ書く ライブラリの関数は hook からも呼ばれる。hook は核が起動した claude の下で動くと `NDF_RUN_LOG` を受け継ぐ。一方で hook 自身は核を通らず、実行の識別子を 持たない。環境変数だけで書くと、識別子の無い行が親の記録へ混ざり、I1 が破れる。プロセスの中で `open_run` が開いた記録があるときだけ書く。そうすれば -核の外では標準エラーへ書くだけになり、移す前の入口とも並んで動く。 +核の外では標準エラーへ書くだけになり、移す前のエントリポイントとも並んで動く。 根拠: Value 7(MVV 版 5) @@ -85,8 +85,8 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ シェルの本体の `>&2` は 203 か所・24 ファイルあり、要求の前提 4 のとおり書き換えない。核は `exec` で起こし直した子の標準エラーをパイプで受け、 1 行ずつ標準エラーへ出してから記録へ書く。確かめたのは、スクリプト自身に標準エラーを端末か判定する箇所が無いこと(設計文書の「あるべき姿の根拠」)までである。 シェルの本体が呼ぶ外部コマンド(git・gh・uv・claude など)は、標準エラーが端末かで進捗表示・色・対話の出力を変えることがある。 -行単位で転送するため、改行の無い進捗の表示も遅れる。端末の文が変わらないこと(受け入れ条件 6)は未確認で、設計文書の「未確認のまま残ること」に載せ、 -核の拡張の Pull Request で代表的なシェルの入口の出力を比べて確かめる。 +行単位で転送するため、改行の無い進捗の表示も遅れる。端末の文が変わらないこと(受け入れ条件 6)は未確認である。設計文書の「未確認のまま残ること」に載せ、 +核の拡張の Pull Request で代表的なシェルのエントリポイントの出力を比べて確かめる。 根拠: Value 5 / Value 7(MVV 版 5) @@ -115,13 +115,13 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ 鍵を足すだけなら、今の読み手(見積・使用量の集計・進捗の要約)は未知の鍵を読み飛ばす。識別子の無い古い行もそのまま読める。版を上げると 移行関数が要り、柱 P6(#2339)の型の部品より先に版を動かすことになる。値は書き手の引数ではなく、`UsageRecord` の既定値と `progress_write` の中で -環境変数 `NDF_RUN_ID` から取る。書き手の呼び出し元(`UsageRecord` を作る 2 か所と `progress_write` の呼び出し 15 か所。2026-11-04 に `grep -rn` で数えた)を書き換えずに済む。 +環境変数 `NDF_RUN_ID` から取る。そのため書き手の呼び出し元を書き換えずに済む。呼び出し元は `UsageRecord` を作る 2 か所と `progress_write` の呼び出し 15 か所である(2026-11-04 に `grep -rn` で数えた)。 根拠: Value 6 / Value 7(MVV 版 5) ### 決定 11: 記録の寿命を耐久の記録と 1 つの値にするため、`KEEP_DAYS` を `durable_keys.py` へ移し、日付のディレクトリの名前で判定して消す -`durable.py` は最上位で DBOS を読むため、書き口から import すると 1 入口の起動が約 390 ms 増える(#2335 の実測)。値を DBOS を読まない +`durable.py` は最上位で DBOS を読む。書き口から import すると、エントリポイント 1 つの起動が約 390 ms 増える(#2335 の実測)。値を DBOS を読まない `durable_keys.py` へ移し、両方がそこから引く。消す判定はディレクトリの名前の UTC の日付で行い、更新時刻を見ない。長く動いた起点の実行の ファイルは更新時刻が新しく、更新時刻で判定すると日付のディレクトリが消えずに残る。掃除は起点の実行の起動でだけ行い、子の実行では行わない。 diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index 42ee37a5c..cb014fe8c 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -2,15 +2,15 @@ ## 目的 -- **今どうなっているか**: スクリプトは標準エラーへ診断を直に書き(Python の直書きは 131 か所)、どこにも残らない。止まった理由は 10 か所で先頭 N 字に切られる。子プロセスで動いた入口の記録は親の記録とつながらない +- **今どうなっているか**: スクリプトは標準エラーへ診断を直に書き(Python の直書きは 131 か所)、どこにも残らない。止まった理由は 10 か所で先頭 N 字に切られる。子プロセスで動いたエントリポイントの記録は親の記録とつながらない - **誰に効くか**: NDF でスプリントを回す利用者と conductor、振り返りで止まった原因を探す人 - **変えると何が成り立つか**: 1 回の起動(子の実行を含む)の始まり・判断・診断・失敗・終わりと止まった理由の全文を、実行の識別子 1 つで 1 つのファイルから読める。記録とログの不統一が原因の課題(#2335 の型 B)を、1 件ずつ直さずにまとめて確かめられる ## 適用範囲 -- **働く範囲**: NDF の配布物(`plugins/ndf/scripts` と `plugins/ndf/skills/*/scripts`。テスト・`experimental/`・hook の入口を除く)。配布先のリポジトリでも同じ書き口が動く +- **働く範囲**: NDF の配布物(`plugins/ndf/scripts` と `plugins/ndf/skills/*/scripts`)。テスト・`experimental/`・hook のエントリポイントを除く。配布先のリポジトリでも同じ書き口が動く - **プロジェクトごとに違うもの**: 記録の置き場の根は環境変数 `NDF_STATE_DIR` で受ける(既定 `~/.local/state/ndf`)。リポジトリごとの値は置き場のパスの 1 階層(`__`)にだけ現れ、書き口の中に ai-plugins の値を置かない -- **当たるモード**: `standard`。柱 P1(#2337)の核のスケルトンを拡張する柱で、実装は核の拡張の Pull Request 1 本と入口を移す Pull Request 6 本(3 スプリント)に分かれる +- **当たるモード**: `standard`。柱 P1(#2337)の核のスケルトンを拡張する柱である。実装は核の拡張の Pull Request 1 本とエントリポイントを移す Pull Request 6 本(3 スプリント)に分かれる ## あるべき姿の根拠 @@ -33,7 +33,7 @@ | --- | --- | | NDF の開発ワークフロー(`ndf-workflow`) | 実行の記録・実行の識別子・起点の実行・子の実行・診断・全文ファイル・使用量の帳簿 | -実行の記録の書き口(`lib/run_log.py`)は #2335 が決めた共有カーネル(実行の核と契約の一覧)の一部である。各コンテキストの入口は書き口を直に呼ばず、実行の文脈の `ctx.log` と、ライブラリの関数から呼ぶ `run_log.diag` の 2 つだけを使う。 +実行の記録の書き口(`lib/run_log.py`)は #2335 が決めた共有カーネル(実行の核と契約の一覧)の一部である。各コンテキストのエントリポイントは書き口を直に呼ばず、実行の文脈の `ctx.log` と、ライブラリの関数から呼ぶ `run_log.diag` の 2 つだけを使う。 ### 集約 @@ -42,7 +42,7 @@ | 実行 | 実行の核(`run_core`)。行を書くのは書き口(`run_log`)だけ | 実行の識別子 | 実行の記録の行 | 全文ファイルの参照・伏せ字にした文字列 | | 使用量の帳簿 | 帳簿の書き手(`usage_ledger`。持ち主は変えない) | 帳簿のファイル | 帳簿の行 | 実行の識別子(任意の鍵) | | プランの状態の記録 | `RunState`(`supervise_lib/state.py`。持ち主は変えない) | `progress.jsonl` | 状態の行 | 実行の識別子(任意の鍵) | -| 例外リスト | 構造チェック(読むだけ)。行を消すのは入口を移した Pull Request | `scripts/script-structure-allow/` | 許可の行(検査の種類 `stderr-direct`) | — | +| 例外リスト | 構造チェック(読むだけ)。行を消すのはエントリポイントを移した Pull Request | `scripts/script-structure-allow/` | 許可の行(検査の種類 `stderr-direct`) | — | **帳簿とプランの状態の記録は、実行の記録を識別子で参照するだけである。** 実行の記録の行を写さず、実行の記録も帳簿の行を写さない(要求の前提 3)。 @@ -91,7 +91,7 @@ | F2 | 診断を端末へ今と同じ文で出し、同じ文を記録にも残す | 利用者(端末)・振り返り(記録) | | F3 | 止まった理由を切らずに結果 JSON と記録へ残し、人へ見せる表示だけを短くする | conductor・利用者 | | F4 | 使用量の帳簿・プランの状態の記録の行から、その行を書いた実行の記録へたどる | 見積・使用量の集計・振り返り | -| F5 | 標準エラーへの直書きを構造チェックで落とし、例外リストを減る方向にだけ動かす | CI・入口を移す Pull Request | +| F5 | 標準エラーへの直書きを構造チェックで落とし、例外リストを減る方向にだけ動かす | CI・エントリポイントを移す Pull Request | | F6 | 30 日を超えた記録を消す | 利用者(状態の置き場の容量) | ## 構成要素 @@ -116,7 +116,7 @@ ```mermaid graph TD - subgraph 入口 + subgraph エントリポイント PY[Python の本体] LIB[ライブラリの関数] SH[シェルの本体] @@ -223,7 +223,7 @@ classDiagram run_log ..> RunLog : 開いたものを 1 つ持つ ``` -- **プロセスに開いた記録は 1 つだけ**: `open_run` は開いた `RunLog` をモジュールの変数に持ち、`current()` で返す。核の外のプロセス(hook・テスト・核へ移す前の入口)では `None` で、`diag` は標準エラーへ書くだけになる +- **プロセスに開いた記録は 1 つだけ**: `open_run` は開いた `RunLog` をモジュールの変数に持ち、`current()` で返す。核の外のプロセス(hook・テスト・核へ移す前のエントリポイント)では `None` で、`diag` は標準エラーへ書くだけになる - **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種だけを受け、ほかは `ValueError`。8 種は `start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end` である - **`run_log.diag(text, short=None, **fields)`**: 標準エラーへは `short` があれば `short`、無ければ `text` を 1 行で書く。記録へは `{"text": text, **fields}` を書き、`short` があれば `"display": short` も書く。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ - **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前に `failed` を見て結果を `stopped` にする(I7) @@ -264,7 +264,13 @@ classDiagram | --- | --- | --- | --- | | `secret` | bool | 省略を許す(省略は偽) | 真なら、核はこの環境変数の空でない値を記録へ書く前に伏せる | -`NDF_RUN_ID`(`set_by: ndf`。今の実行の識別子。子は親の識別子として読む)・`NDF_RUN_LOG`(`set_by: ndf`。起点の実行の記録のファイルの絶対パス)・`NDF_STATE_DIR`(`set_by: user`。状態の置き場の根)を足す。 +足す環境変数は次の 3 つである。 + +| 環境変数 | `set_by` | 意味 | +| --- | --- | --- | +| `NDF_RUN_ID` | `ndf` | 今の実行の識別子。子は親の識別子として読む | +| `NDF_RUN_LOG` | `ndf` | 起点の実行の記録のファイルの絶対パス | +| `NDF_STATE_DIR` | `user` | 状態の置き場の根 | ### 機能とデータ @@ -347,7 +353,9 @@ sequenceDiagram | テストの状態の置き場(ルートと `plugins/ndf/` の `conftest.py`) | テストの実行中だけ、起動の前に `NDF_STATE_DIR` を一時ディレクトリへ向ける。図の流れは変わらず、置き場の根だけが変わる | | 理由を切る 10 か所・直書きを持つ 60 ファイル | 本体の中身 | -**1 行の書き方**: 次の順で書く。 +### 1 行の書き方 + +次の順で書く。 1. 伏せる 2. JSON にする @@ -357,12 +365,14 @@ sequenceDiagram 全文ファイルは記録の行より先に書く。行が全文ファイルを指した時点で、全文ファイルが在る。 -**SIGTERM と時間切れ**: CPython の Python で書いたシグナルの受け手は、どのスレッドがシグナルを受けても主スレッドで動く。`signal.pthread_sigmask` は呼んだスレッドにしか効かないため、受け手が主スレッドの排他の区間の中で動くことを防げない。核の `exec` の読み手のスレッドと、G4 で移す監視・中継のスレッドがこの条件を満たす。`flock` は同じプロセスでも開き直したファイル記述子どうしで排他になるため、受け手が排他の区間の中で `end` を書きにいくと、自分自身を待って止まる。そこで次の形にする。 +### SIGTERM と時間切れ + +CPython の Python で書いたシグナルの受け手は、どのスレッドがシグナルを受けても主スレッドで動く。`signal.pthread_sigmask` は呼んだスレッドにしか効かないため、受け手が主スレッドの排他の区間の中で動くことを防げない。核の `exec` の読み手のスレッドと、G4 で移す監視・中継のスレッドがこの条件を満たす。`flock` は同じプロセスでも、開き直したファイル記述子どうしで排他になる。そのため受け手が排他の区間の中で `end` を書きにいくと、自分自身を待って止まる。そこで次の形にする。 -1. 書き口はプロセスに 1 つの `threading.Lock` でスレッドどうしの書き込みを順に並べ、その中で `flock` を取る。主スレッドが排他の区間にいる間は、スレッドごとの印(`threading.local`)を立てる。印は `Lock` を取る前に立て、`Lock` を返した後に下ろす。`Lock` を取ってから印を立てるまでと、印を下ろしてから `Lock` を返すまでの隙間を作らないためである(隙間で受け手が動くと、印が無いと見て項目 2 の経路で同じ `Lock` を取りにいき、主スレッドが自分を待って止まる) +1. 書き口はプロセスに 1 つの `threading.Lock` でスレッドどうしの書き込みを順に並べ、その中で `flock` を取る。主スレッドが排他の区間にいる間は、スレッドごとの印(`threading.local`)を立てる。印は `Lock` を取る前に立て、`Lock` を返した後に下ろす。`Lock` を取ってから印を立てるまでと、印を下ろしてから `Lock` を返すまでの隙間を作らないためである。隙間で受け手が動くと、印が無いと見て項目 2 の経路で同じ `Lock` を取りにいき、主スレッドが自分を待って止まる 2. 核の受け手(SIGTERM と時間切れのシグナル SIGALRM)は、主スレッドが排他の区間にいなければ、その場で E9 を呼んで `end` を書き、続けて終わらせ方の関数を呼ぶ 3. 主スレッドが排他の区間にいれば、受け手は `end` を書かずに、終わり方(`sigterm` / `timeout`)を保留の印として残して戻る。`write` は排他を返して印を下ろした後に保留の印を見て、印があれば E9 を呼び、続けて項目 2 と同じ終わらせ方の関数を呼ぶ。`write` は本体へ戻らない - - **終わらせ方の関数**: 項目 2 と 3 が共有する 1 つの関数にする。結果を #2335 の失敗の形(`stopped`・終了コード 1・`gaps` に `interrupted` か `timeout`)にし、核だけが捕まえる例外(`BaseException` から派生させ、本体の `except Exception` に捕まらない)を投げる。核の `main` がこの例外を捕まえ、`end` の `result`・`code` と同じ結果 JSON を標準出力へ出し、同じ終了コードで終わる。`end` の `how` が `sigterm` / `timeout` のときに、標準出力と終了コードが本体の値にならない(1c) + - **終わらせ方の関数**: 項目 2 と 3 が共有する 1 つの関数にする。結果を #2335 の失敗の形(`stopped`・終了コード 1・`gaps` に `interrupted` か `timeout`)にする。続けて、核だけが捕まえる例外を投げる。例外は `BaseException` から派生させ、本体の `except Exception` に捕まらない。核の `main` がこの例外を捕まえ、`end` の `result`・`code` と同じ結果 JSON を標準出力へ出し、同じ終了コードで終わる。`end` の `how` が `sigterm` / `timeout` のときに、標準出力と終了コードが本体の値にならない(1c) - E9 はプロセスで 1 回だけ `end` を書く。2 回目の呼び出しは何も書かずに戻る(保留の印を残した後に項目 2 の経路でも呼ばれる場合を含む) 4. ほかのスレッドが排他を持つ間に受け手が動いたときは、そのスレッドが排他を返すのを待ってから書く(待つ相手が別のスレッドのため止まらない) 5. `close` が `end` を書いた後の `write` は、記録へ書かずに捨てる。読み手のスレッドが後から書いても、`end` の行は最後に並ぶ @@ -385,7 +395,7 @@ stateDiagram-v2 ## 移す順序 直書きを持つ対象のファイルは 60(125 か所)である。数は 2026-11-04 の develop(729dcbe40)で、要求の「調べ方」のコマンドからファイルごとに取った。 -hook の入口の 6 か所は、次のとおり数から除いた。 +hook のエントリポイントの 6 か所は、次のとおり数から除いた。 - `hooks/*.json` が呼ぶ 5 つ: `context-policy.py`・`hook.py`・`relay.py`・`usage-subagent.py`・`training-optout.py` - SessionStart が呼ぶ 1 つ: `hook-env.py` @@ -409,7 +419,7 @@ hook の入口の 6 か所は、次のとおり数から除いた。 | 大項目 | 要求の条件 | 実現方式 | 確かめ方 | | --- | --- | --- | --- | | 運用・保守性 | 1 回の起動を実行の識別子 1 つで、1 ファイルから追う。寿命 30 日 | 起点の実行ごとに 1 ファイル。子は `NDF_RUN_LOG` で同じファイルへ書く。寿命は `durable_keys.KEEP_DAYS` を引き、起点の実行の起動で `runs/*/<日付>` のうち日付が 30 日より前のディレクトリを消す(ディレクトリの名前の日付で判定し、更新時刻を見ない) | 終わり方ごとのテスト・子の実行のテスト・寿命のテスト(テスト設計) | -| 移行性 | 部品の順・例外リストで並走・Pull Request ごとの revert で戻る・識別子の無い行も読める | 書き口は核の外では標準エラーへ書くだけになるため、移した入口と移す前の入口が並んで動く。核の拡張の Pull Request を revert すると、移した入口の `run_log.diag` も標準エラーだけへ書く | 柱の Pull Request ごとに全体テストと構造チェックを通す | +| 移行性 | 部品の順・例外リストで並走・Pull Request ごとの revert で戻る・識別子の無い行も読める | 書き口は核の外では標準エラーへ書くだけになるため、移したエントリポイントと移す前のエントリポイントが並んで動く。核の拡張の Pull Request を revert すると、移したエントリポイントの `run_log.diag` も標準エラーだけへ書く | 柱の Pull Request ごとに全体テストと構造チェックを通す | | セキュリティ | 受け入れ条件 5。C1・C2 は変えない | 書く前に `secret_redact.redact`(トークンの形)と、秘密と宣言した環境変数の空でない値の置き換えを通す。全文ファイルにも同じ処理を通す。秘密の値そのものは記録にも契約にも書かない(契約には `secret: true` だけ) | 秘密を入れた環境変数で起動するテスト | | システム環境 | Claude Code と Codex の両方で同じ核。シェルは bash | 書き口は標準ライブラリ(`json`・`os`・`fcntl`・`hashlib`)だけを使い、extras の無い環境でも動く | extras を外した環境で診断が出るテスト | @@ -421,10 +431,10 @@ hook の入口の 6 か所は、次のとおり数から除いた。 | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | -| 1・I1 | 核を通る入口を正常・例外・SIGTERM・時間切れで終わらせると、1 ファイルに同じ `run_id` で `start` から `end` までが並び、すべての行が 5 つの鍵を持つ | SIGTERM の受け手から E9 を外す/`at` を地方時で書く | -| 1・I1 | 読み手のスレッドが書き続ける間に、主スレッドが排他の区間にいる時点で SIGTERM と SIGALRM を送っても、入口が時間内に終わり、`end` の行が最後に並ぶ | 受け手が排他の区間の中で `end` を書きにいく/`end` の後の `write` を記録へ書く | +| 1・I1 | 核を通るエントリポイントを正常・例外・SIGTERM・時間切れで終わらせると、1 ファイルに同じ `run_id` で `start` から `end` までが並び、すべての行が 5 つの鍵を持つ | SIGTERM の受け手から E9 を外す/`at` を地方時で書く | +| 1・I1 | 読み手のスレッドが書き続ける間に、主スレッドが排他の区間にいる時点で SIGTERM と SIGALRM を送っても、エントリポイントが時間内に終わり、`end` の行が最後に並ぶ | 受け手が排他の区間の中で `end` を書きにいく/`end` の後の `write` を記録へ書く | | 1・1c・I1 | 主スレッドが排他の区間に入る直前と出た直後に SIGTERM を送っても止まらずに終わる。保留の経路で終わったときも、本体は `write` の後の処理へ進まず、標準出力の結果 JSON(`stopped`・`gaps` に `interrupted`)と終了コード 1 が `end` の `result`・`code` に一致する | 印を `Lock` を取った後に立てる/保留の経路で E9 の後に `write` から本体へ戻る | -| 1a・I3 | 入口が別の入口を子プロセスで起こすと、子の行が親のファイルに入り、`parent_id` が親の `run_id` | 子へ `NDF_RUN_LOG` を渡さない/子が自分の識別子を `parent_id` に書く | +| 1a・I3 | エントリポイントが別のエントリポイントを子プロセスで起こすと、子の行が親のファイルに入り、`parent_id` が親の `run_id` | 子へ `NDF_RUN_LOG` を渡さない/子が自分の識別子を `parent_id` に書く | | 1b・I2 | 子の実行を並行に複数起こし、64 KiB 近い行を書かせても、全行が JSON として読める | 排他を外して 2 回の `write` に分ける | | 1c | 本体の `decision` と止まった理由が行に残り、`end` の `fields` が標準出力の結果 JSON と終了コードに一致する | `end` に `summary` だけを書く | | 1d・I9 | 核の中で追記した帳簿の行とプランの状態の記録の行が `run_id` を持ち、核の外で書いた行は鍵を持たずに今の読み手で読める | `run_id` を必須にする/読み手が未知の鍵で落ちる | @@ -457,6 +467,6 @@ hook の入口の 6 か所は、次のとおり数から除いた。 | P1 の核の形 | `run_core.py` は 2026-11-04 の時点で無い(#2337 は OPEN)。この設計は #2335 の設計の `RunContext`・`main`・`exec`・SIGTERM の受け手の形を前提にした。P1 の実装で形が変わったときは、核の拡張の Pull Request で呼ぶ位置だけを合わせる | | Codex の hook と Bash の子 | `NDF_RUN_ID` が hook へ届くことは Claude Code の hook で確かめた。Codex の hook と、エージェントの Bash ツールが起こす子プロセスへ届くかは未確認。届かないときも、子は起点の実行として自分のファイルを作るだけで、記録は失われない | | 直書きの別の形 | 検査は `print` の `file` の式に `sys.stderr` を含むものと `sys.stderr.write` を落とす。変数へ入れてから書く形(`err = sys.stderr`)と、`sys.stderr` を参照するほかの 11 か所(`flush`・`argparse` の `print_usage`・`subprocess` の出力先・DBOS のハンドラ)は対象にしない | -| シェルの標準エラーをパイプにした影響 | シェルの本体が呼ぶ外部コマンド(git・gh・uv・claude など)は、標準エラーが端末かで進捗表示・色・対話の出力を変えることがある。行単位で転送するため、改行の無い進捗の表示も遅れる。`plugins/ndf/scripts` と `plugins/ndf/skills` のシェルの本体に `read -p` は 0 件(2026-11-04、`grep -nE 'read +-[a-z]*p'`)。受け入れ条件 6 を満たすかは、核の拡張の Pull Request で、代表的なシェルの入口を標準エラーが端末のときとパイプのときで動かし、出力を比べて確かめる | +| シェルの標準エラーをパイプにした影響 | シェルの本体が呼ぶ外部コマンド(git・gh・uv・claude など)は、標準エラーが端末かで進捗表示・色・対話の出力を変えることがある。行単位で転送するため、改行の無い進捗の表示も遅れる。`plugins/ndf/scripts` と `plugins/ndf/skills` のシェルの本体に `read -p` は 0 件(2026-11-04、`grep -nE 'read +-[a-z]*p'`)。受け入れ条件 6 を満たすかは、核の拡張の Pull Request で、代表的なシェルのエントリポイントを標準エラーが端末のときとパイプのときで動かし、出力を比べて確かめる | | 正規表現に当たらない切る箇所 | 要求の「調べ方」の正規表現に当たらない書き方(`textwrap.shorten`・変数名の違い)で切る箇所は数えていない | | 問い合わせの記録 | hook が書く `ask.jsonl` の行へ実行の識別子を足すかは、hook の変更として #2335 の対象範囲の外に置いた(決定 4) | From 3fbac4b75253c49b4ecb5931e802f03905fbaa09 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 22:28:59 +0000 Subject: [PATCH 08/11] =?UTF-8?q?docs(#2338):=20=E8=A8=BA=E6=96=AD?= =?UTF-8?q?=E3=81=AE=E7=AB=AF=E6=9C=AB=E5=87=BA=E5=8A=9B=E3=83=BB=E7=B5=82?= =?UTF-8?q?=E3=82=8F=E3=82=8A=E3=81=AE=E8=A1=8C=E3=81=AE=E5=A4=B1=E6=95=97?= =?UTF-8?q?=E3=83=BB=E9=83=A8=E5=88=86=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=AE=E6=89=B1=E3=81=84=E3=82=92=E8=A8=AD=E8=A8=88=E3=81=AB?= =?UTF-8?q?=E6=98=8E=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ctx.log の diag を run_log.diag へ渡し、標準エラーへ先に出す - 核は E9 の完了後にも failed を見てから標準出力と終了コードを確定する - 1 行は排他の中で全バイトを書き切り、書き切れなければ追記前の長さへ戻して I7 にする Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design-decisions.md | 6 ++++-- issues/issue-2338-design.md | 21 +++++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/issues/issue-2338-design-decisions.md b/issues/issue-2338-design-decisions.md index c8a99af22..55491162c 100644 --- a/issues/issue-2338-design-decisions.md +++ b/issues/issue-2338-design-decisions.md @@ -4,9 +4,11 @@ ## 決定の記録 -### 決定 1: 並行に書く子の実行の行が混ざらないよう、1 行を排他の中で 1 回の書き込みで書く +### 決定 1: 並行に書く子の実行の行が混ざらないよう、1 行を排他の中で書き切る -記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取ってから、1 行を `os.write` の 1 回で書く。 +記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取ってから、1 行の全バイトを書き切るまで `os.write` を呼ぶ。 +`os.write` は容量不足などで例外を投げずに要求より少ないバイト数を返すことがあるため、戻り値を確かめて残りを続けて書く。 +書き切れないときは、排他を持ったまま追記前の長さへ戻してから I7 の失敗にする。途中まで書いた行が残ると、次に書く子の行が同じ行へつながるためである。 Linux の通常のファイルへの `O_APPEND` の書き込みは、1 回なら他と混ざりにくい。 ただし書き込みが途中で分かれる場合(シグナル・容量)は、排他なしでは防げない。排他を置けば、行の大きさに関わらず混ざらないことを書き口だけで保証できる。 diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index cb014fe8c..a2a7c8c98 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -51,7 +51,7 @@ | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | | I1 | 実行 | 実行の記録の行はすべて `run_id`・`at`(UTC の ISO 8601)・`event`・`entry`・`fields` を持つ。1 つの `run_id` について、`start` の行が最初、`end` の行が最後に来る(SIGKILL を除く) | 書き口が鍵の欠けた行を書かずに例外を投げる。核は `stopped` にする | -| I2 | 実行 | ファイルのすべての行が、それぞれ 1 つの JSON のオブジェクトとして読める。並行に書く子の実行の行が混ざらない | 1 行を排他(`fcntl.flock`)の中で 1 回の `write` で書く。書けなければ I7 | +| I2 | 実行 | ファイルのすべての行が、それぞれ 1 つの JSON のオブジェクトとして読める。並行に書く子の実行の行が混ざらない | 1 行を排他(`fcntl.flock`)の中で、全バイトを書き切るまで `write` を続ける。書き切れなければ追記前の長さへ戻して I7 | | I3 | 実行 | 子の実行の行は親と同じファイルに書かれ、`parent_id` が親の `run_id` と一致する | 受け継いだファイルが無いときは起点の実行として自分のファイルを作り、診断を残す(決定 5) | | I4 | 実行 | 1 行は 64 KiB 以下。超える行は、長い文字列から順に全文ファイルへ移し、行にはパス・文字数・sha256 を残す。文字列を途中で切らない | 移した後も超えるときは、`fields` を全文ファイルへ移した参照だけの行にする | | I5 | 実行 | 実行の記録と全文ファイルに、秘密の値が書かれない。伏せる対象はトークンの形の文字列と、秘密と宣言した環境変数の空でない値 | 書く前に伏せる(`secret_redact.redact`。短く縮めない) | @@ -98,7 +98,7 @@ | 要素 | 責務 | 新設 / 変更 | Pull Request | | --- | --- | --- | --- | -| 書き口(`lib/run_log.py`) | 置き場を決め、行を伏せて排他の中で 1 回で書く。全文ファイルへ逃がす。診断を標準エラーと記録の両方へ出す。寿命の掃除。標準ライブラリだけを使う | 新設 | 核の拡張 | +| 書き口(`lib/run_log.py`) | 置き場を決め、行を伏せて排他の中で書き切る。全文ファイルへ逃がす。診断を標準エラーと記録の両方へ出す。寿命の掃除。標準ライブラリだけを使う | 新設 | 核の拡張 | | 実行の核(`lib/run_core.py`) | E1 で識別子を振って書き口を開き、`ctx.log` を書き口へつなぐ。E8 で理由を受け、E9 を `finally` と SIGTERM の受け手から呼ぶ。書けなかったら結果を `stopped` にする | 変更(P1 が作るスケルトンを拡張する) | 核の拡張 | | シェルの起こし直し(`run_core.py exec`) | シェルの本体の標準エラーをパイプで受け、1 行ずつ標準エラーへ出してから記録へ `diag` で書く | 変更(P1 が作る `exec` へ足す) | 核の拡張 | | 秘密を伏せる部品(`lib/secret_redact.py`) | 伏せる処理(`redact`)と短く縮める処理(`shorten`)を分ける。`redact_output` は 2 つを順に呼ぶ形で残す | 変更 | 核の拡張 | @@ -224,9 +224,9 @@ classDiagram ``` - **プロセスに開いた記録は 1 つだけ**: `open_run` は開いた `RunLog` をモジュールの変数に持ち、`current()` で返す。核の外のプロセス(hook・テスト・核へ移す前のエントリポイント)では `None` で、`diag` は標準エラーへ書くだけになる -- **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種だけを受け、ほかは `ValueError`。8 種は `start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end` である +- **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種だけを受け、ほかは `ValueError`。8 種は `start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end` である。`event` が `diag` のときだけは `current().write` を直に呼ばず、`run_log.diag(fields["text"], short=fields.get("display"), **残りの鍵)` へ渡す。標準エラーへ先に出してから記録へ書く経路を 1 つにし、Python の直書きを `ctx.log("diag", ...)` へ置き換えても、端末に出ていた警告や進捗が消えないためである(受け入れ条件 6・I6) - **`run_log.diag(text, short=None, **fields)`**: 標準エラーへは `short` があれば `short`、無ければ `text` を 1 行で書く。記録へは `{"text": text, **fields}` を書き、`short` があれば `"display": short` も書く。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ -- **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前に `failed` を見て結果を `stopped` にする(I7) +- **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前と E9 の完了後の 2 回 `failed` を見る。E9 の前に `failed` なら、結果を `stopped` にしてから `end` を書きにいく。E9 で初めて失敗した(`end` の行やその全文ファイルを書けなかった)ときは、E9 の完了後の確認で本体の結果を `original_result` に入れた `stopped`・終了コード 1 へ置き換える。核は 2 回目の確認を終えてから標準出力と終了コードを確定する(I7) ## データ構造 @@ -338,6 +338,7 @@ sequenceDiagram end B-->>K: 結果・例外・SIGTERM・時間切れ(E8) K->>W: end(結果 JSON と終了コード、E9) + K->>W: failed を見直す(E9 で初めて書けなかった場合を含む) K-->>C: 結果 JSON 1 行(failed なら stopped で original_result に元の結果) ``` @@ -360,8 +361,10 @@ sequenceDiagram 1. 伏せる 2. JSON にする 3. 64 KiB を超えたら、長い文字列から全文ファイルへ移す -4. 記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取る -5. `os.write` を 1 回呼び、排他を返す +4. 記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取り、追記前のファイルの長さ(`os.fstat` の大きさ)を控える +5. 排他を持ったまま、1 行の全バイトを書き切るまで `os.write` を呼ぶ。`os.write` は容量不足などで例外を投げずに要求より少ないバイト数を返すことがあるため、戻り値を確かめ、残りのバイトを続けて書く +6. 書き切れなかった(`os.write` が例外を投げた・0 バイトを返した)ときは、排他を持ったまま `os.ftruncate` で追記前の長さへ戻してから、I7 の失敗(`failed`)にする。途中まで書いた行を残すと、次に書く子の行が同じ行へつながり I2 が崩れるためである。長さを戻せなかったときも `failed` にし、理由に戻せなかったことを書く +7. 排他を返す 全文ファイルは記録の行より先に書く。行が全文ファイルを指した時点で、全文ファイルが在る。 @@ -436,18 +439,20 @@ hook のエントリポイントの 6 か所は、次のとおり数から除い | 1・1c・I1 | 主スレッドが排他の区間に入る直前と出た直後に SIGTERM を送っても止まらずに終わる。保留の経路で終わったときも、本体は `write` の後の処理へ進まず、標準出力の結果 JSON(`stopped`・`gaps` に `interrupted`)と終了コード 1 が `end` の `result`・`code` に一致する | 印を `Lock` を取った後に立てる/保留の経路で E9 の後に `write` から本体へ戻る | | 1a・I3 | エントリポイントが別のエントリポイントを子プロセスで起こすと、子の行が親のファイルに入り、`parent_id` が親の `run_id` | 子へ `NDF_RUN_LOG` を渡さない/子が自分の識別子を `parent_id` に書く | | 1b・I2 | 子の実行を並行に複数起こし、64 KiB 近い行を書かせても、全行が JSON として読める | 排他を外して 2 回の `write` に分ける | +| 1b・I2・I7 | `os.write` が要求より少ないバイト数を返す・途中で例外を投げるように差し替えると、短く返す場合は残りを書き切って行が 1 つの JSON になり、例外の場合はファイルの長さが追記前に戻って `failed` になる。どちらの後も、次に書く行は独立した行として読める | 戻り値を確かめずに 1 回で返す/書き切れなかった行を残したまま排他を返す | | 1c | 本体の `decision` と止まった理由が行に残り、`end` の `fields` が標準出力の結果 JSON と終了コードに一致する | `end` に `summary` だけを書く | | 1d・I9 | 核の中で追記した帳簿の行とプランの状態の記録の行が `run_id` を持ち、核の外で書いた行は鍵を持たずに今の読み手で読める | `run_id` を必須にする/読み手が未知の鍵で落ちる | | 1e・I10 | 31 日前の日付のディレクトリが消え、29 日前は残る。消せない(権限)ときは `diag` が残り本体の結果が変わらない | 更新時刻で判定する/消せない例外で止まる | | 1f・I7 | 置き場を書けない場所にすると、標準エラーに理由が出て `stopped`・1。途中で書けなくすると本体は終わりまで動き、結果が `stopped` で `gaps` に `run-log-unwritable`、`original_result` に本体の元の結果 JSON がそのまま入る | 書けない例外を握りつぶして `ok` を返す/`stopped` に置き換えるときに本体の結果を捨てる | +| 1f・I7 | 本体が `ok` で終わり、E9 の `end` の行(またはその全文ファイル)で初めて書けなくすると、標準出力は `original_result` に本体の結果を持つ `stopped`、終了コードは 1 になる | `failed` を E9 の前にだけ見て、本体の `ok` と 0 を返す | | 2・I8 | 対象のファイルに `print(..., file=sys.stderr)` か `sys.stderr.write` を書くと構造チェックが落とす。例外リストに 1 行足すと落とす。書き口の中は落とさない | 検査を `file=sys.stderr` の文字列一致にして `file=sys.stdout if c else sys.stderr` を通す | -| 2a | `run_log.diag` の文が標準エラーと記録の `diag` の両方に出る。シェルの本体の `>&2` の行も `diag`(`source: sh`)で残る | シェルの標準エラーを記録へ書かずに捨てる | +| 2a | `run_log.diag` の文と `ctx.log("diag", ...)` の文が、どちらも標準エラーと記録の `diag` の両方に出る。シェルの本体の `>&2` の行も `diag`(`source: sh`)で残る | シェルの標準エラーを記録へ書かずに捨てる/`ctx.log` の `diag` を `current().write` へ直に渡して標準エラーへ出さない | | 2b | 移した Pull Request で例外リストの行が消え、残った行が `unused-allow` で落ちる | 移した後も行を残す | | 3・I4 | 本体の渡した 10,000 字の `summary` が、結果 JSON と `end` の行に同じ文字列で残る。10 か所の行き先ごとの扱いが表のとおり | 理由を 300 字で切る | | 3a・I4 | 64 KiB を超える理由は全文ファイルへ移り、行のパス・文字数・sha256 が全文と一致する。全文ファイルが行より先に在る | 全文ファイルを書かずに参照だけ残す/長い文字列を切る | | 4 | (手動)G6 の後に子の課題 8 件の再現の手順を 1 回ずつ通し、本文の「子の課題の判定」へ書く | — | | 5・I5 | `secret: true` の環境変数に値を入れ、本体がその値を診断・例外の理由・`summary` へ入れても、記録と全文ファイルに値が無い。トークンの形の文字列も無い。空文字列の値で他の文字列が壊れない | 伏せる処理を全文ファイルに通さない/空の値で置き換える | -| 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。記録の置き場を書けないときも同じ文が出る | 記録へ書いた後でしか標準エラーへ書かない | +| 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。直書きを `ctx.log("diag", ...)` へ置き換えたエントリポイントと `run_log.diag` へ置き換えたエントリポイントの両方で確かめる。記録の置き場を書けないときも同じ文が出る | 記録へ書いた後でしか標準エラーへ書かない | | 1e | 全体テストの後に、テストの外の `NDF_STATE_DIR`(未設定なら `~/.local/state/ndf`)の `runs/` の中身が増えも減りもしない | ルートの `conftest.py` から `NDF_STATE_DIR` の fixture を外す | | 1e | `plugins/ndf/` だけを根にして(ルートの `conftest.py` が読まれない、プラグインのキャッシュと同じ条件で)核を通すテストを実行した後も、テストの外の `NDF_STATE_DIR` の `runs/` の中身が増えも減りもしない | `plugins/ndf/conftest.py` から `NDF_STATE_DIR` の行を外す | | 7 | 柱の Pull Request ごとに `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | — | From eeb49288b7c627a71a4544e736f66b8d1e360ccb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:07:33 +0000 Subject: [PATCH 09/11] =?UTF-8?q?Docs:=20#2338=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99=EF=BC=88=E9=95=B7=E3=81=84=E6=96=87=E3=82=92=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=80=8140=20=E8=A1=8C=E3=82=92=E8=B6=85=E3=81=88?= =?UTF-8?q?=E3=82=8B=E7=AB=A0=E3=81=B8=E5=B0=8F=E8=A6=8B=E5=87=BA=E3=81=97?= =?UTF-8?q?=E3=82=92=E8=B6=B3=E3=81=97=E3=80=81=E7=94=A8=E8=AA=9E=E9=9B=86?= =?UTF-8?q?=E3=81=A8=E6=84=8F=E5=91=B3=E3=81=AE=E9=87=8D=E3=81=AA=E3=82=8B?= =?UTF-8?q?=E8=AA=9E=E3=82=92=E8=A8=80=E3=81=84=E6=8F=9B=E3=81=88=E3=82=8B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 構成要素の依存の図と、処理の流れの「図に描かない要素」を小見出しへ分けた - 100 字を超える文と、決定の記録の長い文を分けた - 「シェルの起こし直し」は用語集で別の意味(resume の送り直し)を持つため、「核の exec」へ言い換えた Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design-decisions.md | 17 ++++++++--------- issues/issue-2338-design.md | 20 ++++++++++++-------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/issues/issue-2338-design-decisions.md b/issues/issue-2338-design-decisions.md index 55491162c..66e419da3 100644 --- a/issues/issue-2338-design-decisions.md +++ b/issues/issue-2338-design-decisions.md @@ -6,8 +6,8 @@ ### 決定 1: 並行に書く子の実行の行が混ざらないよう、1 行を排他の中で書き切る -記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取ってから、1 行の全バイトを書き切るまで `os.write` を呼ぶ。 -`os.write` は容量不足などで例外を投げずに要求より少ないバイト数を返すことがあるため、戻り値を確かめて残りを続けて書く。 +記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取る。その中で、1 行の全バイトを書き切るまで `os.write` を呼ぶ。 +`os.write` は容量不足などで、例外を投げずに要求より少ないバイト数を返すことがある。そのため戻り値を確かめ、残りを続けて書く。 書き切れないときは、排他を持ったまま追記前の長さへ戻してから I7 の失敗にする。途中まで書いた行が残ると、次に書く子の行が同じ行へつながるためである。 Linux の通常のファイルへの `O_APPEND` の書き込みは、1 回なら他と混ざりにくい。 ただし書き込みが途中で分かれる場合(シグナル・容量)は、排他なしでは防げない。排他を置けば、行の大きさに関わらず混ざらないことを書き口だけで保証できる。 @@ -48,8 +48,7 @@ Linux の通常のファイルへの `O_APPEND` の書き込みは、1 回なら ### 決定 4: hook の変更を #2335 の対象範囲の外に保つため、問い合わせの記録(`ask.jsonl`)の行へ実行の識別子を足さない -核が起動した claude の hook へ環境変数 `NDF_RUN_ID` が届くことは確かめた(2026-11-04、Claude Code 2.1.318。設計文書の「あるべき姿の根拠」)。 -足すこと自体はできる。ただし問い合わせの記録を書くのは hook であり、hook の書く記録の形を変えることは要求の「含まない」にある。 +核が起動した claude の hook へ環境変数 `NDF_RUN_ID` が届くことは確かめた。足すこと自体はできる(2026-11-04、Claude Code 2.1.318。設計文書の「あるべき姿の根拠」)。ただし問い合わせの記録を書くのは hook であり、hook の書く記録の形を変えることは要求の「含まない」にある。 使用量の帳簿は hook(`usage-subagent.py`)も書く。ただし帳簿の行を作る `UsageRecord` はライブラリにあり、その既定値として環境変数を読むだけである。 hook の本体は変わらない。 @@ -84,11 +83,11 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ `end` と区切りが別の書き込みになる)。診断の種類と項目を付けられず、`argparse` や DBOS の出力まで混ざる。差し替えで足りるなら構造チェックが 要らないことになる。そうなると、要求の受け入れ条件 2 の「1 つの関数を通る」が検査できない。 -シェルの本体の `>&2` は 203 か所・24 ファイルあり、要求の前提 4 のとおり書き換えない。核は `exec` で起こし直した子の標準エラーをパイプで受け、 -1 行ずつ標準エラーへ出してから記録へ書く。確かめたのは、スクリプト自身に標準エラーを端末か判定する箇所が無いこと(設計文書の「あるべき姿の根拠」)までである。 +シェルの本体の `>&2` は 203 か所・24 ファイルあり、要求の前提 4 のとおり書き換えない。核は `exec` で子として起動したシェルの本体の標準エラーをパイプで受け、 +1 行ずつ標準エラーへ出してから記録へ書く。確かめたのは、スクリプト自身に標準エラーを端末か判定する箇所が無いことまでである(設計文書の「あるべき姿の根拠」)。 シェルの本体が呼ぶ外部コマンド(git・gh・uv・claude など)は、標準エラーが端末かで進捗表示・色・対話の出力を変えることがある。 -行単位で転送するため、改行の無い進捗の表示も遅れる。端末の文が変わらないこと(受け入れ条件 6)は未確認である。設計文書の「未確認のまま残ること」に載せ、 -核の拡張の Pull Request で代表的なシェルのエントリポイントの出力を比べて確かめる。 +行単位で転送するため、改行の無い進捗の表示も遅れる。端末の文が変わらないこと(受け入れ条件 6)は未確認である。設計文書の「未確認のまま残ること」に載せた。 +核の拡張の Pull Request で、代表的なシェルのエントリポイントの出力を比べて確かめる。 根拠: Value 5 / Value 7(MVV 版 5) @@ -117,7 +116,7 @@ Python の `sys.stderr` を、標準エラーと記録の両方へ書くオブ 鍵を足すだけなら、今の読み手(見積・使用量の集計・進捗の要約)は未知の鍵を読み飛ばす。識別子の無い古い行もそのまま読める。版を上げると 移行関数が要り、柱 P6(#2339)の型の部品より先に版を動かすことになる。値は書き手の引数ではなく、`UsageRecord` の既定値と `progress_write` の中で -環境変数 `NDF_RUN_ID` から取る。そのため書き手の呼び出し元を書き換えずに済む。呼び出し元は `UsageRecord` を作る 2 か所と `progress_write` の呼び出し 15 か所である(2026-11-04 に `grep -rn` で数えた)。 +環境変数 `NDF_RUN_ID` から取る。そのため書き手の呼び出し元を書き換えずに済む。書き換えずに済む呼び出し元は、`UsageRecord` を作る 2 か所と `progress_write` の呼び出し 15 か所である。数は 2026-11-04 に `grep -rn` で数えた。 根拠: Value 6 / Value 7(MVV 版 5) diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index a2a7c8c98..a7cca8f42 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -100,7 +100,7 @@ | --- | --- | --- | --- | | 書き口(`lib/run_log.py`) | 置き場を決め、行を伏せて排他の中で書き切る。全文ファイルへ逃がす。診断を標準エラーと記録の両方へ出す。寿命の掃除。標準ライブラリだけを使う | 新設 | 核の拡張 | | 実行の核(`lib/run_core.py`) | E1 で識別子を振って書き口を開き、`ctx.log` を書き口へつなぐ。E8 で理由を受け、E9 を `finally` と SIGTERM の受け手から呼ぶ。書けなかったら結果を `stopped` にする | 変更(P1 が作るスケルトンを拡張する) | 核の拡張 | -| シェルの起こし直し(`run_core.py exec`) | シェルの本体の標準エラーをパイプで受け、1 行ずつ標準エラーへ出してから記録へ `diag` で書く | 変更(P1 が作る `exec` へ足す) | 核の拡張 | +| 核の `exec`(`run_core.py exec`。シェルの本体を子として起動する) | シェルの本体の標準エラーをパイプで受け、1 行ずつ標準エラーへ出してから記録へ `diag` で書く | 変更(P1 が作る `exec` へ足す) | 核の拡張 | | 秘密を伏せる部品(`lib/secret_redact.py`) | 伏せる処理(`redact`)と短く縮める処理(`shorten`)を分ける。`redact_output` は 2 つを順に呼ぶ形で残す | 変更 | 核の拡張 | | 寿命の値(`lib/durable_keys.py`) | `KEEP_DAYS = 30` を持つ。`durable.py` と書き口がここから引く(`durable.py` は DBOS を読むため、書き口から import しない) | 変更(`durable.py` から移す) | 核の拡張 | | 使用量の帳簿(`lib/usage_ledger.py`) | `UsageRecord` に任意の `run_id` を足し、既定値を環境変数 `NDF_RUN_ID` から取る | 変更 | 核の拡張 | @@ -114,6 +114,8 @@ | 理由を切る 10 か所 | 下の「止まった理由を切る箇所の扱い」の表どおりに直す | 変更 | 核の拡張 | | 直書きを持つ 60 ファイル | `print(..., file=sys.stderr)`・`sys.stderr.write` を `ctx.log("diag", ...)` か `run_log.diag(...)` へ置き換える | 変更 | 移す 6 本(下の「移す順序」) | +### 呼ぶ向き + ```mermaid graph TD subgraph エントリポイント @@ -123,7 +125,7 @@ graph TD end subgraph 実行の核 K[核の流れ] - EX[シェルの起こし直し] + EX[核の exec] end subgraph 書き口 RL[run_log] @@ -224,9 +226,9 @@ classDiagram ``` - **プロセスに開いた記録は 1 つだけ**: `open_run` は開いた `RunLog` をモジュールの変数に持ち、`current()` で返す。核の外のプロセス(hook・テスト・核へ移す前のエントリポイント)では `None` で、`diag` は標準エラーへ書くだけになる -- **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種だけを受け、ほかは `ValueError`。8 種は `start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end` である。`event` が `diag` のときだけは `current().write` を直に呼ばず、`run_log.diag(fields["text"], short=fields.get("display"), **残りの鍵)` へ渡す。標準エラーへ先に出してから記録へ書く経路を 1 つにし、Python の直書きを `ctx.log("diag", ...)` へ置き換えても、端末に出ていた警告や進捗が消えないためである(受け入れ条件 6・I6) +- **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種だけを受け、ほかは `ValueError`。8 種は `start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end` である。`event` が `diag` のときだけは `current().write` を直に呼ばない。`run_log.diag(fields["text"], short=fields.get("display"), **残りの鍵)` へ渡す。標準エラーへ先に出してから記録へ書く経路を 1 つにするためである。Python の直書きを `ctx.log("diag", ...)` へ置き換えても、端末に出ていた警告や進捗が消えない(受け入れ条件 6・I6) - **`run_log.diag(text, short=None, **fields)`**: 標準エラーへは `short` があれば `short`、無ければ `text` を 1 行で書く。記録へは `{"text": text, **fields}` を書き、`short` があれば `"display": short` も書く。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ -- **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前と E9 の完了後の 2 回 `failed` を見る。E9 の前に `failed` なら、結果を `stopped` にしてから `end` を書きにいく。E9 で初めて失敗した(`end` の行やその全文ファイルを書けなかった)ときは、E9 の完了後の確認で本体の結果を `original_result` に入れた `stopped`・終了コード 1 へ置き換える。核は 2 回目の確認を終えてから標準出力と終了コードを確定する(I7) +- **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前と E9 の完了後の 2 回 `failed` を見る。E9 の前に `failed` なら、結果を `stopped` にしてから `end` を書きにいく。E9 で初めて失敗することもある(`end` の行やその全文ファイルを書けなかった)。そのときは E9 の完了後の確認で、結果を `stopped`・終了コード 1 へ置き換え、本体の結果を `original_result` に入れる。核は 2 回目の確認を終えてから標準出力と終了コードを確定する(I7) ## データ構造 @@ -238,7 +240,7 @@ classDiagram | --- | --- | --- | | `start` | `{"argv": [...], "cwd": str, "pid": int, "root": str}`(`argv` は伏せた後の値) | 核(E2) | | `decision` | 本体が渡す任意のオブジェクト。止まった理由を持つときは `reason` の鍵に全文 | 本体 | -| `diag` | `{"text": str, "display": str?, "source": "py" / "sh", ...}` | `run_log.diag`・シェルの起こし直し | +| `diag` | `{"text": str, "display": str?, "source": "py" / "sh", ...}` | `run_log.diag`・核の `exec` | | `end` | `{"result": <結果 JSON の全体>, "code": int, "how": "return" / "exception" / "sigterm" / "timeout", "error": str?}`(`error` は例外の型と理由の全文) | 核(E9) | `input`・`lease`・`ref`・`retry` の中身はそれぞれの柱(P8・P3・P5・P4)が決める。この柱は書き口だけを用意する。 @@ -342,11 +344,13 @@ sequenceDiagram K-->>C: 結果 JSON 1 行(failed なら stopped で original_result に元の結果) ``` +### 図に描かない要素 + 図に描かない要素は次のとおりである。 | 要素 | 図のどこで動くか | | --- | --- | -| シェルの起こし直し(E5) | 本体の位置に入る。`exec` の子の標準エラーを 1 行ずつ `diag` へ渡す読み手のスレッド | +| 核の `exec`(E5) | 本体の位置に入る。`exec` の子の標準エラーを 1 行ずつ `diag` へ渡す読み手のスレッド | | 秘密を伏せる部品・寿命の値 | 書き口の中で使う | | 使用量の帳簿・プランの状態の記録(E7) | 本体の中で書かれ、環境変数を読むだけ | | 環境変数の契約・契約の読み手 | E1 の前に、核が秘密の名前を読む | @@ -363,14 +367,14 @@ sequenceDiagram 3. 64 KiB を超えたら、長い文字列から全文ファイルへ移す 4. 記録のファイルを `O_APPEND` で開き、`fcntl.flock(LOCK_EX)` を取り、追記前のファイルの長さ(`os.fstat` の大きさ)を控える 5. 排他を持ったまま、1 行の全バイトを書き切るまで `os.write` を呼ぶ。`os.write` は容量不足などで例外を投げずに要求より少ないバイト数を返すことがあるため、戻り値を確かめ、残りのバイトを続けて書く -6. 書き切れなかった(`os.write` が例外を投げた・0 バイトを返した)ときは、排他を持ったまま `os.ftruncate` で追記前の長さへ戻してから、I7 の失敗(`failed`)にする。途中まで書いた行を残すと、次に書く子の行が同じ行へつながり I2 が崩れるためである。長さを戻せなかったときも `failed` にし、理由に戻せなかったことを書く +6. `os.write` が例外を投げたか 0 バイトを返したときは、書き切れなかったものとする。排他を持ったまま `os.ftruncate` で追記前の長さへ戻してから、I7 の失敗(`failed`)にする。途中まで書いた行を残すと、次に書く子の行が同じ行へつながり I2 が崩れるためである。長さを戻せなかったときも `failed` にし、理由に戻せなかったことを書く 7. 排他を返す 全文ファイルは記録の行より先に書く。行が全文ファイルを指した時点で、全文ファイルが在る。 ### SIGTERM と時間切れ -CPython の Python で書いたシグナルの受け手は、どのスレッドがシグナルを受けても主スレッドで動く。`signal.pthread_sigmask` は呼んだスレッドにしか効かないため、受け手が主スレッドの排他の区間の中で動くことを防げない。核の `exec` の読み手のスレッドと、G4 で移す監視・中継のスレッドがこの条件を満たす。`flock` は同じプロセスでも、開き直したファイル記述子どうしで排他になる。そのため受け手が排他の区間の中で `end` を書きにいくと、自分自身を待って止まる。そこで次の形にする。 +CPython の Python で書いたシグナルの受け手は、どのスレッドがシグナルを受けても主スレッドで動く。`signal.pthread_sigmask` は呼んだスレッドにしか効かないため、受け手が主スレッドの排他の区間の中で動くことを防げない。核の `exec` の読み手のスレッドと、「移す順序」の G4 で移す監視・中継のスレッドがこの条件を満たす。`flock` は同じプロセスでも、開き直したファイル記述子どうしで排他になる。そのため受け手が排他の区間の中で `end` を書きにいくと、自分自身を待って止まる。そこで次の形にする。 1. 書き口はプロセスに 1 つの `threading.Lock` でスレッドどうしの書き込みを順に並べ、その中で `flock` を取る。主スレッドが排他の区間にいる間は、スレッドごとの印(`threading.local`)を立てる。印は `Lock` を取る前に立て、`Lock` を返した後に下ろす。`Lock` を取ってから印を立てるまでと、印を下ろしてから `Lock` を返すまでの隙間を作らないためである。隙間で受け手が動くと、印が無いと見て項目 2 の経路で同じ `Lock` を取りにいき、主スレッドが自分を待って止まる 2. 核の受け手(SIGTERM と時間切れのシグナル SIGALRM)は、主スレッドが排他の区間にいなければ、その場で E9 を呼んで `end` を書き、続けて終わらせ方の関数を呼ぶ From 4edcfe4c309eaeabf1da6d80685ebb60dd11bb86 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:13:44 +0000 Subject: [PATCH 10/11] =?UTF-8?q?docs(#2338):=20end=20=E3=82=92=E6=9B=B8?= =?UTF-8?q?=E3=81=8D=E5=A7=8B=E3=82=81=E3=81=9F=E5=BE=8C=E3=81=AE=E3=82=B7?= =?UTF-8?q?=E3=82=B0=E3=83=8A=E3=83=AB=E3=81=A8=E8=A8=BA=E6=96=AD=E3=81=AE?= =?UTF-8?q?=E6=94=B9=E8=A1=8C=E3=81=AE=E6=89=B1=E3=81=84=E3=82=92=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=AB=E6=98=8E=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index a7cca8f42..3f53b0d3f 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -227,7 +227,7 @@ classDiagram - **プロセスに開いた記録は 1 つだけ**: `open_run` は開いた `RunLog` をモジュールの変数に持ち、`current()` で返す。核の外のプロセス(hook・テスト・核へ移す前のエントリポイント)では `None` で、`diag` は標準エラーへ書くだけになる - **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種だけを受け、ほかは `ValueError`。8 種は `start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end` である。`event` が `diag` のときだけは `current().write` を直に呼ばない。`run_log.diag(fields["text"], short=fields.get("display"), **残りの鍵)` へ渡す。標準エラーへ先に出してから記録へ書く経路を 1 つにするためである。Python の直書きを `ctx.log("diag", ...)` へ置き換えても、端末に出ていた警告や進捗が消えない(受け入れ条件 6・I6) -- **`run_log.diag(text, short=None, **fields)`**: 標準エラーへは `short` があれば `short`、無ければ `text` を 1 行で書く。記録へは `{"text": text, **fields}` を書き、`short` があれば `"display": short` も書く。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ +- **`run_log.diag(text, short=None, **fields)`**: 標準エラーへは `short` があれば `short`、無ければ `text` を、改行の有無も含めて渡された文のまま書き、書くたびに flush する。行末の改行は呼ぶ側が決め、`diag` は改行を足さず、潰さない。子プロセスの複数行の標準エラーを流す箇所(G1 の `lib/loop_drive.py:125`・`lib/post_queue.py:808`)は複数行のまま出る。改行を付けずに入力を促す箇所(G4 の `relay_lib/ask.py:47`)は改行の無いまま、すぐに出る(受け入れ条件 6)。記録へは `{"text": text, **fields}` を書き、`short` があれば `"display": short` も書く。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ - **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前と E9 の完了後の 2 回 `failed` を見る。E9 の前に `failed` なら、結果を `stopped` にしてから `end` を書きにいく。E9 で初めて失敗することもある(`end` の行やその全文ファイルを書けなかった)。そのときは E9 の完了後の確認で、結果を `stopped`・終了コード 1 へ置き換え、本体の結果を `original_result` に入れる。核は 2 回目の確認を終えてから標準出力と終了コードを確定する(I7) ## データ構造 @@ -381,6 +381,7 @@ CPython の Python で書いたシグナルの受け手は、どのスレッド 3. 主スレッドが排他の区間にいれば、受け手は `end` を書かずに、終わり方(`sigterm` / `timeout`)を保留の印として残して戻る。`write` は排他を返して印を下ろした後に保留の印を見て、印があれば E9 を呼び、続けて項目 2 と同じ終わらせ方の関数を呼ぶ。`write` は本体へ戻らない - **終わらせ方の関数**: 項目 2 と 3 が共有する 1 つの関数にする。結果を #2335 の失敗の形(`stopped`・終了コード 1・`gaps` に `interrupted` か `timeout`)にする。続けて、核だけが捕まえる例外を投げる。例外は `BaseException` から派生させ、本体の `except Exception` に捕まらない。核の `main` がこの例外を捕まえ、`end` の `result`・`code` と同じ結果 JSON を標準出力へ出し、同じ終了コードで終わる。`end` の `how` が `sigterm` / `timeout` のときに、標準出力と終了コードが本体の値にならない(1c) - E9 はプロセスで 1 回だけ `end` を書く。2 回目の呼び出しは何も書かずに戻る(保留の印を残した後に項目 2 の経路でも呼ばれる場合を含む) + - 受け手は、E9 が `end` を書き始める前に受けたシグナルでだけ、終わらせ方の関数へ進む。正常の終わりの E9 が `end` を書き始めた後(E9 の途中で保留の経路へ回った場合と、E9 の後・標準出力の前に受けた場合)は、終わらせ方の関数を呼ばない。核は `end` に書いた `result`・`code` をそのまま標準出力と終了コードに出して終わる。E9 は `end` を書き始める時点で「書き始めた」の印を立て、項目 2・3 はこの印を見て分かれる。`end` の `how` と、標準出力の結果 JSON・終了コードが食い違わないためである(1c) 4. ほかのスレッドが排他を持つ間に受け手が動いたときは、そのスレッドが排他を返すのを待ってから書く(待つ相手が別のスレッドのため止まらない) 5. `close` が `end` を書いた後の `write` は、記録へ書かずに捨てる。読み手のスレッドが後から書いても、`end` の行は最後に並ぶ @@ -445,6 +446,7 @@ hook のエントリポイントの 6 か所は、次のとおり数から除い | 1b・I2 | 子の実行を並行に複数起こし、64 KiB 近い行を書かせても、全行が JSON として読める | 排他を外して 2 回の `write` に分ける | | 1b・I2・I7 | `os.write` が要求より少ないバイト数を返す・途中で例外を投げるように差し替えると、短く返す場合は残りを書き切って行が 1 つの JSON になり、例外の場合はファイルの長さが追記前に戻って `failed` になる。どちらの後も、次に書く行は独立した行として読める | 戻り値を確かめずに 1 回で返す/書き切れなかった行を残したまま排他を返す | | 1c | 本体の `decision` と止まった理由が行に残り、`end` の `fields` が標準出力の結果 JSON と終了コードに一致する | `end` に `summary` だけを書く | +| 1c | 本体が `ok` で終わり、正常の終わりの E9 の途中(排他の区間の中)と E9 の直後・標準出力の前に SIGTERM を送ると、`end` は `how: return` のまま 1 行だけ並び、標準出力の結果 JSON と終了コードが `end` の `result`・`code`(`ok`・0)に一致する | `end` を書き始めた後のシグナルでも終わらせ方の関数を呼び、`stopped`・1 を出す | | 1d・I9 | 核の中で追記した帳簿の行とプランの状態の記録の行が `run_id` を持ち、核の外で書いた行は鍵を持たずに今の読み手で読める | `run_id` を必須にする/読み手が未知の鍵で落ちる | | 1e・I10 | 31 日前の日付のディレクトリが消え、29 日前は残る。消せない(権限)ときは `diag` が残り本体の結果が変わらない | 更新時刻で判定する/消せない例外で止まる | | 1f・I7 | 置き場を書けない場所にすると、標準エラーに理由が出て `stopped`・1。途中で書けなくすると本体は終わりまで動き、結果が `stopped` で `gaps` に `run-log-unwritable`、`original_result` に本体の元の結果 JSON がそのまま入る | 書けない例外を握りつぶして `ok` を返す/`stopped` に置き換えるときに本体の結果を捨てる | @@ -456,7 +458,7 @@ hook のエントリポイントの 6 か所は、次のとおり数から除い | 3a・I4 | 64 KiB を超える理由は全文ファイルへ移り、行のパス・文字数・sha256 が全文と一致する。全文ファイルが行より先に在る | 全文ファイルを書かずに参照だけ残す/長い文字列を切る | | 4 | (手動)G6 の後に子の課題 8 件の再現の手順を 1 回ずつ通し、本文の「子の課題の判定」へ書く | — | | 5・I5 | `secret: true` の環境変数に値を入れ、本体がその値を診断・例外の理由・`summary` へ入れても、記録と全文ファイルに値が無い。トークンの形の文字列も無い。空文字列の値で他の文字列が壊れない | 伏せる処理を全文ファイルに通さない/空の値で置き換える | -| 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。直書きを `ctx.log("diag", ...)` へ置き換えたエントリポイントと `run_log.diag` へ置き換えたエントリポイントの両方で確かめる。記録の置き場を書けないときも同じ文が出る | 記録へ書いた後でしか標準エラーへ書かない | +| 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。直書きを `ctx.log("diag", ...)` へ置き換えたエントリポイントと `run_log.diag` へ置き換えたエントリポイントの両方で確かめる。記録の置き場を書けないときも同じ文が出る。子プロセスの複数行の標準エラーは複数行のまま、改行の無い入力の促しは改行の無いまま、入力を待つ前に出る | 記録へ書いた後でしか標準エラーへ書かない/`diag` が改行を足すか潰す/flush しない | | 1e | 全体テストの後に、テストの外の `NDF_STATE_DIR`(未設定なら `~/.local/state/ndf`)の `runs/` の中身が増えも減りもしない | ルートの `conftest.py` から `NDF_STATE_DIR` の fixture を外す | | 1e | `plugins/ndf/` だけを根にして(ルートの `conftest.py` が読まれない、プラグインのキャッシュと同じ条件で)核を通すテストを実行した後も、テストの外の `NDF_STATE_DIR` の `runs/` の中身が増えも減りもしない | `plugins/ndf/conftest.py` から `NDF_STATE_DIR` の行を外す | | 7 | 柱の Pull Request ごとに `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | — | From 82490984aaa0482c5d412222dfbf13196cac0396 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:17:47 +0000 Subject: [PATCH 11/11] =?UTF-8?q?docs(#2338):=20diag=20=E3=81=B8=E6=B8=A1?= =?UTF-8?q?=E3=81=99=E6=96=87=E3=81=A8=20200=20=E5=AD=97=E3=81=AE=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=81=AB=E8=A1=8C=E6=9C=AB=E3=81=AE=E6=94=B9=E8=A1=8C?= =?UTF-8?q?=E3=82=92=E6=8C=81=E3=81=9F=E3=81=9B=E3=82=8B=E8=A6=8F=E5=89=87?= =?UTF-8?q?=E3=82=92=E8=A8=AD=E8=A8=88=E3=81=AB=E6=98=8E=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2338-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/issues/issue-2338-design.md b/issues/issue-2338-design.md index 3f53b0d3f..2ec1fe31d 100644 --- a/issues/issue-2338-design.md +++ b/issues/issue-2338-design.md @@ -112,7 +112,7 @@ | 構造チェック(`scripts/check-script-structure.py`) | 検査の種類 `stderr-direct` を足す。例外リストの増加は既存の検査(#2335 の `allow-grow`)で落とす | 変更 | 核の拡張 | | 例外リスト(`scripts/script-structure-allow/`) | 直書きを持つ 60 ファイルを `stderr-direct` の行として載せ、移した Pull Request が消す | 変更 | 核の拡張 → 移す 6 本 | | 理由を切る 10 か所 | 下の「止まった理由を切る箇所の扱い」の表どおりに直す | 変更 | 核の拡張 | -| 直書きを持つ 60 ファイル | `print(..., file=sys.stderr)`・`sys.stderr.write` を `ctx.log("diag", ...)` か `run_log.diag(...)` へ置き換える | 変更 | 移す 6 本(下の「移す順序」) | +| 直書きを持つ 60 ファイル | `print(..., file=sys.stderr)`・`sys.stderr.write` を `ctx.log("diag", ...)` か `run_log.diag(...)` へ置き換える。`print(*args, sep=s, end=e, file=sys.stderr)` は `s.join(map(str, args)) + e` を文として渡し(既定では末尾に `\n` を含む)、`sys.stderr.write(x)` は `x` をそのまま渡す | 変更 | 移す 6 本(下の「移す順序」) | ### 呼ぶ向き @@ -227,7 +227,7 @@ classDiagram - **プロセスに開いた記録は 1 つだけ**: `open_run` は開いた `RunLog` をモジュールの変数に持ち、`current()` で返す。核の外のプロセス(hook・テスト・核へ移す前のエントリポイント)では `None` で、`diag` は標準エラーへ書くだけになる - **`ctx.log(event, fields)`** は `current().write` を呼ぶ。`event` は #2335 の 8 種だけを受け、ほかは `ValueError`。8 種は `start` / `input` / `lease` / `ref` / `retry` / `decision` / `diag` / `end` である。`event` が `diag` のときだけは `current().write` を直に呼ばない。`run_log.diag(fields["text"], short=fields.get("display"), **残りの鍵)` へ渡す。標準エラーへ先に出してから記録へ書く経路を 1 つにするためである。Python の直書きを `ctx.log("diag", ...)` へ置き換えても、端末に出ていた警告や進捗が消えない(受け入れ条件 6・I6) -- **`run_log.diag(text, short=None, **fields)`**: 標準エラーへは `short` があれば `short`、無ければ `text` を、改行の有無も含めて渡された文のまま書き、書くたびに flush する。行末の改行は呼ぶ側が決め、`diag` は改行を足さず、潰さない。子プロセスの複数行の標準エラーを流す箇所(G1 の `lib/loop_drive.py:125`・`lib/post_queue.py:808`)は複数行のまま出る。改行を付けずに入力を促す箇所(G4 の `relay_lib/ask.py:47`)は改行の無いまま、すぐに出る(受け入れ条件 6)。記録へは `{"text": text, **fields}` を書き、`short` があれば `"display": short` も書く。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ +- **`run_log.diag(text, short=None, **fields)`**: 標準エラーへは `short` があれば `short`、無ければ `text` を、改行の有無も含めて渡された文のまま書き、書くたびに flush する。行末の改行は呼ぶ側が決め、`diag` は改行を足さず、潰さない。`short` の行末の改行も呼ぶ側が付ける。`print` を置き換える箇所は `print` が書いていた行末の改行(`end`。既定は `\n`)を文に含めて渡し、核の `exec` の読み手は子の標準エラーの各行を行末の改行を残したまま渡す。子プロセスの複数行の標準エラーを流す箇所(G1 の `lib/loop_drive.py:125`・`lib/post_queue.py:808`)は複数行のまま出る。改行を付けずに入力を促す箇所(G4 の `relay_lib/ask.py:47`)は改行の無いまま、すぐに出る(受け入れ条件 6)。記録へは `{"text": text, **fields}` を書き、`short` があれば `"display": short` も書く。ライブラリの関数は実行の文脈を受け取らないため、この関数を呼ぶ - **`write` が書けなかったとき**: 例外を投げず、`failed` に理由を持ち、標準エラーへ理由を 1 回だけ出す。以後の `write` は記録へ書かない。核は E9 の前と E9 の完了後の 2 回 `failed` を見る。E9 の前に `failed` なら、結果を `stopped` にしてから `end` を書きにいく。E9 で初めて失敗することもある(`end` の行やその全文ファイルを書けなかった)。そのときは E9 の完了後の確認で、結果を `stopped`・終了コード 1 へ置き換え、本体の結果を `original_result` に入れる。核は 2 回目の確認を終えてから標準出力と終了コードを確定する(I7) ## データ構造 @@ -309,7 +309,7 @@ classDiagram | `merged_lib/merge.py:317` | 例外の理由(結果の `summary` になる) | 切らない | | `lib/sprint_loop_gate.py:127` | 周回の記録の止まった理由 | 全文を `decision` で記録へ書き、周回の記録は 300 字のまま | | `lib/sprint_loop_gate.py:128` | 結果の `summary` | 切らない | -| `lib/monitor_loop.py:120` | 端末の監視の行 | `run_log.diag(全文, short=先頭 200 字)` | +| `lib/monitor_loop.py:120` | 端末の監視の行 | `run_log.diag(全文, short=表示)`。表示は全文の行末の改行を除いた先頭 200 字に、全文と同じ行末の改行を付けたもの | | `lib/monitor_loop.py:240` | 端末の監視の行 | 同上 | | `release-steps.py:141` | ステップの結果の文(結果の `summary` になる) | 切らない | @@ -458,7 +458,7 @@ hook のエントリポイントの 6 か所は、次のとおり数から除い | 3a・I4 | 64 KiB を超える理由は全文ファイルへ移り、行のパス・文字数・sha256 が全文と一致する。全文ファイルが行より先に在る | 全文ファイルを書かずに参照だけ残す/長い文字列を切る | | 4 | (手動)G6 の後に子の課題 8 件の再現の手順を 1 回ずつ通し、本文の「子の課題の判定」へ書く | — | | 5・I5 | `secret: true` の環境変数に値を入れ、本体がその値を診断・例外の理由・`summary` へ入れても、記録と全文ファイルに値が無い。トークンの形の文字列も無い。空文字列の値で他の文字列が壊れない | 伏せる処理を全文ファイルに通さない/空の値で置き換える | -| 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。直書きを `ctx.log("diag", ...)` へ置き換えたエントリポイントと `run_log.diag` へ置き換えたエントリポイントの両方で確かめる。記録の置き場を書けないときも同じ文が出る。子プロセスの複数行の標準エラーは複数行のまま、改行の無い入力の促しは改行の無いまま、入力を待つ前に出る | 記録へ書いた後でしか標準エラーへ書かない/`diag` が改行を足すか潰す/flush しない | +| 6・I6 | 移す前と後で、同じ入力に対する標準エラーの文が一致する。直書きを `ctx.log("diag", ...)` へ置き換えたエントリポイントと `run_log.diag` へ置き換えたエントリポイントの両方で確かめる。記録の置き場を書けないときも同じ文が出る。子プロセスの複数行の標準エラーは複数行のまま、改行の無い入力の促しは改行の無いまま、入力を待つ前に出る。`print` を置き換えた行と、200 字に切った監視の行は行末の改行を持ち、次の出力とつながらない | 記録へ書いた後でしか標準エラーへ書かない/`diag` が改行を足すか潰す/`print` の `end` や切った表示の改行を落とす/flush しない | | 1e | 全体テストの後に、テストの外の `NDF_STATE_DIR`(未設定なら `~/.local/state/ndf`)の `runs/` の中身が増えも減りもしない | ルートの `conftest.py` から `NDF_STATE_DIR` の fixture を外す | | 1e | `plugins/ndf/` だけを根にして(ルートの `conftest.py` が読まれない、プラグインのキャッシュと同じ条件で)核を通すテストを実行した後も、テストの外の `NDF_STATE_DIR` の `runs/` の中身が増えも減りもしない | `plugins/ndf/conftest.py` から `NDF_STATE_DIR` の行を外す | | 7 | 柱の Pull Request ごとに `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | — |