diff --git a/docs/glossary.md b/docs/glossary.md index 2fa50f4a5..7d54bb715 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -362,6 +362,10 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 登録簿 | `relay_registry` | 統括役だけが書く、名前つきのラッパーの名前と cwd(起動元のリポジトリ)・最初の引数・tmux のセッションの表(registry.json。作業ディレクトリのパスは持たない) | — | — | `docs/specifications/ndf-relay-multi-session-supervisor.md` | | 起動の依頼 | `spawn_request` | 頼む側が統括役の受付箱へ置く 1 件のファイル。起動・やり直しの種類と名前・作業ディレクトリ・引数を持つ | — | — | `docs/specifications/ndf-relay-multi-session-supervisor.md` | | 起動のブロック | `spawn_block` | conductor の応答に置く、情報文字列が ndf-spawn <名前> の囲み。中身が最初の入力になる | — | — | `docs/specifications/ndf-relay-multi-session-supervisor.md` | +| 並列の計画 | — | 1 つのリポジトリで同時に進めるレーンの一覧と、レーンに共通の決まりを書いた 1 つの文書。オーガナイザーが読む。名前(計画名)を持つ | — | — | — | +| レーン | — | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ | — | — | — | +| オーガナイザー | — | --parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別 | — | — | — | +| 配布の依頼 | — | レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件の依頼 | — | — | — | | 再起動の方針 | — | conductor が工程境界で会話を切るかを決める規則の組。現行方式と試行方式の 2 つ | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 現行方式 | — | 運用上限 200K を超えたら次の工程を止め、4 つのカットポイントではコンテキスト量に関わらず ndf-next を出す再起動の方針。既定 | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 試行方式 | — | カットポイントを再起動を判断できる境界として扱い、判断開始の値・運用上限・容量で継続か再起動かを決める再起動の方針。明示的に選んだときだけ働く | — | — | `docs/specifications/ndf-context-restart-policy.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 822a5b16a..2da59b0f6 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5151,6 +5151,30 @@ "meaning": "conductor の応答に置く、情報文字列が ndf-spawn <名前> の囲み。中身が最初の入力になる", "source": "docs/specifications/ndf-relay-multi-session-supervisor.md" }, + { + "term": "並列の計画", + "context": "ndf-workflow", + "meaning": "1 つのリポジトリで同時に進めるレーンの一覧と、レーンに共通の決まりを書いた 1 つの文書。オーガナイザーが読む。名前(計画名)を持つ", + "pending_source": "issues/issue-2436-requirements.md" + }, + { + "term": "レーン", + "context": "ndf-workflow", + "meaning": "並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ", + "pending_source": "issues/issue-2436-requirements.md" + }, + { + "term": "オーガナイザー", + "context": "ndf-workflow", + "meaning": "--parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別", + "pending_source": "issues/issue-2436-requirements.md" + }, + { + "term": "配布の依頼", + "context": "ndf-workflow", + "meaning": "レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件の依頼", + "pending_source": "issues/issue-2436-requirements.md" + }, { "term": "再起動の方針", "context": "ndf-workflow", diff --git a/issues/issue-2436-design-approvals.md b/issues/issue-2436-design-approvals.md new file mode 100644 index 000000000..e51184d6f --- /dev/null +++ b/issues/issue-2436-design-approvals.md @@ -0,0 +1,157 @@ +# 並列開発の設計: 問いをオーガナイザーへ集め、作業は持ち主のレーンと一時レーンへ渡す(#2436 の受け入れ条件 6c〜6f) + +設計文書 [issue-2436-design.md](issue-2436-design.md) から、オーガナイザーが問いを集めて答えを配る部分を分けたファイル。レーンのループの側(質問待ち・直しの周回)は [issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md)、本番の配布は設計文書の「まとめた配布」にある。決定の理由は [issue-2436-design-decisions.md](issue-2436-design-decisions.md) の決定 16〜19 にある。 + +## 目的 + +- **今どうなっているか**: 問い(`AskUserQuestion`)は各レーンの conductor が出し、答えまでレーンが止まる。夜の間はすべてのレーンが問いで止まることがあり、利用者はレーンを回って答える +- **変えると何が成り立つか**: 並列の計画の下では、区分つきの問いはすべてオーガナイザーへ集まり、1 回の問いにまとまる。レーンは問いで止まらず、別の作業へ進む。オーガナイザーは作業を持たない。答えの後の処理は持ち主のレーンが、持ち主のいない共有の作業は一時レーンが行う + +## 責務の分け方 + +| 担い手 | 持つもの | +| --- | --- | +| オーガナイザー | 問いを集める・示す・答えを送る。一時レーンを起こす(`relay.py spawn ... --role helper`)。終わっている持ち主のレーンを起こす(`relay.py restart <レーン>` の 1 行)。作業は持たない | +| 持ち主のレーン | 答えの後の処理(承認ラベル `design-approved`・`loop answer` の承認 / 直して出し直す / やらない・設計 PR のマージ・設計と実装の直し・revert)を周回の境で行う | +| 一時レーン(`<計画名>-rel<連番>`) | 持ち主のいない共有の作業: まとめた配布のプラン(提示のプランと本番のプラン)の実行、その失敗の対応(CI の失敗・衝突)、`release/v<版>-sync` の版数の衝突の解消。結果を耐久ワークフローへ返して自分で止まる。直せなければ問いとしてオーガナイザーへ送る | + +一時レーンの起動と数え方は設計文書の「一時レーン」にある。 + +## 当たる問い + +| 区分(`metadata.source`) | レーンの送り方 | オーガナイザーの出し方 | +| --- | --- | --- | +| `ndf-gate:1` | ループは「人の判断待ちへ移す」経路で設計の承認の依頼(`kind: gate1`)を送る。1 スプリントのレーンは conductor が `parallel.py request-gate1` を打つ。どちらも漏れた問いは hook が送る | 3 つの答え(承認 / 直して出し直す / やらない) | +| `ndf-gate:2` | レーンは配布の依頼を送るため問わない。一時レーンが本番のプランの承認ゲート 2 で止まったら、承認資料を添えた `kind: gate2` の依頼を送る。漏れた問いは hook が送る | 設計文書の「まとめた配布」 | +| `ndf-ask:redline:<番号>`・`cost`・`irreversible`・`legal`・`ndf-ask:<名前>` | hook が送る(`kind: ask`) | 問いの文面と選択肢をそのまま、区分を付けて出す | +| `ndf-ask:human-action` | hook が送る(`kind: ask`。一時レーンの問いも同じ) | 代われないため、`relay.py attach <レーン>` の 1 行を添えて「済んだ」「まだ」で問う。「済んだ」をレーンへ返す | + +## 問いの hook がレーンの問いを送る + +問いの hook は PreToolUse の `AskUserQuestion` の guard(`plugins/ndf/scripts/hook_lib/ask_guard.py` の `ask_decision`。判定は `lib/ask_policy.py`、区分の正本は `skills/development-workflow/references/asking.md`)である。判定が通す(`gate`・`redline`・`allowed`)問いのとき、次を足す。 + +1. 環境変数 `NDF_RELAY_NAME` があり、メインディレクトリの `.ndf/parallel/*/plan.json` のどれかがその名前のレーンを持つか、作業ディレクトリ(`NDF_RELAY_DIR`)の役割のファイルが `helper` で計画名を持つかを、ファイルだけで見る(`lane_name_in_plans()`。hook の 10 秒の上限の中で DBOS を読まないため) +2. 当たれば `parallel.py request-ask` を子プロセスで打つ(上限 8 秒)。`request-ask` は送り手の資格を `parallel_lane.sender_of()` で確かめる。資格は 2 つで、`lane_of()` が返す動いている計画のレーンか、役割のファイルが `helper` で、その計画名の耐久ワークフローが終わっていない(状態が `PENDING`)一時レーンである。一時レーンは `lanes` に載らず `lane_of()` に当たらないため、`lane_of()` だけでは判定しない。資格を確かめたら、問いの中身(文面・選択肢・区分・課題・作業の場所・レーン・判定の記録の `id`)を `kind: ask` のメッセージで送る +3. 送れたら `permissionDecision: deny` で問いを止め、理由に「オーガナイザーへ送った(依頼 ``)。この作業を保留し、別の作業へ進め。答えは周回の境で届く」と保留の手順(下の「レーンの保留」)を返す +4. レーンでない・計画が動いていない・送れない・上限を越えたときは、今どおり問いを通す(I8 の「どの失敗も通す」と同じ) + +判定が止める(`deny`・`advise`)問いは今どおりで、送らない。 + +## レーンの保留 + +| レーン | 保留の仕方 | 答えが届いたとき | +| --- | --- | --- | +| ループ(設計の承認) | 今の「人の判断待ちへ移し、残りの課題で続ける」(`move_to_waiting`) | 周回の境で読み、答えに応じて次の周回で選ぶ([issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md)) | +| ループ(作業の途中の問い) | 周回を「質問待ち」で締め、止まったプランとステップを記録して次の周回へ進む | 周回の境で、止まったプランを続きのステップから流し、残りのステージとまとめを流す(今の承認ゲート 2 の続きの `gate2_pipeline` と同じ形) | +| 1 スプリント | 引継ぎ文書の「次にやること」に続きのコマンドと依頼の `id` を書き、`ndf-next` を出さずにセッションを終える | オーガナイザーが `relay.py restart <レーン>` で起こす。レーンは `parallel.py await --from` で答えを読み、続きから進む | + +**作業の途中の問いは、課題ごとに分けて保留しない。** 人の判断待ちへ移せるのは、課題ごとにプランが分かれる設計のステージだけである(`move_to_waiting` が設計のプランの課題を外す)。実装から後のステージは周回の課題をまとめた 1 本のプランで進むため、1 つの課題だけを外せない。そのため周回ごと保留する。 + +## 設計の承認の依頼 + +依頼は配布の依頼と同じ話題 `inbox` のメッセージで、`kind` で分ける。 + +| 項目 | `kind: gate1` | `kind: ask` | +| --- | --- | --- | +| `id` | `<レーン名>-g<設計 PR の番号>-` | `<レーン名>-q<判定の記録の id>` | +| `lane`・`issue` | レーン名・課題 | レーン名・課題(分かれば) | +| 中身 | `pr`(設計 PR)・`report`(承認資料のパス)・`verdict`・`reasons`(MVV 判定) | `source`(区分)・`question`・`options`・`cwd`(作業の場所)・`ledger_id` | +| `loop`・`sprint_state` | ループ名・スプリント状態ファイル | 同じ | +| `stamp` | 設計 PR の先頭の SHA | 作業の場所の `HEAD` | + +**依頼の `id` は、論理的な依頼 1 件に 1 つの値で、送る側が依頼の中身から決める。** 受け取った依頼の数からは採番しない(数が更新される前に続けて送ると、後の依頼が前と同じ `id`・冪等の鍵になって消え、受け取った後の打ち直しでは別の `id` になって二重に問われるため)。設計の承認は、設計 PR と問いの時点の先頭の SHA の組が 1 つの依頼である。同じ先頭で打ち直すと同じ `id` になり冪等の鍵が 2 度目を数えず、「直して出し直す」で push すると先頭が変わって新しい依頼になる。承認ゲートの外の問いは、hook が問いを止めるたびに追記する判定の記録(`lib/ask_ledger.py` の行)の `id` を使う。1 回の `AskUserQuestion` が 1 つの依頼で、行は呼び出しごとに 1 つのため、同じ文面を問い直しても別の依頼になる(行の `id` は `ask_ledger.verdict_row` が作り、追記に失敗しても `ask_guard._record_verdict` が返す)。答えは `stamp` をそのまま付けて返し、レーンは今の印と違えば答えを使わずに問い直す。同じ原因の問いのまとめ・4 回目の「直して出し直す」の選択肢・一時レーンの失敗の問いは [issue-2436-design-failures.md](issue-2436-design-failures.md) にある。承認ゲート 2 の依頼(`kind: gate2`)の `stamp` は承認資料の承認したコミットで、`id` はまとめた配布の連番と提示の番号から `<計画名>-p<連番>-<提示>` とする。 + +## オーガナイザーがまとめて問う + +```mermaid +sequenceDiagram + participant L as レーン + participant W as 耐久ワークフロー + participant O as オーガナイザー + participant U as 利用者 + L->>W: gate1 か ask の依頼(inbox) + L->>L: 保留して別の作業へ + W-->>O: wait が questions + O->>U: 区分ごとの AskUserQuestion + U-->>O: 答え + O->>W: parallel.py answer(依頼ごと) + W-->>L: answer-依頼(周回の境で読む) + L->>L: ラベル・loop answer・直し +``` + +1. 耐久ワークフローは `inbox` の `gate1` と `ask` を `questions_pending` に入れ、イベント `questions`(`{seq, items}`)を立てる。配布の止まりの間も 60 秒ごとに `inbox` を読む +2. `wait` が `questions` を返したら、オーガナイザーは同じ回の中で、区分ごとに `AskUserQuestion` を呼ぶ。`metadata.source` は呼び出しごとに 1 つのため、区分の違う問いを 1 つの呼び出しにまとめない +3. 順は `ndf-gate:1` → `ndf-ask:redline:*` → ほかの `ndf-ask:*` → `ndf-gate:2` とする。承認ゲート 2 を最後にするのは、答えの時点で `develop` の先頭を比べる(決定 14)ためである +4. 1 回の呼び出しの問いは 4 つまでのため、依頼 1 件を問い 1 つにし、5 件以上の区分は届いた順に 4 件ずつ続けて呼ぶ。各問いにはレーン名・課題・設計 PR の URL か作業の場所を添える +5. 依頼ごとに `parallel.py answer` を打ち、`wait` を打ち直す。答えの間に新しい依頼が届いていれば `questions` が続けて返り、続けて問う。設計 PR と承認ゲートの外の問いは互いに独立で、コミットとの結び付きが無いため、出し直しではなく続けて問うだけである + +## 答えの後の処理 + +オーガナイザーは `parallel.py answer` で答えのイベント `answer-<依頼>` を立てるだけにする。処理は答えを受けた持ち主のレーンが、周回の境で `parallel_lane.answers_of()`(待たずに読む)で読んで行う。1 スプリントのレーンが終わっていれば、オーガナイザーは `relay.py restart <レーン>` で起こし、レーンが起きた後に読む。 + +### 設計の承認(3 つの答え) + +| 答え | ループのレーンが周回の境で行うこと | 1 スプリントのレーンが起きた後に行うこと | +| --- | --- | --- | +| 承認 | `gh pr edit <設計 PR> --add-label design-approved` と `sprint-state.py loop answer <ループ> <課題> --approved --pr <設計 PR>`。設計 PR のマージは次の周回のプランが行う([issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md)) | ラベルと `sprint-state.py gate <状態> "関門 1" --by user --pr <設計 PR> --outcome approved` を書き、今の承認ゲート 1 の後の手順で進む。マージはプランの `design-approved` のステップが流す | +| 直して出し直す(指摘つき) | `sprint-state.py loop answer <ループ> <課題> --revise --note <指摘のファイル>`。課題の本文の「人の判断待ち」の節に指摘を書く | 承認の記録に `--outcome revise` と指摘を書き、設計のステージを指摘つきで流し直す | +| やらない | `sprint-state.py loop answer <ループ> <課題> --rejected`(今の差し戻し) | `--outcome rejected` を書き、今の差し戻しの手順で進む | + +### 配布の差し戻し(宛先のレーンつき) + +承認ゲート 2 の差し戻しでは、承認資料のレーンごとの Pull Request の内訳から、指摘の宛先のレーンを選べるようにする(`AskUserQuestion` の 2 問目で、宛先のレーンを複数選ぶ)。 + +| 受け手 | 答え | +| --- | --- | +| 宛先のレーン | `answer-<依頼>` に `rejected` と指摘。依頼していない宛先のレーンにも、イベント `fix-<レーン名>-<連番>` で指摘を届ける。レーンは周回の境で直しの周回を組み、直す Pull Request か revert の Pull Request を `develop` へ入れ、配布の依頼を送り直す | +| ほかのレーン | `answer-<依頼>` は立てず、知らせのイベント `defer-<依頼>` で次の配布へ回したことを返す(I10)。依頼は `pending` に残り、後の配布で `answer-<依頼>` に最終の答えが 1 回だけ立つ | + +耐久ワークフローは、宛先のレーンすべてから差し戻しの後の配布の依頼が届くまで、次の配布を組まない(同じ中身をもう一度組まないため)。宛先のレーンが終わっていれば、オーガナイザーは `relay.py restart <レーン>` で起こすだけにする。 + +## 構成要素と触るファイル + +| ファイル | 変えること | +| --- | --- | +| `plugins/ndf/scripts/hook_lib/ask_guard.py` | 通す問いのとき、レーンか一時レーンなら `parallel.py request-ask` で送って止める | +| `plugins/ndf/scripts/lib/parallel_lane.py` | `request_gate1()`・`request_ask()`・`answers_of()`(自分の依頼の答えを待たずに読む)・送り手の資格を確かめる `sender_of()`(`lane_of()` のレーンか、役割のファイルが `helper` で計画が動いている一時レーン)・レーンか一時レーンかをファイルだけで見る `lane_name_in_plans()`(計画のレーン名か、作業ディレクトリの役割のファイルが `helper`) | +| `plugins/ndf/scripts/lib/parallel_run.py` | 依頼の受け取り・イベント `questions`・止まりの間の `inbox` の読み取り・副命令 `answer`・`request-gate1`・`request-ask`・`settle --rejected --to` | +| `plugins/ndf/scripts/lib/durable.py` | 止まりを自分で書くための `recv(topic, timeout)` と `set_event(key, value)` の包み | +| `plugins/ndf/scripts/lib/sprint_loop_gate.py` | `move_to_waiting` がレーンのときに依頼を送る | +| `plugins/ndf/scripts/lib/sprint_loop.py` | `loop answer` に `--revise --note`。周回の境で自分の依頼の答えを読み、答えの後の処理を行う([issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md)) | +| `plugins/ndf/skills/development-workflow/references/parallel.md` | オーガナイザーの問い方(区分ごと・4 件ずつ・順・human-action の案内・差し戻しの宛先) | +| `plugins/ndf/skills/development-workflow/references/asking.md` | 並列の計画のレーンでは hook が問いをオーガナイザーへ送ること、送られたときの保留の手順 | + +## 入出力の契約 + +| 名前 | 入力 | 成功 | 失敗の形 | +| --- | --- | --- | --- | +| `parallel.py request-gate1 <計画名> --lane <名> --issue <番号> --pr <番号> --report <パス> --state <パス> [--loop <名>] [--verdict <判定> --reason <理由>...]` | — | 0。依頼を送り `metrics.id` を返す | 1: 計画が動いていない | +| `parallel.py request-ask <計画名> --lane <名>` | 標準入力に問いの入力(`AskUserQuestion` の `tool_input`)と作業の場所 | 0。依頼を送り `metrics.id` を返す。送り手が一時レーンなら `lane` は一時レーンの名前(`<計画名>-rel<連番>`) | 4: レーンでも一時レーンでもない(hook は問いを通す)。1: 計画が動いていない | +| `parallel.py answer <計画名> <依頼> (--approved / --revise --note <パス> / --rejected / --choice <選んだ答え>...)` | 利用者の答え | 0。答えのイベントを立てる。持ち主のレーンが終わっていれば `metrics.next` に `relay.py restart <レーン>` の 1 行 | 1: 依頼が無い・答え済み | +| `parallel.py settle <計画名> <連番> --rejected [--to <レーン>... --note <パス>]`(変更) | 差し戻しの宛先 | 0。宛先とほかのレーンへ上の表の答えを返す | 今のまま | +| `parallel.py wait`(変更) | — | イベント `questions` の `seq` が進んだときも 0 で返す(`metrics.action: questions`、`items` に依頼) | 今のまま | + +## テスト設計 + +| 受け入れ条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 6c まとめて 1 回で問う | 依頼が 3 件届いた後の `wait` が `questions` を 1 回返し、`items` に 3 件が並ぶ。同じ区分が 5 件なら 4 件と 1 件の 2 回の呼び出しの並びを、区分が違えば区分ごとの呼び出しを返す | 1 件ごとに返す・区分の違う問いを 1 つの呼び出しに詰めると落ちる | +| 6c 答えの時点の新しい依頼を続けて問う | 3 件に答える間に 1 件届くと、答えの後の `wait` が新しい 1 件だけを返す | 答えた依頼をもう一度返す・新しい依頼を落とすと落ちる | +| 6c レーンは待たずに残りへ進む | ループのレーンで承認ゲート 1 が判定できないと、依頼を送り、残りの課題の続きの `pipeline` を返す | 依頼を送らない・答えを待つと落ちる | +| 6d hook の送り先(レーン) | 動いている計画のレーンで `ndf-ask:cost` の問いを出すと、`request-ask` が送り、hook が `deny` と保留の案内を返す | 送らずに通す・送ったのに通すと落ちる | +| 6d hook の送り先(一時レーン) | 役割のファイルが `helper` で計画が動いている一時レーンで `ndf-ask:human-action` の問いを出すと、`request-ask` が 4 を返さずに送り、hook が `deny` を返す | 送り手の資格を `lane_of()` だけで見ると落ちる | +| 依頼の `id` | 同じ設計 PR・同じ先頭で `request-gate1` を受け取りの前に 2 回打つと同じ `id` で 1 件だけ数えられ、受け取りの後に打ち直しても同じ `id` になる。同じレーンで別の設計 PR を続けて送ると別の `id` になる | 受け取った数から採番すると落ちる | +| 6d hook の送り先(レーンでない) | `NDF_RELAY_NAME` が無い・計画に無い名前・計画が終わっているときは今どおり通す。`request-ask` が落ちても通す | レーンでないのに止める・送れないのに止めると落ちる | +| 6d 質問待ちの再開 | ループのレーンの周回が「質問待ち」で締まり、答えの届いた境で止まったプランが続きのステップから流れ、残りとまとめの後に締め直る | 答えの前に流す・最初から流し直すと落ちる | +| 6d human-action | オーガナイザーへの `items` に `relay.py attach <レーン>` の 1 行が付く | 案内が無いと落ちる | +| 6e・6f 設計の 3 つの答え | `answer` は答えのイベントだけを立て、ラベルも `loop answer` も打たない。持ち主のループのレーンが周回の境で、承認ならラベルと `loop answer --approved --pr`、直して出し直すなら `--revise --note`、やらないなら `--rejected` を打つ | オーガナイザーがラベルか記録を書く・レーンが答えを読まない・指摘が記録に無いと落ちる | +| 6f 終わったレーンを起こす | 持ち主の 1 スプリントのレーンが止まっていると、`answer` が `relay.py restart <レーン>` の 1 行を返す。動いていれば返さない | 止まったレーンを起こさない・動いているレーンを起こすと落ちる | +| 6e 配布の差し戻しの宛先 | `--to lroot` の差し戻しで lroot の依頼に `rejected` と指摘、ほかの依頼に `defer-<依頼>` が立って `answer-<依頼>` は立たず、lroot の新しい依頼が届くまで次の配布を組まない | 全レーンに `rejected` を返す・宛先の直しの前に同じ中身で組むと落ちる | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| hook の時間 | `request-ask` の子プロセスが、`deps.require` の起動し直しを含めて 8 秒以内に終わるかは実装で測る。越えたときは問いを通す | +| レーンの起こし方 | `relay.py restart <名前>` が、終えたセッションの引継ぎ文書の続きから起こすかは #2346 の作りを実装で確かめる。起こせなければ `relay.py spawn` で最初の入力(引継ぎ文書の続き)を渡す | +| 承認資料の置き場 | 設計のプランの `report.md` はレーンの worktree の状態ディレクトリにある。オーガナイザーがメインディレクトリから読めるパスで送る | diff --git a/issues/issue-2436-design-decisions.md b/issues/issue-2436-design-decisions.md new file mode 100644 index 000000000..a7414c5e5 --- /dev/null +++ b/issues/issue-2436-design-decisions.md @@ -0,0 +1,247 @@ +# 並列開発の設計の決定(#2436) + +設計文書 [issue-2436-design.md](issue-2436-design.md) の決定の記録を分けたファイル。 + +各決定の「根拠」の Mission・Vision・`Value <番号>` は、プロジェクト MVV([.ndf/mvv.md](../.ndf/mvv.md)。版の履歴は `.ndf/mvv.json`)の版 4 の項目を指す。この文書が挙げる Value は次のとおりである。 + +| Value | 判断の基準 | +| --- | --- | +| 2 | 利用者の手を止めない(人の承認は設計と本番への配布の 2 回に寄せる) | +| 3 | 測って決める | +| 4 | 決まった手順はスクリプトに、判断と中身は AI に | +| 5 | どのプロジェクトでも使える形を保つ | +| 6 | 根本原因の場所で直す(同じ役割の定数・関数を分けない) | +| 7 | 判断の理由を残す | + +## 決定の記録 + +### 決定 1: 計画と起動の受け皿を既存の手順へ寄せるため、新しい Skill を作らず `issue-plan-strategy` と `development-workflow` の `--parallel` で受ける + +並列の計画を作る手順は、分割と並行の手順である。`parallel-work.md` の境界では `issue-plan-strategy` の担当に当たる。起動は `development-workflow` の `--loop` と同じ形の引数で受け、判定の手順へ進まずに専用の参照へ進む。利用者が覚えるエントリポイントは今の 2 つのまま増えない。 + +新しい Skill にする形は採らない。計画と起動の手順が既存の 2 つの Skill の担当と重なり、同じ役割の手順が 2 か所に分かれるためである。 + +根拠: Value 6 / Value 2(MVV 版 4) + +### 決定 2: 11-02 の配布の衝突を繰り返さないため、オーガナイザーを置き、起動の後も残して本番の配布を 1 本にまとめる + +レーンごとに本番を出すと、先に出したレーンが `develop` にある他のレーンの変更も出す。調整する役が無ければ衝突は再発する。オーガナイザーは依頼を受けて、1 回の承認ゲート 2 と 1 本の本番の配布にまとめる。承認する人は 1 度に全レーンの内訳を見て決められる。 + +計画の手順が `ndf-spawn` のブロックを並べて出すだけの形は、起動は楽になるが配布の衝突を防げないため採らない。 + +根拠: Mission / Vision(MVV 版 4) + +### 決定 3: 機械が検査でき、ループの記録と同じ扱いにするため、並列の計画の正本を `plan.json`(pydantic のモデル)にし、`plan.md` を描画にする + +検査(I1〜I4)と起動はスクリプトが行い、表の Markdown を解析するより JSON を読む方が誤りが少ない。形は pydantic のモデル `ParallelPlan` が持ち、I1〜I3 は検証の誤りの種類で返す。循環は標準の `graphlib.TopologicalSorter` の `CycleError` で見つける。どちらも依存にあるもので、検査の手書きの分岐を持たない。人が読む形は `plan.md` として描画し、書き換えるたびに作り直す。`.ndf/loop/<名>/loop.json` と `laps.md` の組と同じ形で、利用者は同じ読み方で済む。レーンに共通の決まりは `plan.json` の `rules` の 1 か所に置き、引継ぎ文書へ写さない。 + +Markdown の表を正本にする形は、検査のたびに表の解析が要り、表の形の崩れが検査の誤りとして現れるため採らない。JSON Schema(`jsonschema`)で形を書く形は、レーン名の重なりと依存先の照合を schema の外に書くことになり、検査が 2 か所に分かれるため採らない。 + +根拠: Value 4 / Value 7(MVV 版 4) + +### 決定 4: 統括役の作業ディレクトリの読み方に揃えるため、オーガナイザーの印を作業ディレクトリの役割のファイルで持つ + +統括役は作業ディレクトリの今のファイル(`relay.lock`・`question`・`name`)だけを読んで数える(#2346 の I5)。役割も同じ置き場に `relay.py role` が書き、`live()` が読む。オーガナイザーは名前の無い前景のラッパー(統括役を兼ねるもの)で動くことが多い。このラッパーは登録簿(名前つきのラッパーの一覧)に載らない。それでも同時に動かせる数には入る。統括役が数えるのは登録簿の行ではなく、`relay.lock` を取っている作業ディレクトリのすべて(名前の無いラッパーを含む)だからである(`relay_lib/hub.py` の `live()`)。外さなければ、既定の上限 3 のうち 1 本をオーガナイザーが使い、レーンは 2 本しか起動できない。外すのは印を持つ動いているラッパーのうち 1 本だけにし、印を置けば上限を越えられる経路を 1 本に限る(I13)。 + +環境変数で持つ形は、別のプロセス(統括役)から読めないため採らない。登録簿の役割で持つ形は、名前の無い前景のラッパーを表せないため採らない。 + +根拠: Value 6(MVV 版 4) + +### 決定 5: レーンの最初の入力を変えずに済ませるため、レーンかどうかをラッパーの名前と動いている計画の照合で決める + +統括役が起動するラッパーは `NDF_RELAY_NAME` にレーン名を持つ。その環境は、レーンの conductor とプランのステップへ受け継がれる。耐久ワークフロー `parallel-<計画名>` が終わっていない(`PENDING`)計画のレーンにその名前があればレーンとし、無ければ今の振る舞いのまま(I12)。状態は `durable.status_of` で外から読み、DBOS を起動しない。区間を切り替えても名前は変わらないため、再開の経路に引数を足さずに済む。判定はプランを組む時点に 1 度だけ行い、組んだプランに計画名を書く。 + +`--loop` や `#<課題>` に並列の計画を示す引数を足す形は採らない。ループの記録・再開のコマンド・スプリントの状態の 3 か所へ、引数を引き継ぐ変更が要るためである。 + +根拠: Value 6 / Value 5(MVV 版 4) + +### 決定 6: レーンの工程を変えずに配布だけを差し替えるため、本番のステージを組む 1 つの関数でだけ配布の依頼のプランへ置き換える + +本番のステージは `supervise_lib/sprint.py` の 4 か所(`fast`・`auto`・最終の検査の後・通常)で組まれる。4 か所が同じ関数を通り、その関数がレーンのときだけ配布の依頼のプランを返す。依頼のプランは run のステップ `parallel-request` の 1 つだけを持つ。自分の Pull Request がすでに `main` にあれば依頼を送らずに `released` で進め(I15)、送ったら「配布の依頼」の承認ゲートとして終わる。ループはこれを周回の結果「配布待ち」で締めて次の周回へ進む([issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md))。 + +ループの側(`sprint_loop`)で本番のステージを外す形は、ループでないスプリントのレーンに効かないため採らない。 + +根拠: Value 6(MVV 版 4) + +### 決定 7: 承認ゲートを増やさないため、まとめた配布の承認は今の本番のプランの承認ゲート 2 をそのまま使う + +まとめた配布の本番のプランは、今のスプリントと同じ `plan_mvv_release` で作る。出す版の Pull Request には、提示のプランが範囲から求めた Pull Request を渡す(決定 13)。`pace: auto` なら MVV 判定が、それ以外なら利用者が、今と同じ承認ゲート 2 で範囲を 1 回承認する。出し直しは同じ承認ゲート 2 の提示のやり直しで、新しい承認ゲートではない(決定 14)。版は耐久ステップが配布を組む時点の `develop` の開発版の版数から決め、依頼はレーンの希望の版を持たない。 + +依頼ごとに承認ゲート 2 を求める形は、範囲に入った他のレーンの変更も出るため、依頼の数と承認の対象が一致せず採らない。 + +根拠: Value 2(MVV 版 4) + +### 決定 8: 統括役を並列の計画から独立させるため、計画名での停止を `relay.py` ではなく `parallel.py stop` に置く + +統括役はリポジトリをまたいで動くラッパーの層で、並列の計画はリポジトリのメインディレクトリにある。`parallel.py stop <計画名>` が計画のレーン名を読み、レーンごとに今の `relay.py stop <名前>` を打つ。統括役が知る語は役割の印だけに留まる。要求の「影響」に挙げた `relay.py` の計画名の指定は、この形で受け入れ条件 8 を満たす。 + +`relay.py stop --plan <計画名>` を足す形は採らない。統括役がリポジトリの置き場を探す処理を持つことになり、2 つのコンテキストの境界が崩れるためである。 + +根拠: Value 6(MVV 版 4) + +### 決定 9: 計画の大きさを起動の前に確かめられる形にするため、レーンの数の上限を `NDF_RELAY_MAX_NAMED` から読み、依存のあるレーンも数える + +レーンの数の上限は統括役が実際に断る値と同じ `NDF_RELAY_MAX_NAMED` から読み、別の設定を持たない。依存のあるレーンは同時には動かないが、検査は計画の全レーンを数える。同時に動く本数を依存から求める形は、どのレーンがいつ止まるかに依って値が変わり、起動の前に確かめられないため採らない。上限を越える計画は、利用者が `NDF_RELAY_MAX_NAMED` を上げるか計画を分ける。 + +根拠: Value 3 / Value 5(MVV 版 4) + +### 決定 10: オーガナイザーの手順を記録から決まる 1 手に絞るため、進行を耐久ワークフロー 1 本にし、AI は承認ゲート 2 の提示だけを担う + +起動・観測・配布を組む・答えを返す・終わりの判定は、耐久ワークフロー `parallel-<計画名>` が耐久ステップとメッセージで行う。オーガナイザーの conductor は `run` と `wait` を背景で打ち、返った 1 手(知らせる・配布を流す・承認を問う・終える)を打つだけにする。プロセスが落ちても打ち直した `run` が記録から続け、記録のある耐久ステップ(レーンの起動)は流し直さず、答えのイベントは同じ値を返す(I7・I10)。止まっている間に届いた依頼は SQLite のメッセージとして残る。オーガナイザーのための引継ぎ文書の名を新しく作らず、再開のコマンドは起動と同じ 1 行にする。 + +状態ファイル(`state.json`)・依頼と答えのファイル(`requests/`・`results/`)・停止の印・冪等の 1 手(`tick`)・ファイルを問い合わせる待ちを自分で書く形は採らない。打ち直しで続く性質・依頼の取りこぼしの防止・待ちを、依存にある DBOS がすでに持っているためである(決定 12)。オーガナイザーの conductor が状態を判断して起動と配布を決める形は、切り替えのたびに判断の材料を読み直す費用がかかり、判断が揺れるため採らない。 + +根拠: Value 4 / Value 6(MVV 版 4) + +### 決定 11: ラッパーの外では配布をまとめずに今の振る舞いへ戻すため、手で起動した計画では耐久ワークフローを始めない + +ラッパーの外・tmux が無いときは、オーガナイザーが動き続けられず、依頼に答える役が居ない。`run` はレーンごとの手で起動する 1 行を示して、耐久ワークフローを始めずに `manual` で終える。耐久ワークフローの無い計画はレーンの判定(I12)に当たらないため、手で起動したレーンは今どおり自分で本番を出す。 + +手で起動したときも依頼を送らせる形は、答える役が居ないためレーンが待ち続けるので採らない。 + +根拠: Value 2(MVV 版 4) + +### 決定 12: 自分で書く部品を減らすため、並列の進行を DBOS の耐久ワークフローとメッセージで作り、検査を pydantic と graphlib で行う + +並列の進行に要るのは、状態を残すこと・別のプロセス(レーン)から依頼を受けて答えを返すこと・待つこと・落ちた後の打ち直しで続けることである。DBOS Transact 3.1.0 は依存(グループ `durable`)にあり、#1142 の包み `lib/durable.py` が起動・実行の鍵・止まりと続き(`pause`・`resume`・`wait`・`exit_leaving_pending`)を持つ。2026-11-02 の試行で、1 つのプロセスの耐久ワークフローが `recv` で待つメッセージを、別のプロセス 2 本が `DBOSClient` で SQLite へ送り、`get_event` で答えを読めた(サーバー無し)。包みには外のプロセスから送る・読む関数が無いため、`durable.py` に `DBOSClient` の関数を足す。DBOS を import するのは今どおり `durable.py` だけである(#1142 の I16)。 + +#1142 の I17(1 つの SQLite を開くプロセスは同時に 1 つ)は、DBOS を起動して耐久ワークフローを回復・実行するプロセスに限る。`DBOSClient` は耐久ワークフローを流さず、送る・読むだけで、回復が混ざる理由(決定 26 の耐久キューの取り出し)に当たらない。複数のプロセスが書くときのロックの待ちは未確認として残す。 + +| 候補 | 何に向くか | 採否と理由 | +| --- | --- | --- | +| DBOS Transact 3.1.0(`durable.py`) | 長く動く耐久ワークフロー・プロセスをまたぐメッセージとイベント・SQLite でサーバー無し | **採る。** 依存にあり、包みと試行がある | +| pydantic 2.13.5(グループ `schema`) | JSON の形の検証と誤りの種類 | **採る。** 依存にあり、`project-decl.py` などに前例がある | +| `graphlib.TopologicalSorter`(標準ライブラリ) | 依存の順序と循環の検出 | **採る。** 依存を足さない | +| Optuna | 短く独立した試行を共有のストレージと study 名で複数の worker が取り合う | 採らない。共有のストレージと名前で合流する形(耐久の記録と計画名)は借りる。試行の間の依存・配布のまとめ・対話のセッションを扱わない | +| Hydra の multirun と joblib / submitit の launcher、Ray Tune | 設定の組み合わせごとのジョブを並べて流す | 採らない。Optuna と同じ理由 | +| scipy・pyDOE | 実験の条件の組み合わせを作る | 採らない。条件を作るもので、実行を制御しない | +| doit・Snakemake・Luigi・pipefunc | バッチの依存グラフ(DAG)を流す | 採らない。レーンの依存は「先のレーンが止まってから起動」だけで、ステージの直列は `supervise.py queue` が持つ。長く動いて人の承認を待つ流れに向かない | +| Prefect・Airflow | サーバーを持つワークフローの基盤 | 採らない。上の理由に加え、サーバーが要る | +| tmuxp・libtmux | tmux のセッションの起動 | 採らない。起動には合うが、`relay.py` が専用のソケットで tmux を包み済みで、範囲は #2346 | +| GitHub の merge queue | ベースブランチへのマージの直列化 | 採らない。`develop` へのマージを直列にするもので、`develop` → `main` の本番の配布をまとめない | + +根拠: Value 4 / Value 6(MVV 版 4) + +### 決定 13: レーンの変更はすでに `develop` にあるため、配布の中身を依頼の集合ではなく承認資料を作る時点の `develop` のコミットで決める + +今の本番の配布は `develop` の先頭を `main` へ出す(決定 15 で改める)。依頼を送った時点で、そのレーンの変更も他のレーンの変更も `develop` に入っている。そのため、依頼の集合で中身を決めても、実際に出る中身と一致しない(Pull Request 2420 の件)。中身は提示のプランが承認資料を作る時点の `origin/develop` のコミット(`main..`)で決め、範囲に入るレーンを依頼済みかどうかの印つきで承認資料に載せる。範囲に入ったレーンには依頼の有無によらず結果を返し、後から依頼のステップに来たレーンは出し直さずに進む(I15)。依頼は「配布を始めてほしい」という知らせで、届いた時点で配布が流れていなければ他のレーンを待たずに直ちに組む(I9)。配布は 1 本ずつ直列で、流れている間に `develop` へ入った変更と届いた依頼は、今の配布の直後に組む次の配布になる。 + +本番のプランへ渡す Pull Request は、今の approval-facts が範囲から求める「含む PR」(#1274)を `present` が `work/prs.txt` へ書き、差し込み `{queue_prs:<計画名>}` で渡す。本番のプランの changelog・notes・後片付けのステップは `--prs` を受ける作りで、範囲から機械で求めた番号を渡せば今のステップのまま使える。SHA と範囲のレーンの一覧を本番のプランへ直接渡す形は、これらのステップの引数をすべて変えることになるため採らない。 + +`develop` → `main` をやめ、レーンのブランチを個別に `main` へ出す形は採らない。スプリントのブランチは `base-sync` で他のレーンの変更を含み、切り分けられない。`main` 宛は `develop` からだけという方針(`scripts/check-pr-base.sh`)と 2 つのチャネルの前提を崩し、`main` と `develop` が分かれる。 + +承認したコミットをそのまま `main` へ出す道は決定 15 にある。 + +根拠: Mission / Value 2(MVV 版 4) + +### 決定 14: 承認の答えの時点で `develop` が進んでいれば 1 回だけ出し直し、出し直しで承認したコミットを出す + +人の答え待ちの実測は 10 分〜40 時間(`.ndf/loop/*/loop.json` の `waiting`)で、本番のプランの所要は 855〜1,406 秒(`.ndf/loop/l20261031/lap-2`・`lap-3` の done)である。答え待ちが本番のプランより 1 桁以上長いため、待つ間に他のレーンのマージが `develop` へ入る見込みは高い。出し直さない形では、そのレーンは今の配布が終わった後の次の配布で、2 回目の人の答え待ちを負う。 + +承認の答えを受けた時点で `origin/develop` の先頭を 1 度だけ比べる。同じならそのコミットで確定する。進んでいれば、今の承認資料の生成を新しい先頭で呼び直し、加わった範囲(レーンと Pull Request)を添えて同じ承認ゲート 2 を 1 回だけ出し直す。選択肢は「新しい範囲で出す」「最初の範囲だけ出す」「差し戻す」である。出し直しへの答えでは比べず、出し直しで承認したコミットを出す。その後に `develop` へ入った変更は次の配布になり、出し直しは連鎖しない。確定したコミットを出せるのは、本番の Pull Request の規則を改めるため(決定 15)である。新しい部品は作らず、比べるのは 1 つの関数、資料は今の生成の呼び直し、出し直しはオーガナイザーの手順の 1 段である。`pace: auto`・`fast` で MVV 判定が通すときは答え待ちが無く、比べない。 + +| 比べた形 | 採否と理由 | +| --- | --- | +| 出し直さない | 採らない。答え待ちの間にマージしたレーンが、次の配布で 2 回目の答え待ちを負う | +| 回数の上限なしで出し直す | 採らない。答え待ちの間に毎回 `develop` が進むと、出し直しが続いて配布が始まらない | +| 承認のときに次の範囲を続けて承認させる | 採らない。まだ資料の無い範囲を承認することになり、承認したものと出すものの一致(I14)が崩れる | +| 1 回だけ出し直し、出し直しで承認したコミットを出す | **採る。** 答え待ちを 1 回で済ませる見込みが高く、連鎖しない | + +根拠: Value 2 / Value 4(MVV 版 4) + +### 決定 15: 本番チャネルへの Pull Request は `develop` に含まれるコミットだけにし、head を `release/v<版>` にする + +承認したコミットを出すには、`main` へ出す中身を `develop` の先頭から切り離す必要がある。`release/v<版>` を承認したコミットから切って版上げのコミットだけを積み、今どおり先に `develop` へ取り込んでから、その先頭を `main` へ出す。`main` へ出るコミットはすべて `develop` に含まれるため、2 つのチャネルの前提(`main` は `develop` を通ったものだけを持つ)は保たれる。`scripts/check-pr-base.sh` は「head が `develop`」から「head が `develop` か、先頭が `origin/develop` の祖先の `release/v*`」に改める。#815 の照合は `release/v<版>` の先頭と承認したコミットを比べ、許すのは版上げのコミットだけにする。承認の後に他のレーンが `develop` の版数を上げていれば、取り込むときの衝突を配布のステップが本番の版で解く。詳細は [issue-2436-design-release-rule.md](issue-2436-design-release-rule.md) にある。 + +並列の計画の外の本番の配布もこの規則で動き、承認の後に `develop` が進んでも止まらずに承認したコミットを出す。 + +| 比べた形 | 採否と理由 | +| --- | --- | +| 承認の後に `develop` へのマージを保留する | 採らない。保留の間、レーンの開発版の配布と確認が止まる | +| `develop` の先頭を何度も出し直す(#815 の止まりのたびに承認し直す) | 採らない。答え待ちと本番のプランの間に `develop` が進むたびに承認し直しが連鎖する | +| レーンごとのブランチを `main` へ出す | 採らない。スプリントのブランチは `base-sync` で他のレーンの変更を含み、切り分けられない。`main` と `develop` が分かれる | +| `release/v<版>` を承認したコミットから切り、`develop` に含めてから `main` へ出す | **採る。** `develop` を止めず、承認したものと出すものが一致し、`main` は `develop` に含まれるコミットだけを持つ | + +根拠: Mission / Value 2 / Value 6(MVV 版 4) + +### 決定 16: 並列の計画の下では承認ゲートの問いをオーガナイザーへ集める + +並列の計画の下では、承認ゲート 1・2 の問い(`ndf-gate:1` / `ndf-gate:2`)はオーガナイザーだけが出す。夜の間にすべてのレーンが承認待ちに入ることがあり、問いがレーンごとに散ると、利用者はレーンを回って答え、答えるまでレーンが止まる。レーンは設計の承認の依頼を送って待たずに残りの課題か次の周回へ進み、オーガナイザーが届いている依頼を 1 回の問いにまとめる。ループのレーンは今の「人の判断待ちへ移し、残りの課題で続ける」経路をそのまま使い、違いは依頼をオーガナイザーへ送ることだけである。答えの後の処理は持ち主のレーンが行う(決定 19)。設計 PR は互いに独立でコミットとの結び付きが無いため、答えの間に届いた依頼は出し直しではなく続けて問う。詳細は [issue-2436-design-approvals.md](issue-2436-design-approvals.md) にある。 + +| 比べた形 | 採否と理由 | +| --- | --- | +| レーンが問いを出し、オーガナイザーは知らせるだけ | 採らない。レーンが答えまで止まり、問いがレーンの数だけ散る | +| 区分の違う問いを 1 つの `AskUserQuestion` の呼び出しにまとめる | 採らない。呼び出しの `metadata.source` は 1 つで、区分を区別できない。同じ回の中で区分ごとに呼ぶ | + +根拠: Mission / Value 2(MVV 版 4) + +### 決定 17: オーガナイザーは問いと本番の配布の取りまとめだけを持ち、直す作業は持ち主のレーンへ戻す + +オーガナイザーが直す作業(設計の直し・実装の直し・revert)を持つと、1 本のセッションに全レーンの作業が積まれ、問いと配布の取りまとめが止まる。設計の答えは「承認」「直して出し直す(指摘つき)」「やらない」の 3 つにし、「直して出し直す」は `loop answer --revise --note` で、その課題を次の周回で最優先に選び、同じ設計 PR に対して設計のステージを指摘つきで流し直す。配布の差し戻しは、承認資料のレーンごとの内訳から指摘の宛先のレーンを選び、宛先のレーンが直す Pull Request か revert の Pull Request を `develop` へ入れる。ほかのレーンには「次の配布へ」を返し、宛先のレーンの直しの後の依頼で次の配布を組む。 + +| 比べた形 | 採否と理由 | +| --- | --- | +| オーガナイザーが直す | 採らない。直しの間、問いと配布の取りまとめが止まる | +| 差し戻しを全レーンへ返す | 採らない。直す必要の無いレーンまで周回を締め直す | +| 指摘の宛先のレーンへ戻す | **採る。** 直しは変更を持つレーンが最も早く行え、オーガナイザーが詰まらない | + +根拠: Value 4 / Value 6(MVV 版 4) + +### 決定 18: 承認ゲートの外の問いも、問いの hook でオーガナイザーへ送る + +承認ゲートの外の区分(`ndf-ask:redline:*`・`cost`・`irreversible`・`human-action`・`legal`・プロジェクトの区分)の問いも、レーンで出れば答えまでレーンが止まる。問いの hook(PreToolUse の `AskUserQuestion` の guard、`hook_lib/ask_guard.py`)は、判定が通す問いで、問いを出したのが並列の計画のレーンか一時レーンなら、問いの中身をオーガナイザーへ送って止め、保留して別の作業へ進むよう返す。レーンでなければ、送れなければ、今どおり通す。作業の途中の問いは課題ごとに分けられないため、ループのレーンは周回ごと「質問待ち」で保留し、答えの届いた周回の境で止まったプランを続きから流す。`human-action` はオーガナイザーが代われないため、`relay.py attach <レーン>` の案内を添えて問う。 + +| 比べた形 | 採否と理由 | +| --- | --- | +| 承認ゲートの外の問いはレーンに残す | 採らない。夜の間にレーンが問いで止まる | +| conductor の手順だけでオーガナイザーへ送る(hook を使わない) | 採らない。手順に書いていない場所の問いが漏れる。区分を判定している hook が送れば漏れない | +| hook が送り、送れなければ通す | **採る。** 漏れが無く、DBOS が読めないときも今の振る舞いに戻る | + +根拠: Mission / Value 2(MVV 版 4) + +### 決定 19: オーガナイザーは作業を持たず、答えの後の処理は持ち主のレーン、共有の作業は一時レーンが行う + +答えの後の処理(承認ラベル・`loop answer`・設計 PR のマージのプラン・直し)をオーガナイザーが行うと、問いの取りまとめが処理の間止まる。オーガナイザーの責務を「問いを集める・示す・答えを送る」「一時レーンの起動」「持ち主のレーンを起こす `relay.py restart` の 1 行」に絞る。答えの後の処理は、答えを受けた持ち主のレーンが周回の境で行う。持ち主のいない共有の作業(まとめた配布のプランの実行・その失敗の対応・`release/v<版>-sync` の版数の衝突の解消)は、オーガナイザーが起こす一時レーン(`<計画名>-rel<連番>`、役割 `helper`)が行い、結果を耐久ワークフローへ返して自分で止まる。一時レーンは同時に 1 本までで、統括役は役割 `helper` の 1 本だけ `NDF_RELAY_MAX_NAMED` を 1 本超えて許す。 + +| 比べた形 | 採否と理由 | +| --- | --- | +| オーガナイザーが行う | 採らない。処理と配布の失敗の対応の間、問いの取りまとめが止まる | +| 空いているレーンへ移す | 採らない。空きの判定と、他のレーンの周回への割り込みが要る。答えの後の処理は持ち主へ返す形でほぼ受けられる | +| 持ち主のレーンと、共有の作業だけの一時レーン | **採る。** オーガナイザーが作業を持たず、一時レーンは配布の間だけ動いて止まる | + +根拠: Value 4 / Value 6(MVV 版 4) + +### 決定 20: 異常系は、検知を耐久ワークフローの観測と問いの依頼の印に寄せ、扱いを問いか持ち主のレーンへ返す形に揃える + +異常系([issue-2436-design-failures.md](issue-2436-design-failures.md) の 19 件)は、新しい仕組みを足さずに、この設計の 3 つの部品へ寄せる。検知は、耐久ワークフローの観測の耐久ステップ(`relay.py ls` の状態と最後に動いた時刻、本番のプランの状態ディレクトリ)と、依頼に付けた問いの時点の印で行う。扱いは、人が決めることなら問い(オーガナイザーがまとめて出す)にし、直す作業なら持ち主のレーンか一時レーンへ返す。オーガナイザーは作業を持たないまま(決定 19)である。 + +主な選択と理由: + +| 選択 | 理由 | +| --- | --- | +| `run` はセッションから切り離して起動し、2 本目は今のプロセスを指して 0 で終わる | Bash の背景の子はセッションの切り替えで消える恐れがある。実行の鍵の排他(#1142 の I17)で二重にならない | +| 待ちだけのループは `no_issue` でなく `waiting_only` で止まり、答えで起こされる | `no_issue` で止まると見直しを挟み、答えが届いても再開されない。止まれば統括役の枠も空く | +| 差し戻した変更が残る間は配布を自動で組まない | 同じ中身をもう一度組むと、差し戻した変更が承認の外の扱いのまま出る | +| 同じ原因の問いは文面を正規化して 1 つにする | 認証切れ・ディスク不足はすべてのレーンで同時に起き、同じ問いが並ぶ | +| 答えに問いの時点の印を付け、違えば問い直す | 問いと答えの間に設計 PR やコミットが進むと、承認したものと使うものが食い違う | +| `main` へのマージの後に止まった配布は「続きから流す」だけを出す | `main` から取り消せないため、差し戻しは選べない | +| GitHub API の枠・ホットフィックスは扱わない | 枠は実測が無く、ホットフィックスは並列の計画の外の配布の経路である。リリース後テストと別の課題で扱う | + +根拠: Value 4 / Value 6(MVV 版 4) + + +### 決定 21: 範囲を 1 台の中に閉じ、レーンとオーガナイザーの間の依頼と答えの形を NDF の契約として公開する + +Neural Development Factory では、同じリポジトリで並行する実行が、それぞれ別の実行ジョブ(コンテナ 1 つ。中で動くのはスプリント 1 本の conductor で、レーンは並ばない)で動く。実行ジョブ 1 本がレーン 1 本に当たるため、Factory に要るのは上位のまとめ役ではない。同じオーガナイザーの役割を Factory の側で担う実装で、1 台の中のオーガナイザーとは排他である。Factory の実行ジョブの中では 1 台の中のオーガナイザーは動かない(2026-11-02 の利用者の指摘。層は [issue-2436-design-hierarchy.md](issue-2436-design-hierarchy.md))。この設計のオーガナイザーは 1 台の SQLite(DBOS)に閉じ、実行ジョブの間をまたげない。Factory のオーガナイザーは `factory/` の側に置く。`factory/` は TypeScript と DynamoDB で書き、常駐のプロセスを持たない。そのため、Factory のオーガナイザーは別の課題 https://github.com/devbasex/ai-plugins/issues/2439 と別の設計 PR で作る。この設計は境界だけを持つ。 + +- **範囲**: この設計のオーガナイザーは 1 台の中のレーンだけをまとめる。ランナーが立てる `NDF_FACTORY_RUN=1` があれば、`parallel.py init`・`run` は断る(排他。手元のランナーのマシンには tmux とラッパーもあり得るため、それらの有無には頼らない) +- **公開する形**: レーンが送る依頼(`kind`: `release`・`gate1`・`gate2`・`ask`)と、オーガナイザーが返す答え(配布の結果・範囲のイベント・問いの答え)の形を `plugins/ndf/contracts/forms/parallel-request.json` に置き、構造チェック(リポジトリの根の `scripts/contracts_lib/model.py`)が形を確かめる。Factory のオーガナイザーは、ランナー(実行ジョブの中のプログラム)の翻訳でこの形を受ける(下流の腐敗防止層。`ndf-factory-dev-loops.md` の `ndf-next` のブロックと同じ扱い) +- **共有する規則**: 本番の Pull Request の規則(決定 15、I16)と、配布の中身を `develop` のコミットの範囲で決めること(決定 13・14)は、Factory のオーガナイザーでも同じものを使う + +| 比べた形 | 採否と理由 | +| --- | --- | +| この設計へ組み込む | 採らない。`plugins/` から `factory/` を参照できない(`scripts/check-service-boundary.py`)。実行の基盤(DynamoDB・見回り)も違う。設計は既に 6 本で 1,445 行ある | +| 別の課題にし、この設計は何も持たない | 採らない。依頼と答えの形が `durable.py` の中に埋もれる。Factory の側は形を推して写すことになり、片方だけが変わる | +| 別の課題にし、この設計は形を契約として公開する | **採る。** 1 台の中の部品はそのまま作れる。Factory の側は公開された形だけに依る | + +根拠: Value 4(MVV 版 4)、判断の記録 09a63a07a4a5 diff --git a/issues/issue-2436-design-failures.md b/issues/issue-2436-design-failures.md new file mode 100644 index 000000000..e4991e0e4 --- /dev/null +++ b/issues/issue-2436-design-failures.md @@ -0,0 +1,63 @@ +# 並列開発の設計: 異常系(#2436 の受け入れ条件 6g・6h) + +設計文書 [issue-2436-design.md](issue-2436-design.md) から、異常系の扱いを分けたファイル。問いの集め方は [issue-2436-design-approvals.md](issue-2436-design-approvals.md)、レーンのループの側は [issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md) にある。決定の理由は [issue-2436-design-decisions.md](issue-2436-design-decisions.md) の決定 20 にある。 + +## 一時レーンの失敗(受け入れ条件 6g) + +一時レーンも問いの hook の経路に乗る(役割のファイルが `helper`)。直せない CI の失敗・衝突は、一時レーンが問いとしてオーガナイザーへ送る。一時レーン自体が止まったときは、耐久ワークフローの観測が見つけてオーガナイザーへ問いを立てる。 + +| 止まり方 | 検知 | オーガナイザーの問い | +| --- | --- | --- | +| 一時レーンのラッパーが止まり、`settle` も `gate2` の依頼も届いていない | 観測の耐久ステップが `relay.py ls --json` で一時レーンの `state: stopped` を見る | 「続きから起こし直す」「止める」。起こし直すと、新しい一時レーンが配布のプランを `supervise.py queue ... --resume` で続ける | +| 動いているが答えが来ない | 一時レーンの `at`(最後に動いた時刻)が下の「動かないレーン」の時間より前 | 「起こし直す」「止める」「待つ」 | +| `main` へのマージの後に止まった(タグ・GitHub Release・導入の確認・リリース記録・後片付けの前) | 本番の Pull Request(head が `release/v<版>`、base が `main`)がマージ済みか(`gh pr list --head release/v<版> --base main --state merged --json number,mergedAt` が 1 件)。`release` のステップの終了コードでは決めない(`release` のステップは `main` のマージの後にタグと GitHub Release も作るため、その途中で止まると 0 の記録が残らない) | 差し戻しの選択肢を出さず「続きから流す」だけを出す。済んだステップの一覧を添える | + +`main` へのマージの後は、差し戻しても `main` から取り消せないため、選択肢から外す。マージ済みの判定は耐久ステップ「観測」が GitHub から読み、マージ済みなら `release` のステップの記録によらず「続きから流す」だけを出す。続きの `release` のステップはタグと GitHub Release がすでにあるかを見て、無いものだけを作る。 + +## 異常系の一覧(受け入れ条件 6h) + +| # | 起きること | 検知 | 扱い | テストの観点 | +| --- | --- | --- | --- | --- | +| 1 | オーガナイザーの claude が切り替わる(カットポイント・利用上限・落ちた) | 新しいセッションが `--parallel <計画名>` の同じ 1 行で始まる | `parallel.py run` は conductor の Bash の背景の子にしない。`parallel.py start <計画名>` が `run` をセッションから切り離して起動する(`subprocess.Popen(..., start_new_session=True)`。`relay_lib/login.py` に前例がある。出力は `.ndf/parallel/<計画名>/run.log`)。`run` は実行の鍵の排他(`durable.launch`)を待たずに取り(`lock_timeout=0`)、取れなければ今のプロセス(`run.pid`)を指して 0 で終わる。オーガナイザーの引継ぎ文書は `.ndf/handoff/parallel-<計画名>.md`(`handoff.md` の形。「次に実行するコマンド」は起動と同じ 1 行)。カットポイントでは、問いを出していなければ `ndf-next` を出して切る。問いの途中で切り替わったら、新しいセッションの `wait --after 0` が答えていない問い(イベント `questions`)を返し、出し直す | `run` を 2 回打つと 2 本目が 0 で今の pid を返す。`start` の後に conductor のプロセスを止めても `run` が残る。答えていない問いが新しいセッションの `wait` に返る | +| 2 | 同じ計画の `--parallel` を 2 つのセッションで始める | `start` が、耐久ワークフローに記録したオーガナイザーの作業ディレクトリ(`NDF_RELAY_DIR`)と自分を比べ、記録の側のラッパーが動いている | 断る(終了コード 1)。理由と今の持ち主(作業ディレクトリと名前)を返す。記録の側が止まっていれば、`kind: organizer` のメッセージで持ち主を移して続ける | 持ち主が動いていれば断る・止まっていれば移る | +| 3 | 動かないレーン | 観測の耐久ステップで、レーンの `relay.py ls` の `at` が `stall_minutes`(`plan.json`。既定 60 分)より前で、承認待ち(`awaiting_approval`)でも保留でもない | 問い(`kind: stall`、区分 `ndf-ask:human-action`)を立てる: 「起こし直す(`relay.py restart`)」「止める(`relay.py stop`)」「待つ(次の `stall_minutes` まで問わない)」 | 60 分を越えたら 1 回だけ問いが立つ。「待つ」の後は次の区切りまで立たない | +| 4 | 配布を差し戻した変更が `develop` に残る | `settle --rejected --to <レーン>` が差し戻した Pull Request を記録する | 宛先のレーンが直したと報告するまで、耐久ワークフローは配布を自動で組まない。報告は、直しの周回の配布の依頼に `fixes: <差し戻した配布の連番>` と直す・revert の Pull Request を付けたメッセージで行う。報告より前に別の依頼で配布を組む必要が出たら(宛先のレーンが止まった等)、オーガナイザーへ問いを立て、承認資料に「差し戻した変更が範囲に残っている」と Pull Request を示す | 報告の前に配布が組まれない。報告の後に組まれる。残っている間の資料に印が付く | +| 5 | 同じ原因の問いが複数のレーンから届く(`gh` の認証切れ・ディスク不足・ネットワーク・API の上限) | 区分と、問いの文面を正規化した値(空白をまとめ、レーン名・パス・番号を伏せる)が同じ | 1 つの問いにまとめて出し、答えを依頼してきた全レーンへ配る(`answer-<依頼>` をそれぞれに立てる) | 2 レーンから同じ文面の `ndf-ask:human-action` が届くと問いは 1 つで、答えは 2 つ立つ | +| 6 | 手元の作業が待ちだけになったレーン | ループの周回の境で、選べる課題が無く(`no_issue`)、保留の周回か答えの無い人の判断待ちがある。または保留の周回が `release_pending_max` に達した | ループは新しい止まる理由 `waiting_only`(待ちだけ)で止まる。`no_issue` で止まらない(見直しも挟まない)。セッションを終え、統括役の上限の枠を空ける。オーガナイザーは答えを送るとき、宛先のレーンが止まっていれば `relay.py restart <レーン>` の 1 行を出す | 待ちだけのとき `waiting_only` で止まり `no_issue` で止まらない。答えの後に起こされた境で続きを流す | +| 7 | レーンの間で課題・マイルストーンが重なる | 検査(I19) | 誤り(終了コード 1)。スプリントのレーンの `input.issues` どうし、ループのレーンの `--milestones` どうし、スプリントの課題がループのマイルストーンに入るものを重なりとする。`--milestones` の無いループのレーン(すべてのマイルストーン)は、ほかのレーンと必ず重なる | 重なる組・`--milestones` の無いループが 2 本で誤り。重ならなければ通る | +| 8 | メインディレクトリの git の操作が複数のレーンで同時に走り、`.git/index.lock` が衝突する | — | メインディレクトリで index か HEAD を動かす操作を、`<メインディレクトリ>/.git/ndf-main.lock` の排他(`lib/locks.py`)で 1 本ずつにする。当たる操作は下の表 | 2 プロセスが同時に後片付けを打つと、片方が待ってから成功する | +| 9 | 設計の「直して出し直す」が同じ課題で続く | 人の判断待ちの行の `revise_count` | 3 回続いた後の 4 回目の問いは、選択肢に「やらない」「方針を変える(指摘に新しい方針を書く)」を含める | 4 回目の問いの選択肢に 2 つが入る | +| 10 | 問いの後に状況が変わり、古い答えを使う | 依頼に問いの時点の印(設計の承認なら設計 PR の先頭の SHA、承認ゲート 2 なら承認資料の承認したコミット、承認ゲートの外の問いなら作業の場所の `HEAD`)を付け、答えにも同じ印を付ける | レーンは答えを使う前に今の印と比べ、違えば答えを使わずに問い直す(新しい依頼を送る) | 設計 PR へ push した後に届いた承認は使われず、問い直しが送られる | +| 11 | 止まったレーンの配布待ちのまとめが残る | 計画を終える条件(全レーンが止まり、答えていない依頼が無い)を満たした時点で、`released` を受けたのにまとめを流していない保留の周回がある | `finished` の前に、一時レーンを起こして `sprint-state.py loop pending <ループ>` が返すまとめの `pipeline` を流させる。流し終えてから `finished` にする | 止まったレーンの保留の周回が残ると `finished` の前に一時レーンが起こされる | +| 12 | 配布の途中の `parallel.py stop` | 一時レーンが動いていて、`settle` がまだ | レーンへ `relay.py stop` を打ち、配布が終わる(`settle` が届く)のを待ってから耐久ワークフローを `stopped` で終える。`--force` なら一時レーンも止める。本番のプランの状態ディレクトリは消さず、済んだステップを残す(新しい計画から `--resume` で続けられる) | 配布の途中の `stop` は `settle` の後に終わる。`--force` は待たずに終わり、状態ディレクトリが残る | +| 13 | 利用上限で全レーンが止まる | 観測で、すべてのレーンが動いていない(`at` が進まない)か止まっている | アカウントの切り替えと再開はラッパーに任せる。オーガナイザーは、全レーンが上限で止まったと分かった時点で 1 回だけまとめて知らせる(`notice`)。レーンごとには知らせない | 全レーンの停止で `notice` が 1 回だけ立つ | +| 14 | GitHub API の同時本数の枠(GraphQL 2 本)がプロセスごとで、レーンをまたいで数えない | — | この設計では扱わない。リリース後テストで、レーン 3 本と一時レーンの同時の呼び出しで上限(403・二次の上限)に当たるかを測る。当たれば別の課題にする | (リリース後テスト) | +| 15 | 計画の途中でプロジェクト MVV が変わる | レーンが `mvv_changed` で止まる | オーガナイザーは止まったレーンをまとめて 1 回知らせる。計画は新しい計画名で始め直す(`manual` / `stopped` / `finished` から `running` に戻らない) | 2 レーンが `mvv_changed` で止まると `notice` が 1 回 | +| 16 | `main` へのホットフィックス | — | 範囲外。並列の計画は扱わない(「未確認のまま残ること」) | — | +| 17 | 一時レーンを起こせない(統括役が断る・tmux が無い) | `relay.py spawn` の終了コード | 次の観測の周で起こし直す。3 回続けて断られたら、理由を添えた問いを立てる | 3 回目の断りで問いが立つ | +| 18 | 耐久の記録のファイルが読めない・消えた(掃除・壊れた) | `run` が `durable.launch` で `DurableError` | `run` が終了コード 2 で止まり、オーガナイザーは理由を出して終える。計画は新しい計画名で始め直す。レーンは計画に属さない(I12)ため、今の本番のプランを流す | 記録の無い計画でレーンが依頼を送らない | +| 19 | 出す版のタグがもう在る(計画の外で同じ版が出た) | queue の版の差し込みが止まる(`fill_release_version`) | 一時レーンが問いとして送る(区分 `ndf-ask:irreversible`)。「次の版で出す」「止める」 | 版の重なりで問いが立つ | + +### メインディレクトリの git の操作(異常系 8) + +| 操作 | 置き場 | 排他を取る箇所 | +| --- | --- | --- | +| マージの後の後片付け(worktree を外す・ブランチを消す・メインディレクトリの pull) | `plugins/ndf/scripts/merged-steps.py` の `_cleanup_pr`(`_remove_pr_worktrees`・`_delete_branch`・メインディレクトリの pull) | `_cleanup_pr` の全体 | +| worktree を作る | `plugins/ndf/scripts/supervise_lib/paths.py` の `_add_worktree_with_retry` | `git worktree add` の 1 回ごと(今の `.git/config` の lock のやり直しは残す) | +| メインディレクトリの追従(`follow_branch: true`) | `plugins/ndf/scripts/worktree-session.sh` の `git -C "$MAIN_DIR" checkout -q --detach` と `fetch` | `flock <メインディレクトリ>/.git/ndf-main.lock` で囲む | + +引継ぎ文書の更新(`sprint_loop_record.py` の `update_handoff`)はレーンごとに別のファイルを書き、git を動かさないため当たらない。 + +## 触るファイル + +`plugins/ndf/scripts/lib/parallel_run.py`(`start`・観測の動かないレーンと一時レーン・問い `stall`・重複の正規化・終わりの前のまとめ・`stop --force`)、`plugins/ndf/scripts/lib/parallel_plan.py`(I19・`stall_minutes`)、`plugins/ndf/scripts/lib/parallel_lane.py`(答えの印の比べ)、`plugins/ndf/scripts/lib/sprint_loop_record.py`・`sprint_loop_select.py`(止まる理由 `waiting_only`・`revise_count`)、`plugins/ndf/scripts/lib/sprint_loop.py`(`loop pending`)、`plugins/ndf/scripts/merged-steps.py`、`plugins/ndf/scripts/release-steps.py`(本番の Pull Request がマージ済みなら、マージを飛ばし、タグと GitHub Release のうち無いものだけを作って続ける)、`plugins/ndf/scripts/supervise_lib/paths.py`、`plugins/ndf/scripts/worktree-session.sh`、`plugins/ndf/skills/development-workflow/references/parallel.md`(オーガナイザーの引継ぎ文書とカットポイント) + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 切り離した `run` の寿命 | `start_new_session=True` で起動した `run` が、ラッパーの終わり・コンテナの中のセッションの切り替えで残るかは、実装で確かめる。残らなければ統括役の tmux のセッション(`relay_lib/tmux.py` の `new-session -d`)で起動する | +| `flock` と `filelock` の相互の排他 | シェルの `flock(1)` と `lib/locks.py`(`filelock` の `fcntl.flock`)が同じファイルで互いを待つかは、実装の前に実行して確かめる | +| 利用上限の検知 | `relay.py ls` の行には利用上限で待っていることを示す欄が無い。異常系 13 は `at` が進まないことで推し量る。欄を足すかは #2346 の側の課題にする | +| GitHub API の枠 | 異常系 14 のとおり、リリース後テストで測る | +| ホットフィックス | `main` へ直接直す配布は並列の計画の範囲外。必要になれば別の課題にする | diff --git a/issues/issue-2436-design-hierarchy.md b/issues/issue-2436-design-hierarchy.md new file mode 100644 index 000000000..be5eb622d --- /dev/null +++ b/issues/issue-2436-design-hierarchy.md @@ -0,0 +1,72 @@ +# 並列開発: 全体の階層(論理の階層と置き場)(#2436) + +## 目的 + +- **今どうなっているか**: 2026-11-02、利用者は Neural Development Factory の側にも並列のまとめ役が要るかを検討した。その中で次の 4 つの問いと指摘が出た(#2439 の「依頼(原文)」)。「1 コンテナ = 1 ランナーか、マシン単位で 1 ランナーか」。「上位のオーガナイザー」の定義は合っているか。全体の階層を定義すべきではないか。2 つのオーガナイザーは対等というより排他ではないか。語が 2 つの軸を混ぜて使われているため、上下の関係を取り違えた +- **誰に効くか**: 並列開発を計画する利用者と、#2436・#2439 を設計・実装する conductor +- **変えると何が成り立つか**: どの語がどの層の何を指すかが 1 つに決まる。1 台の中の実装(#2436)と Factory の実装(#2439)が同じ層を担い、互いに排他だと読める + +## 2 つの軸 + +**階層は 2 つの軸に分ける。** 論理の階層は「何の仕事を持つか」を表し、置き場は「どこで動き、同時にいくつ動かせるか」を表す。上下の関係は論理の階層でだけ言う。置き場の大小(マシンはコンテナを含む)を論理の上下として読まない。 + +### 軸 1: 論理の階層 + +| 層 | 持つもの | 1 台の中(relay.py) | Factory | +| --- | --- | --- | --- | +| 1 | 契約と使える量 | 利用者 | アカウント | +| 2 | 開発の対象 | リポジトリ | リポジトリ | +| 3 | 並列のまとめ(本番の配布を 1 本にする・問いを集める・答えの後の作業を持ち主へ返す) | オーガナイザー(`--parallel <計画名>` の conductor と耐久ワークフロー。この設計) | オーガナイザー(API と見回り。#2439) | +| 4 | 1 本の開発の流れ | レーン = 名前つきのラッパー 1 本 | レーン = 開発ループ 1 本、または手動のスプリントの実行 1 本 | +| 5 | 1 版の中身 | スプリント | スプリントの実行 | +| 6 | claude の 1 回の起動 | セッション | 区間 | +| 7 | 対話と承認ゲート | conductor | conductor | +| 8 | 1 つのフェーズ | supervisor / プラン(`plan.json`) | 同じ | +| 9 | 1 つの作業 | worker | 同じ | + +- **層 3 のオーガナイザーは役割の名前で、実装が 2 つある。2 つは排他である。** Factory の実行ジョブの中では、1 台の中のオーガナイザーは動かない。Factory で動く開発のまとめは、Factory のオーガナイザーだけが持つ。2 つの実装は同じ依頼と答えの形(決定 21 の契約)を使う +- **Factory のオーガナイザーの仕事は、1 台の中のオーガナイザーの仕事すべてに、Factory 固有の仕事を足したものである。** 共通の仕事は、本番の配布を 1 本にまとめる、問いを集める、答えの後の作業を持ち主のレーンへ返す、共有の作業を一時レーンへ渡す、の 4 つである。Factory 固有の仕事は、ランナーの制御(実行ジョブの起動・停止・答えでの再開)、実行の枠と手元のランナーの枠の管理、ジョブをまたぐ状態の置き場(S3)である。1 台の中では、ランナーの制御に当たる仕事を統括役とラッパーが担う。Factory ではそれをオーガナイザーが併せて持つ(#2439) +- **排他は起動の側で守る。** ランナーは実行ジョブの環境に `NDF_FACTORY_RUN=1` を立てる(#2439)。`parallel.py init`・`run` は、これが立っていれば何も作らずに断る(終了コード 1、`status: refused`)。tmux とラッパーが無いことに頼らないのは、手元のランナーのマシンには両方があり得るためである +- **層 4 から層 6 の境は、実装によって違う。** 1 台の中では、1 本のラッパーがセッションを替えながらレーンを続ける。Factory では、承認の依頼で実行ジョブが終わる。答えで新しい実行ジョブが起き、同じレーン(ループ)が続く + +### 軸 2: 置き場 + +| 置き場の語 | 1 台の中 | Factory | +| --- | --- | --- | +| 動く場所 | 1 台のマシンかコンテナ(tmux) | 実行ジョブ(コンテナ 1 つ)。`managed`・`byok` は ECS、`local` は手元のランナーのマシン | +| 同時の数を守るもの | 統括役(`NDF_RELAY_MAX_NAMED`) | 実行の枠(リポジトリごと)、手元のランナーの枠(`max_jobs`) | +| 層 6 をつなぐもの | ラッパー(`relay.py run`) | ランナー(実行ジョブの中のプログラム) | + +- **統括役・ラッパー・ランナー・手元のランナーは置き場の語である。** 論理の層を持たない。統括役は同じコンテナのラッパーの数と一覧だけを持ち、オーガナイザーとは別である(決定 4・I13) +- **1 コンテナ = 1 実行ジョブ = 1 ランナーである。** 手元のランナーは、マシン単位で複数の実行ジョブを受け取る受け手である。それぞれのジョブの中では、別々のランナーが動く + +```mermaid +graph TD + A[層1 利用者 / アカウント] --> R[層2 リポジトリ] + R --> O1[層3 オーガナイザー: 1 台の中] + R --> O2[層3 オーガナイザー: Factory] + O1 --> L1[層4 レーン = 名前つきのラッパー] + O2 --> L2[層4 レーン = 開発ループ / スプリントの実行] + L1 --> S[層5 スプリント] + L2 --> S + S --> C[層6 セッション / 区間 → 層7 conductor] + C --> P[層8 supervisor / プラン] + P --> W[層9 worker] +``` + +## 語の衝突と解き方 + +| 衝突 | 今の状態 | 解き方(案) | 持ち主 | +| --- | --- | --- | --- | +| 層 6 の 2 つの名前 | 同じ「claude の 1 回の起動」を、`ndf-workflow` は「セッション」、`ndf-factory` は「区間」と呼ぶ | 用語集で同じ層の対応として結ぶ。名前は文脈ごとに残す(どちらも実装と契約に広く出ている) | この課題(用語集) | +| 「プラン」の 2 つの意味 | `ndf-workflow` は `plan.json`、`ndf-factory` はアカウントの契約の種類 | Factory の側を「料金プラン」に改める(#2439 の設計。コードの識別子は変えない) | #2439 | +| 「ランナー」と「手元のランナー」 | 前者はコンテナ単位のプログラム、後者はマシン単位の受け手 | 手元のランナーを「手元のマシン」へ改める(#2439 の設計。コードの識別子は変えない) | #2439 | +| 「上位のオーガナイザー」 | 置き場の大小を論理の上下と読んだ | 使わない。「Factory のオーガナイザー」と呼び、1 台の中のオーガナイザーとは排他とする | この課題・#2439 | + +## 触るファイル + +| ファイル | 変えること | +| --- | --- | +| `docs/glossary/glossary.json`・`docs/glossary.md` | 層の対応(セッション ↔ 区間)、オーガナイザーの「実装が 2 つある役割」の意味、統括役との区別 | +| `plugins/ndf/scripts/parallel.py`・`plugins/ndf/contracts/env/NDF_FACTORY_RUN.json` | `NDF_FACTORY_RUN` が立っていれば `init`・`run` が断る。環境変数の宣言を足す(立てるのは #2439 のランナー) | +| `plugins/ndf/skills/development-workflow/references/agent-layers.md` | 層 7〜9(conductor / supervisor / worker)の上に層 3〜6 があることを 1 節で示し、この階層への参照を置く | diff --git a/issues/issue-2436-design-lane-loop.md b/issues/issue-2436-design-lane-loop.md new file mode 100644 index 000000000..f8c33c632 --- /dev/null +++ b/issues/issue-2436-design-lane-loop.md @@ -0,0 +1,129 @@ +# 並列開発の設計: レーンのループが答えを待たずに次の周回へ進み、答えの後の処理を周回の境で行う(#2436 の受け入れ条件 6a・6d〜6f) + +設計文書 [issue-2436-design.md](issue-2436-design.md) から、開発ループ(`--loop`)の側の変更を分けたファイル。ループの今の手順は `plugins/ndf/skills/development-workflow/references/loop.md`、契約と不変条件は確定仕様 `docs/specifications/ndf-workflow-development-loop.md` にある。 + +## 具体例: レーン `lroot` のループ `l20261102` + +1. 周回 3 が #2501・#2502 を開発し、本番のステージで配布の依頼のプランが依頼を送った。プランは「配布の依頼」の承認ゲートとして終わり、queue はまとめ(確定仕様化・振り返り・課題を閉じる・棚卸し)を流さなかった +2. `loop lap` が周回 3 を「配布待ち」で締め、`ndf-next` を返す。夜の間、利用者は承認ゲート 2 に答えていない +3. 周回 4 の `loop select` は #2501・#2502 を「保留の周回の課題」で外し、#2510 を選んで開発する。周回 4 の開発版の配布は止まらない +4. 朝、利用者が承認ゲート 2 に答え、配布が出た。周回 4 を締めた後の境で、`loop select` が周回 3 の答え(`released`、`10.28.0`)を待たずに読み、周回 3 のまとめの `pipeline` を返す +5. まとめが終わると `loop finish l20261102 3 --done ` が周回 3 を「完了」で締め直し、版を記録へ書く。続けて周回 5 を選ぶ + +## 周回の結果「配布待ち」 + +| 結果 | 意味 | 締め直せるか | +| --- | --- | --- | +| `進行中` | 今のまま | — | +| `配布待ち`(新規) | 配布の依頼を送り、答えを待たずに締めた。まとめはまだ流していない | 1 回だけ、`完了` / `失敗` / `人へ戻した` へ | +| `質問待ち`(新規) | 作業の途中の問いを問いの hook がオーガナイザーへ送り、止まったプランを残して締めた | 1 回だけ、続きを流した後の結果へ | +| `完了` / `失敗` / `人へ戻した` | 今のまま | 締め直さない | + +```mermaid +stateDiagram-v2 + [*] --> 進行中 + 進行中 --> 配布待ち: 配布の依頼を送った + 進行中 --> 完了 + 進行中 --> 失敗 + 進行中 --> 人へ戻した + 配布待ち --> 完了: released でまとめが通った + 配布待ち --> 失敗: failed かまとめが落ちた + 配布待ち --> 人へ戻した: rejected(直しの周回へ) + 進行中 --> 質問待ち: 問いを送った + 質問待ち --> 完了: 答えの後に続きが通った + 質問待ち --> 失敗: 続きが落ちた +``` + +周回の記録(`.ndf/loop/<名>/loop.json` の `laps[]`)に `release` の項目を足す。 + +| 項目 | 意味 | +| --- | --- | +| `release.plan` | 配布の依頼のプランのパス | +| `release.out` | 依頼の識別子を書いたファイル(`{state_dir}/work/request.txt`) | +| `release.plan_name` | 並列の計画名 | +| `release.close_plans` | まとめのプランのパス(`loop sprint` が組んだもの。流さずに残す) | +| `release.answer` | 届いた答え(`result`・`version`・`at`)。読むまでは無い | +| `release.finish` | まとめを流した区間(`started_at`・`ended_at`)と done のパス | +| `question` | 質問待ちのとき: 依頼の `id`・止まったプランとステップ(`gate2_pipeline` と同じく続きのステップから流す)・done のパス | + +## どこに何を足すか + +| 今の部品 | 足すこと | +| --- | --- | +| 結果の定数(`sprint_loop_record.py` の `IN_PROGRESS, DONE, FAILED, RETURNED`) | `RELEASE_PENDING = "配布待ち"`・`QUESTION_PENDING = "質問待ち"` | +| 新しい副命令 `loop hold-question <名> --ref <依頼> --plan <止まったプラン> --step <続きのステップ>` | 問いの hook に止められた conductor が打つ。周回を `質問待ち` で締めて次の周回へ進む | +| `loop answer`(`cmd_answer`) | `--revise --note <パス>` を足す。人の判断待ちの行に `answer: revise` と指摘を書き、課題の本文の節にも指摘を書く | +| 人の判断待ちの答えを読む(新しい `answers_step`) | 周回の境で、自分の `gate1` の依頼の答えを `parallel_lane.answers_of()` で読む。承認ならラベル(`gh pr edit --add-label design-approved`)と `loop answer --approved --pr`、直して出し直すなら `loop answer --revise --note`、やらないなら `loop answer --rejected` を自分で打つ | +| 選定(`choose_issues`・`exclusion_reason`) | `answer: revise` の課題は順位によらず先頭に選び、設計のステージを指摘つきで流し直す(`new sprint` の設計の課題に足し、設計のプランの `design` のステップの指示に課題の本文の指摘の節を読むことを足す。同じ `design/issue-` のブランチと設計 PR を使う)。`answer: approved` で設計 PR がまだマージされていない課題は「設計 PR がマージ待ち」で外さずに選び、周回の最初のステージに設計 PR のマージのプラン(`design_merge.plan_design_merge`。番号は記録の `pr`)を置く | +| `loop sprint`(`sprint_loop.py` の `cmd_sprint`) | `build_close` が返したまとめのプランを `lap["close_plans"]` に残す | +| `loop lap`(`cmd_lap` → `sprint_loop_gate.py` の `handle_gate`) | 承認ゲートで終わった計画の「フェーズ」が `配布の依頼` なら、承認ゲート 2 の扱い(`handle_gate2`)へ進まず、新しい `handle_release_pending` が周回を `配布待ち` で締める(`close_lap` に `release` を渡す)。その後は今の `after_close` で次の周回へ進む | +| `close_lap`(`sprint_loop_gate.py`) | `配布待ち` で締められる。締め直しは新しい `finish_lap` だけが行い、`配布待ち` の行の `outcome`・`ended_at`・費用と秒・振り返り・棚卸しを 1 回だけ書き換える | +| 周回の境(`sprint_loop_select.py` の `cmd_select`、`sprint_loop.py` の `resume_stopped`、`sprint_loop_gate.py` の `after_close`) | 選定と止まる条件の判定の前に、新しい `pending_step` を通す(下の「周回の境」) | +| 新しい副命令 `loop finish <名> <周回> --done `(`sprint_loop.py`・`sprint_loop_cli.py`) | まとめの done を読み、`finish_lap` で周回を `完了` か `失敗` で締め直し、周回の境へ戻る(`after_close` と同じ結果を返す) | +| 選定の除外(`sprint_loop_select.py` の `exclusion_reason`) | 保留の周回(`配布待ち`・`質問待ち`)の課題を、その周回が締め直されるまで「保留の周回の課題」で外す。課題はまだ閉じていないため順位に残るが、保留が解けるまで新しいスプリントの課題に選ばない(選ぶと保留中の実装と作業が重なる) | +| 予算の消費(`sprint_loop_record.py` の `consumed`・`active_seconds`) | `配布待ち` は終わった周回として周回の数に入れる。動いていた時間には、締めるまでの区間と `release.finish` の区間を足す。周回の境で答えを待つ時間は、今の「周回の境で止まっていた時間」と同じく数えない | +| 同じ失敗の数え方(`same_failure_count`) | `配布待ち` は完了でも失敗でもないものとして読み飛ばす。締め直した後は締め直した結果で数える | +| 止まる条件(`boundary_stop`) | 下の「周回の境」の順で判定する | +| `loop status`・`loop list`・`laps.md` | `配布待ち` の周回と答えの有無を出す | +| 止まる理由(`sprint_loop_record.py` の `boundary_stop`・確定仕様の止まる条件の表) | `waiting_only`(待ちだけ)を足す。再開(起こされた境)で解ける | +| 新しい副命令 `loop pending <名>` | 答えの届いた保留の周回のまとめの `pipeline` だけを返す。止まったレーンの保留の周回を一時レーンが流すときに使う(異常系 11) | +| `plugins/ndf/skills/development-workflow/references/loop.md` | 「周回ごとに打つ順」に `status: finish`・`release_wait` の 2 行、「承認ゲート」に配布の依頼の行、「記録」に `release` の項目 | +| `.ndf/pace.json` の `loop` | `release_pending_max`(配布待ちと質問待ちの周回を合わせた上限。既定 2) | + +確定仕様の I1(終わった周回の行は書き換えない)は、「`配布待ち` の行は `loop finish` か周回の境の差し戻しで 1 回だけ締め直す」を例外として足す。確定仕様の文書は、この課題の確定仕様化で直す。 + +## 周回の境 + +`pending_step` は、選定の前と周回を締めた後の境で、人の判断待ちの答えと、保留の周回(`配布待ち`・`質問待ち`)を古い順に 1 つずつ見る。答えは `parallel_lane.answers_of()`(`durable.event_of` を `timeout 0` で読む。待たない)で読む。 + +| 順 | 当たる場面 | 扱い | +| --- | --- | --- | +| 1 | 止まる条件の `user_stop`・`mvv_changed` | 今どおりすぐ止まる。保留の周回はそのまま残り、再開の境で続きを読む | +| 2 | 人の判断待ちの課題に答えが届いている | `answers_step` が答えの後の処理(ラベル・`loop answer`)を行う。選定で次の周回に効く | +| 3 | 最も古い保留の周回(`配布待ち` か `質問待ち`)に答えが届いている | `配布待ち` に `released` なら `release.answer` に版を書き、`status: finish` と `pipeline`(`supervise.py queue <まとめのプラン> --done && wait && loop finish <名> <周回> --done `)を返す。`質問待ち` なら、止まったプランを続きのステップから流し、残りのステージとまとめを流す `pipeline` を返す。conductor はそれを流す | +| 4 | `配布待ち` に `rejected` が届いている(宛先のレーンとして指摘つき)か、イベント `fix-<レーン名>-<連番>` が届いている | 周回を `人へ戻した` で締め直し、まとめは流さない。指摘を `rec["fixes"]` に入れ、次の周回を直しの周回にする(周回の課題を順位によらず選び、`new sprint` に指摘を渡して直す Pull Request か revert の Pull Request を作る。どちらにするかは直しの周回の実装のステップが決める)。直しの周回は配布の依頼を送り直す | +| 5 | `配布待ち` に知らせ `defer-<依頼>` が届いている(`answer-<依頼>` はまだ無い) | 答えの確定ではないため、`配布待ち` のまま次の配布を待つ。後の配布の答えは `answer-<依頼>` に 1 回だけ立つ(I10) | +| 6 | `failed` が届いている | 周回を `失敗` で締め直し、`failure` に配布の失敗を書く(同じ失敗に数える)。3 へ戻る | +| 7 | 答えの無い保留の周回が `release_pending_max` 以上ある、または選べる課題が無く保留の周回か答えの無い人の判断待ちがある | 新しい周回を始めず、新しい止まる理由 `waiting_only`(待ちだけ)で止まる。`no_issue` では止まらず、見直しも挟まない。`ndf-next` を出さずにセッションを終え、統括役の上限の枠を空ける。答えが届くと、オーガナイザーが `relay.py restart <レーン>` で起こす([issue-2436-design-failures.md](issue-2436-design-failures.md) の異常系 6) | +| 8 | 止まる条件の `same_failure`・予算・`outside_mvv` に当たり、答えの無い保留の周回がある | 先に `waiting_only` で止まる。起こされた境で保留の周回の続きとまとめを流してから、その止まる条件で止まる。流さないまま止まると、課題が閉じずに残るためである | +| 9 | どれにも当たらない | 今どおり選定へ進む | + +**上限を 2 にする理由。** 1 回の承認ゲート 2 で範囲に入った周回はまとめて答えを受けるため、配布待ちの周回を増やしても承認の回数は減らない。配布待ちの周回は課題を選定から外し、振り返りと棚卸しを遅らせる。2 なら、承認を待つ間に次の周回を 1 本開発でき(利用者の依頼の「承認を待っているあいだ次の sprint を実行する」)、その周回も配布待ちになった時点で待つ。 + +**次の周回の変更が同じ配布に入るとき。** 配布待ちの間に次の周回が `develop` へマージした変更は、承認の答えの時点の比べで出し直しの範囲に入り得る(「新しい範囲で出す」)。その周回が配布の依頼のステップに来ると、自分の Pull Request がすでに `main` にあるため依頼を送らずに `released` を返し、配布の依頼のプランは完了で終わる。queue は続けてまとめを流し、周回は `配布待ち` を経ずに `完了` で締まる。範囲に入らなかった変更は、その周回の依頼が次の配布を組む。 + +**開発版の版数。** 配布待ちの間に次の周回が開発版を出す。本番の版上げと開発版の版数の衝突は、配布の規則の側([issue-2436-design-release-rule.md](issue-2436-design-release-rule.md) の「版数の衝突」)が解く。 + +## スプリントのレーン + +ループでないスプリントのレーンは周回を持たない。conductor は配布の依頼のプランが `配布の依頼` で終わったら、`parallel.py await <計画名> --from <パス>` を背景で打って答えを待ち、`released` ならまとめのステージを流す。`rejected` は今の承認ゲート 2 の差し戻しと同じく扱う。 + +## テスト設計 + +| 受け入れ条件 6a | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 待たずに次の周回へ進む | 配布の依頼のプランが `配布の依頼` の承認ゲートで終わった done を `loop lap` に渡すと、周回が `配布待ち` で締まり、`ndf_next` を返す。今のループの止まる理由 `gate2_returned`(承認ゲート 2 を人へ戻し、周回を進行中のまま止まる。確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の I3)で止まらない | 承認ゲート 2 として止まる・まとめを流してから締めると落ちる | +| 保留の周回の課題を選ばない | 次の `loop select` が `配布待ち` と `質問待ち` の周回の課題を「保留の周回の課題」で外す。`完了` か `失敗` に締め直した後は外さない(閉じた課題は棚卸しの順位に出ない) | 配布待ちか質問待ちの課題を選ぶと落ちる | +| 答えの後に残りを流す | `released` の答えのある `配布待ち` の周回があると、境が `status: finish` とまとめの `pipeline` を返し、`loop finish` が `完了` で締め直して版を書く。複数あれば古い順に 1 つずつ返す | 新しい周回を先に始める・版を書かない・新しい順に流すと落ちる | +| 差し戻しで締める | `rejected` の答えでは周回が `人へ戻した` で締まり、まとめは流れない | まとめを流す・`完了` で締めると落ちる | +| 上限で止まる | 答えの無い `配布待ち` が 2 つあると、境が新しい周回を始めずに `waiting_only` で止まる | 3 つ目の周回を始める・`no_issue` で止まると落ちる | +| 止まる前に流す | 予算に当たったとき、答えの無い `配布待ち` があれば `waiting_only` で止まり、起こされた境でまとめを流してから予算で止まる。`user_stop` はすぐ止まる | 予算で止まってまとめを残す・`user_stop` で待つと落ちる | +| 予算と同じ失敗 | `配布待ち` を周回の数に入れ、同じ失敗の数え方では読み飛ばす | 配布待ちを数えない・失敗として数えると落ちる | +| 6d 質問待ちの再開 | `loop hold-question` で周回が `質問待ち` で締まり、次の周回へ進む。答えの届いた境で止まったプランが続きのステップから流れ、締め直る | 答えの前に流す・最初から流し直すと落ちる | +| 6e・6f 答えの後の処理をレーンが行う | `gate1` の答えが届いた境で、レーンが承認ならラベルと `loop answer --approved --pr`、直して出し直すなら `--revise --note`、やらないなら `--rejected` を打つ | 答えを読まない・別の答えを打つと落ちる | +| 6e 直して出し直す | `revise` の課題が次の周回で順位によらず先頭に選ばれ、設計のプランの指示に指摘の節を読むことが入り、同じ設計 PR のブランチで流れる | 順位どおりに選ぶ・新しいブランチで設計し直すと落ちる | +| 6e 承認の後の設計 PR のマージ | `approved` でマージされていない課題が選ばれ、周回の最初のステージに設計 PR のマージのプランが置かれる | マージ待ちで外したまま・マージのプランが無いと落ちる | +| 6e 配布の差し戻しの宛先 | 宛先のレーンでは周回が `人へ戻した` で締まり、次の周回が直しの周回になって依頼を送り直す。宛先でないレーンでは知らせ `defer-<依頼>` を受けて `配布待ち` のまま残る | 宛先でないレーンで締め直す・宛先のレーンが直さないと落ちる | + +## 触るファイル + +`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/sprint_loop_gate2.py`、`plugins/ndf/scripts/lib/sprint_loop_record.py`、`plugins/ndf/scripts/lib/sprint_loop_select.py`、`plugins/ndf/scripts/lib/sprint_loop_cli.py`、`plugins/ndf/scripts/supervise_lib/sprint_waves.py`(設計のプランの指示に指摘の節)、`plugins/ndf/scripts/supervise_lib/design_merge.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/references/loop.md` + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| まとめのプランを後で流すこと | `build_close` が組んだまとめのプランを、周回を締めた後の別の queue で流して同じ結果になるか(`{queue_record}` の差し込みが流す時点に記録の Pull Request を探す。#2230)は実装で確かめる | +| 作業の途中の問いの止まり方 | 問いの hook に止められた時点で、周回のプランが続きのステップから流せる形で止まっているか(queue の途中で止まった計画の続き方)は、止まる場所ごとに実装で確かめる。続けられない場所は `質問待ち` にせず、今どおり人へ戻す | +| 直しの周回の作り方 | 実装がすでに `develop` にある課題を、指摘つきでもう 1 度スプリントにする `new sprint` の引数(直しの指示の渡し方)は実装で決める | +| 配布待ちの間のリリース後テスト | まとめのリリース後テストは配布の後に流れるため、次の周回の変更が同じ配布に入ったときは、その変更もまとめて確かめることになる。前の周回の課題に絞って確かめる形は持たない | diff --git a/issues/issue-2436-design-release-rule.md b/issues/issue-2436-design-release-rule.md new file mode 100644 index 000000000..253cf0a6b --- /dev/null +++ b/issues/issue-2436-design-release-rule.md @@ -0,0 +1,127 @@ +# 並列開発の設計: 本番チャネルへの Pull Request の規則の改定(#2436 の受け入れ条件 6b) + +設計文書 [issue-2436-design.md](issue-2436-design.md) から、本番の配布の規則を改める部分を分けたファイル。決定の理由は [issue-2436-design-decisions.md](issue-2436-design-decisions.md) の決定 15 にある。 + +## 目的 + +- **今どうなっているか**: 本番の配布は `develop` → `main` の Pull Request で `develop` の先頭を出す。承認したコミットの後に配布の Pull Request の外のコミットが `develop` へ入ると、配布は承認ゲート(プランのステップが承認ゲートとして止まったことを表す終了コード 10。`supervise_lib/plan.py` の「ステップの遷移」)で止まる(#815)。レーンが並ぶと、承認を待つ間と本番のプランが流れる間に他のレーンのマージが入り、配布が止まる +- **変えると何が成り立つか**: 承認したコミットと版上げのコミットだけを `main` へ出す。`develop` は止めず、レーンの開発版の配布と確認は続く。承認の後に `develop` へ入った変更は次の配布になる + +## 今の作りと改めた後 + +| 点 | 今の作り(コードの位置) | 改めた後 | +| --- | --- | --- | +| `release/v<版>` を切る起点 | 本番のプランの「起点」が `origin/<ベースブランチ>`(`release_templates.py` の `plan_release_package_plugin`。worktree は `paths.py` の `_resolve_base` が作る) | 承認したコミット。本番のプランに新しい項目「起点のコミット」を足し、差し込み `{queue_approved:<計画名>}` を、名前の一致する完了した計画の approval-facts の `metrics.approved_sha` で埋める。「起点」は今どおり `origin/<ベースブランチ>` のまま残す(下の「起点のコミットと版の判定」) | +| 本番の Pull Request の head | `develop`(`release-steps.py` の `_merge_prod_pr` が `create_pr(root, prod, base, ...)`) | `release/v<版>`(`create_pr(root, prod, branch, ...)`) | +| 承認の照合 | `origin/<ベースブランチ>` の先頭と承認したコミットを比べる(`release_lib/approval.py` の `check_approved` → `lib/approved_commit.py` の `compare_approved`) | `origin/release/v<版>` の先頭と承認したコミットを比べる。許すのは `release/v<版>` の Pull Request のコミット(版上げ)だけで、版数の衝突を解いたときも `release/v<版>-sync` の Pull Request からは読まない | +| 本番の Pull Request のマージで指定する先頭 | 比べた `develop` の先頭(`wait_and_merge(..., expect=verdict.tip)`) | 比べた `release/v<版>` の先頭 | +| 宛先の検査 | head が `develop` のときだけ通す(`scripts/check-pr-base.sh`) | head が `develop`、または先頭が `origin/develop` の祖先の `release/v*` なら通す | +| `release/v<版>` を `develop` へマージする時期 | 本番の Pull Request の前(`_merge_release_pr`) | 同じ。版数の衝突は配布のステップが解く(下の「版数の衝突」) | + +## 不変条件 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| R1 | `main` 宛の Pull Request の head は `develop` か、先頭が `origin/develop` の祖先の `release/v*` である | `scripts/check-pr-base.sh` が落とす(継続的統合のジョブ `pr-base-guard`) | +| R2 | `release/v<版>` は承認したコミットから切り、版上げのコミット(bump・changelog・説明文)だけを足す | 承認の照合が承認の外のコミットとして承認ゲート(終了コード 10)で止める | +| R3 | `release/v<版>` を `develop` へ取り込んでから、本番の Pull Request を作る | 取り込みが済むまで本番の Pull Request を作らない(今の順のまま) | +| R4 | `release/v<版>` はマージのコミットで `develop` へ取り込み、先頭を `develop` の祖先に残す | squash と rebase では取り込まない(祖先にならず R1 が落ちる) | +| R5 | `develop` へ取り込むときの衝突は、版数を持つ箇所だけを本番の版で解く。ほかの衝突は解かずに止める | 配布のステップが終了コード 1 で止まる(今の失敗の扱い) | + +## 処理の流れ + +```mermaid +sequenceDiagram + participant P as 本番のプラン + participant R as release/v版 + participant D as develop + participant M as main + P->>R: 承認したコミットから切る + P->>R: 版上げのコミット + R->>D: 取り込み(衝突は版数だけ解く) + P->>P: 承認したコミットと R の先頭を照合 + R->>M: 本番の Pull Request(head は R) + P->>M: タグと GitHub Release +``` + +1. queue が本番のプランを流す前に、「起点のコミット」の `{queue_approved:<計画名>}` を承認したコミットで埋める。埋められなければ本番のプランを流さない(#2079 の既存の扱い)。並列の計画の外のスプリントでは `<計画名>` は開発版のプラン(`release`。`DEV_SERIES`)で、並列の計画では提示のプラン(`present`)である +2. worktree は承認したコミットから `release/v<版>` で作られる。bump・changelog・説明文は今のステップのまま、承認したコミットの上に版上げのコミットを積む +3. `release` のステップは `release/v<版>` を `develop` へ取り込む(`_merge_release_pr`)。衝突が無ければ今どおりマージする。衝突があれば下の「版数の衝突」で解く +4. 承認したコミットと `origin/release/v<版>` の先頭を照合する。照合で許すコミット(`release_pr_allowed` の許す一覧)は、`release/v<版>` → `develop` の Pull Request から読む。版数の衝突を解いて `release/v<版>-sync` の Pull Request で取り込んだときも、照合には元の `release/v<版>` の Pull Request を渡す。`-sync` の head は承認の後の `develop` の変更を含むため、照合に渡すと版上げしかない `release/v<版>` でも承認ゲート(終了コード 10)で止まる。外のコミットが無ければ、head を `release/v<版>` にした本番の Pull Request を作り、照合した先頭を指定してマージする。タグと GitHub Release は今どおり `main` のマージのコミットに打つ + +`develop` が承認の後に進んでいても、照合する相手は `release/v<版>` で、配布は止まらない。昇格の経路(`merged-steps.py promote`。head は `develop`)は R1 の `develop` の場合に当たり、変えない。 + +## 起点のコミットと版の判定 + +本番のプランの「起点」は、worktree の起点と版の判定の両方に読まれている。worktree は `paths.py` の `_resolve_base` が「起点」から作り、版の差し込み(`queue_fill.py` の `_Fill`)は同じ「起点」から `origin/` を外したものをベースブランチとして `release_version.observe()` へ渡す。「起点」を承認したコミットに置き換えると、版の判定が `origin/` を読もうとして `unreadable` になり、本番のプランが流れる前に止まる。そこで 2 つを別の項目に分ける。 + +| 項目 | 値 | 読み手 | +| --- | --- | --- | +| 起点 | `origin/<ベースブランチ>`(今どおり) | 版の差し込み(`_Fill` → `release_version.observe()`)と、「起点のコミット」が無いときの worktree | +| 起点のコミット(新規) | `{queue_approved:<計画名>}`。queue が承認したコミットの SHA で埋める | worktree の起点(`_resolve_base`)だけ | + +「起点のコミット」を持たないプラン(開発版のプラン・並列の計画の外の既存のプラン)は、今どおり「起点」から worktree を作る。 + +## 版数の衝突 + +承認の後に他のレーンの開発版の配布が `develop` の版数を上げていると、`release/v<版>` を `develop` へ取り込むときに版数を持つ箇所([docs/versioning-and-distribution.md](../docs/versioning-and-distribution.md) の「版数を持つ箇所」)が衝突する。 + +| 段 | 扱い | +| --- | --- | +| 衝突を見つける | `release/v<版>` → `develop` の Pull Request が衝突している(`mergeStateStatus` が `DIRTY`。merged-steps の終了コード 4) | +| 解く枝を作る | `release/v<版>` から `release/v<版>-sync` を作り、`origin/develop` をマージする。`release/v<版>` そのものには `develop` を取り込まない(取り込むと承認の外のコミットが `main` へ出る) | +| 解く | 衝突した箇所がすべて版数を持つ箇所なら、`release/v<版>` の側(本番の版)を取る。1 つでもほかの箇所が衝突していれば止める(R5) | +| 取り込む | `release/v<版>-sync` → `develop` の Pull Request を作ってマージする。`release/v<版>` の先頭は `release/v<版>-sync` の親として `develop` の祖先になる(R1) | +| 記録 | 照合(`release_pr_allowed`・`compare_approved`)に渡す Pull Request と、取り込みに使った Pull Request を分けて持つ。照合には元の `release/v<版>` の Pull Request(マージされずに閉じても、承認したコミットの後のコミットは版上げだけ)を渡し、`release/v<版>-sync` の Pull Request は渡さない。`release/v<版>-sync` の Pull Request は `develop` への取り込みにだけ使い、リリース記録(`record`)と後片付け(`cleanup`。head が `release/v<版>` で始まる)は両方の Pull Request を扱う | + +並列の計画の下では、このステップを含む本番のプランは一時レーンが流し、衝突の解消と失敗の対応も一時レーンが行う([issue-2436-design-approvals.md](issue-2436-design-approvals.md) の「責務の分け方」)。 + +**版の規則とは矛盾しない。** 解いた後の `develop` の版数は本番の版(例: `10.28.0`)になる。次の開発版の配布は、記録の版(例: `10.28.0-dev.4`)がもう出ている版以下になるため、queue が次に空いている版(`10.28.1-dev.1`)で決め直す(`queue_fill.py` の `_recheck_dev` と `release_version.taken`)。接尾辞は次に出す正式版へ付く。`CHANGELOG.md` は開発版を載せない(`CHANGELOG.md` の冒頭)ため、開発版の配布との衝突は起きない見込みで、起きたら R5 で止める。 + +## 互換性 + +並列の計画の外の本番の配布(レーンでない 1 本のスプリント・ループ)も、この規則で動く。 + +| 場面 | 今の振る舞い | 改めた後 | +| --- | --- | --- | +| 承認の後に `develop` が進んだ | 本番の配布が承認ゲート(終了コード 10)で止まり、承認し直す | 止まらず、承認したコミットと版上げを出す。進んだ分は次の配布 | +| 本番の Pull Request | head が `develop` | head が `release/v<版>`(衝突を解いたときも `release/v<版>`) | +| `main` と `develop` の関係 | 本番の配布の直後、`main` の中身は `develop` の先頭と同じ | `main` は承認したコミットと版上げまで。`develop` はその先を持つ | + +## 構成要素と触るファイル + +| ファイル | 変えること | +| --- | --- | +| `scripts/check-pr-base.sh` | 引数に head の SHA を足す(`check-pr-base.sh <分岐元> []`)。head が `release/v*` なら `origin/<起点>` を fetch し、`git merge-base --is-ancestor origin/<起点>` が 0 のときだけ通す | +| `.github/workflows/pr-base-guard.yml` | `actions/checkout` の `fetch-depth: 0`、環境変数で `github.event.pull_request.head.sha` を渡す | +| `plugins/ndf/scripts/release-steps.py` | `_merge_prod_pr` の head と `expect` を `release/v<版>` にする。`_merge_release_pr` に衝突のときの解く枝(`release/v<版>-sync`)を足し、照合に渡す Pull Request(元の `release/v<版>`)と取り込みの Pull Request(`-sync`)を分けて返す。`cmd_release` の照合の相手を `release/v<版>` にする | +| `plugins/ndf/scripts/release_lib/approval.py` | `check_approved` が比べる先頭を引数の参照(`release/v<版>`)から読む。止まったときの文言の参照名 | +| `plugins/ndf/scripts/release_lib/version_merge.py`(新規) | 衝突した箇所が版数を持つ箇所かの判定と、本番の版の側を取る解き方 | +| `plugins/ndf/scripts/supervise_lib/release_templates.py` | 本番のプランに「起点のコミット」`{queue_approved:<計画名>}` を足す。「起点」は `origin/<ベースブランチ>` のまま変えない | +| `plugins/ndf/scripts/supervise_lib/queue_fill.py` | 差し込み `{queue_approved:<計画名>}`。版の差し込み(`_Fill`)は今どおり「起点」のブランチ名を `release_version.observe()` へ渡し、「起点のコミット」を読まない | +| `plugins/ndf/scripts/supervise_lib/paths.py` | `_resolve_base` が「起点のコミット」を「起点」より先に読み、あれば worktree の起点にする(`origin` を fetch してから、その SHA から `release/v<版>` を作る) | +| `AGENTS.md` | 「Git運用ルール」の `main` 宛の Pull Request の行を「`develop` に含まれるコミットだけ(head は `develop` か `release/v<版>`)」にする | +| `docs/versioning-and-distribution.md` | 正式版を出す手順・チャネルの説明の、本番の Pull Request の head と承認したコミットの扱い | +| `plugins/ndf/skills/release/references/release-steps.md` | `release` の `prod` の説明(「続けて `develop` → `main` をマージ」)と #815 の照合の説明 | +| `plugins/ndf/scripts/tests/`・`scripts/tests/` | 下のテスト設計 | + +## テスト設計 + +| 条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| R1(通す) | head が `develop`、または先頭が `origin/develop` の祖先の `release/v10.28.0` なら 0 | 祖先の判定を外す・`release/v*` の形を見ないと落ちる | +| R1(落とす) | head が `feature/x`、`develop` の祖先でない `release/v10.28.0`、SHA を渡さない `release/v*` なら 1 | 名前だけで通すと落ちる | +| R2・照合 | 承認したコミットから切った `release/v<版>` に版上げだけがあれば照合が通る。`develop` が承認の後に進んでいても止まらない。`release/v<版>` に承認の外のコミットがあれば 10 | 照合の相手を `develop` の先頭に戻すと「進んでいても止まらない」が落ちる | +| 起点 | 本番のプランの worktree が承認したコミット(「起点のコミット」)から作られ、版の差し込みは「起点」の `develop` から版を読んで `unreadable` にならない。`{queue_approved:<名>}` を埋められなければ流さない | worktree の起点を `origin/develop` に戻す・版の判定に SHA を渡すと落ちる | +| 版数の衝突 | `develop` で `plugin.json` の版数が `10.28.0-dev.4` に上がった後に `release/v10.28.0` を取り込むと、`release/v10.28.0-sync` で本番の版を取り、`release/v10.28.0` の先頭が `develop` の祖先になる。版数でない箇所が衝突すれば 1 で止まる | `release/v<版>` に `develop` を取り込む・版数でない衝突を解くと落ちる | +| 版数の衝突の後の照合 | 承認の後に `develop` が進み、`release/v10.28.0-sync` で取り込んだとき、`release/v10.28.0` に版上げしか無ければ照合が通り、本番の Pull Request が作られる | 照合に `-sync` の Pull Request を渡すと、承認の後の `develop` の変更が承認の外になって 10 で止まり落ちる | +| Pull Request 2420 の件 | レーン A の配布の後、範囲に入っていたレーン B が依頼のステップに来ても本番の Pull Request を作らない。レーン A の配布の承認の後にマージされたレーン B の変更は A の `main` に入らず、次の配布になる | B のための本番の Pull Request を作る・A の配布に承認の外の B の変更が入ると落ちる | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 配布の Pull Request の取り込み方 | `release-steps.py` の `wait_and_merge` が打つ `merged-steps.py merge-when-green` の `--method` の既定がマージのコミット(`merge`)かは未確認(R4) | +| 継続的統合の祖先の判定 | `pr-base-guard` で `fetch-depth: 0` と head の SHA の受け渡しにより `git merge-base --is-ancestor` が判定できるかは、実装で Pull Request を作って確かめる | +| `main` のブランチの規則 | `main` の ruleset が `develop` 以外の head の Pull Request のマージを許すかは未確認 | +| 明示の開発版の版数 | ループの `loop sprint --dev <開発版>` が queue の版の記録で明示(`explicit`)として扱われると、本番の版が先に出た後の開発版の配布は決め直さずに止まる(`_recheck_dev`)。どちらで扱われるかは未確認 | diff --git a/issues/issue-2436-design.md b/issues/issue-2436-design.md new file mode 100644 index 000000000..b3120c64f --- /dev/null +++ b/issues/issue-2436-design.md @@ -0,0 +1,600 @@ +# 並列開発: 並列の計画を 1 度書き、1 行で複数のレーンを起動して本番の配布を 1 本にまとめる(#2436) + +## 目的 + +- **今どうなっているか**: 統括役(同じコンテナで最初に起動したラッパーが兼ねる、ほかのラッパーを登録・起動し同時に動かす数を数える役。#2346)で tmux のセッションを並べて起動できるが、並列開発を計画して始める手順が無い。2026-11-02 は 3 本の開発ループを手で起動し、共通の決まりを 3 本の引継ぎ文書へ手で書き写した。本番の配布もレーンごとに出した。先に出したレーンが他のレーンの変更をまとめて出した。そのため、別のレーンの本番の Pull Request https://github.com/devbasex/ai-plugins/pull/2420 を閉じた +- **誰に効くか**: 1 つのリポジトリで複数の開発ループかスプリントを同時に進める利用者 +- **変えると何が成り立つか**: 並列の計画を 1 度書き、`/goal /ndf:development-workflow --parallel <計画名>` の 1 行を打つ。オーガナイザーがレーンを起動して監視する。レーンの配布の依頼は他のレーンを待たずにすぐ本番の配布になる。中身は承認資料を作る時点の `develop` のコミットで決まり、範囲に入った他のレーンの変更も 1 回の承認ゲート 2 で出る + +## 適用範囲 + +- **働く範囲**: NDF を導入したすべてのリポジトリ。起動まで行うのは Claude Code をラッパー(利用者の `claude` を包んで常駐し、セッションの切り替えと次のセッションの起動を行う `relay.py`)の下で動かし、tmux があるときだけである。それ以外は、レーンを手で起動するコマンドを示して終わる +- **プロジェクトごとに違うもの**: レーンの構成(名前・最初の入力・触る場所・プランの本数・依存)は並列の計画に書く。同時に動かせるラッパーの数は環境変数 `NDF_RELAY_MAX_NAMED` で受ける。本番の配布の手段は今の配布のテンプレート(`.ndf/release.json`)のまま +- **1 台の中に閉じる**: まとめるのは 1 台の中のレーンだけである。Neural Development Factory の実行をまとめるのは、同じ層の別の実装である Factory のオーガナイザー(#2439)で、2 つは排他である(Factory の実行ジョブの中では `parallel.py` が断る)。この設計は依頼と答えの形を契約として公開する(決定 21) +- **当たるモード**: オーガナイザーはモードを持たない。レーンの中のスプリントは、今どおりモードを判定する + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 依頼の原文(#2436 の「依頼(原文)」。2026-11-02) | 利用者の指示の原文 | 事前に計画を立て、レーンの数だけ引継ぎ文書を起こし、`/goal / <指示>` の 1 行でオーガナイザーが起動を担う流れ | +| 受け入れ条件 6・6a・6b の改訂(2026-11-02) | 利用者の指示の原文 | 配布のために他のレーンを待たない。中身は承認資料を作る時点の `develop` のコミットで決まり、承認ゲート 2 はその範囲を承認する。答えの時点で `develop` が進んでいれば 1 回だけ出し直す。`main` へは承認したコミットと版上げだけを出し、`develop` は止めない。レーンは配布の答えを待たずに次の周回へ進む | +| 受け入れ条件 6c〜6f の改訂(2026-11-02) | 利用者の指示の原文 | 承認待ちはできるだけまとめ、待つ間レーンは別の作業へ進む。承認ゲートの外の問いも集める。差し戻しの後の直しはオーガナイザーに積まない。答えの後の処理は持ち主のレーンへ、共有の作業はその時だけの一時レーンへ(4 本目を許す) | +| 人の答え待ちと本番のプランの所要(`.ndf/loop/*/loop.json` の `waiting`、`.ndf/loop/l20261031/lap-2`・`lap-3` の done) | 実測 | 人の答え待ちは 10 分〜40 時間、本番のプランは 855〜1,406 秒。承認待ちの間に他のレーンのマージが入る見込みは高い(決定 14) | +| Pull Request 2420 を閉じた件(伝言 `.ndf/handoff/notice-to-lroot2.md`) | 実測 | 本番の配布は `develop` の先頭を `main` へ出す。先に出したレーンが、`develop` にある他のレーンの変更も出す | +| 同時に流すプランの合計 6 本(2026-09-25 の実測。8 コア・30 GB) | 実測 | 並列の計画のプランの本数の合計の上限の既定値。マシンごとに違うため、環境変数 `NDF_PARALLEL_MAX_PLANS` で変えられる(2026-11-03 の承認ゲート 1 で利用者が決めた) | +| DBOS の試行(2026-11-02。下の「既存の部品で作る部分」) | 実測 | 1 つのプロセスの耐久ワークフローが待つメッセージを、サーバー無しで別のプロセスから送り、答えのイベントを読める | + +要求と受け入れ条件は #2436 の本文にある(コピーは `issues/issue-2436-requirements.md`)。この文書は「どう作るか」だけを扱う。 +決定の記録は [issue-2436-design-decisions.md](issue-2436-design-decisions.md) に分けた。部分の設計は次の 5 つに分けた。 + +| 文書 | 扱う部分 | +| --- | --- | +| [issue-2436-design-release-rule.md](issue-2436-design-release-rule.md) | 本番チャネルへの Pull Request の規則の改定(受け入れ条件 6b) | +| [issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md) | レーンのループが答えを待たずに次の周回へ進み、答えの後の処理を周回の境で行う(受け入れ条件 6a・6d〜6f) | +| [issue-2436-design-approvals.md](issue-2436-design-approvals.md) | 問いをオーガナイザーへ集め、作業を持ち主のレーンと一時レーンへ渡す(受け入れ条件 6c〜6f) | +| [issue-2436-design-failures.md](issue-2436-design-failures.md) | 一時レーンの失敗と異常系(受け入れ条件 6g・6h) | +| [issue-2436-design-hierarchy.md](issue-2436-design-hierarchy.md) | 全体の階層(論理の階層と置き場)。オーガナイザーとレーンが、どの層を担うか | + +### 既存の部品で作る部分 + +並列の進行の状態・依頼と答え・待ち・打ち直しからの再開は、自分で書かずに既存のパッケージで行う。比較と採否は決定 12 にある。 + +| 必要なこと | 使う部品 | 依存のグループ | +| --- | --- | --- | +| 進行の状態を残し、落ちた後の打ち直しで続ける | DBOS Transact 3.1.0 の耐久ワークフローと耐久ステップ(包みは `plugins/ndf/scripts/lib/durable.py`。#1142) | `durable`(既存) | +| レーンからの依頼を受け、答えを返す | DBOS のメッセージ(`send` / `recv`)とイベント(`set_event` / `get_event`)。外のプロセスからは `DBOSClient` で送る・読む | `durable`(既存) | +| 並列の計画の形の検査 | pydantic 2.13.5 のモデル | `schema`(既存) | +| 依存の循環の検出 | 標準ライブラリの `graphlib.TopologicalSorter`(`CycleError`) | — | +| 配布の中身の Pull Request の算出 | 今の `release-steps.py approval-facts`(前のタグから承認したコミットまでの範囲の Pull Request。`release_lib/included.py`、#1274) | — | +| 承認したコミットと出すコミットの一致 | 今の `release-steps.py release --channel prod` の比較(承認の外のコミットが入ると、承認ゲートを表す終了コード 10 で止まる。`lib/approved_commit.py`、#815) | — | + +試行(`org.py`・`lane.py`)では、1 つのプロセスが SQLite のシステムデータベースで耐久ワークフローを動かし、`DBOS.recv("release")` で待った。別のプロセス 2 本が `DBOSClient(system_database_url="sqlite:///...")` で `send(wid, msg, topic="release", idempotency_key=...)` を打ち、`client.get_event(wid, "answer-<レーン>", timeout_seconds=30)` で答えを受け取った。サーバーは使っていない。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| 開発の工程(`ndf-workflow`) | 並列の計画・レーン・オーガナイザー・配布の依頼 | +| 統括役(`ndf-relay`) | ラッパー・統括役・名前つきのラッパー・同時に動かせる数 | +| 配布(`ndf-release`) | 本番の配布・承認ゲート 2・版・承認したコミット | +| 耐久の記録(#1142) | 耐久ワークフロー・耐久ステップ・止まり・続き・実行の鍵 | + +開発の工程と統括役は**公開された言語**の関係にする。開発の工程は統括役の CLI(`relay.py spawn`・`ls`・`stop`・`role`)を子プロセスで打ち、統一 JSON レポートだけを読む。統括役は並列の計画(`plan.json`)を読まない。`relay.py role`・`spawn --role helper` が受ける計画名は、役割のファイルへ書いて置くだけの値で、統括役はその中身を解釈しない(読むのは問いの hook とレーンの側)。統括役が数え方(I13)に使うのは役割の名前だけである。配布へは、今のスプリントと同じく本番のプラン(`plan_mvv_release` が作るもの)を渡す。耐久の記録は**公開ホストサービス**で、開発の工程は `durable.py` の関数だけを呼び、DBOS を直接 import しない(#1142 の I16)。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 並列の計画 | `parallel.py init`(作る)。人と AI は起動の前だけ手で直してよい | 並列の計画(計画名) | レーン | 最初の入力・触る場所・プランの本数・依存 | +| 並列の進行 | 耐久ワークフロー `parallel-<計画名>`(`parallel.py run` が動かす。中身を書くのは耐久ワークフローだけ) | 並列の進行(計画名) | レーンの進行・まとめた配布・提示 | 起動の結果・提示したコミット・範囲のレーン・配布の結果 | +| 配布の依頼 | レーン(`parallel.py request` が送るメッセージ) | 配布の依頼(`<レーン名>-<連番>`) | — | 出す Pull Request・課題・依頼した時刻 | +| ラッパーの役割 | 統括役の CLI(`relay.py role`) | 作業ディレクトリの役割 | — | 役割の名前・計画名 | + +まとめた配布は 1 つか 2 つの**提示**を持つ。提示は、承認ゲート 2 の資料を作った時点の `develop` のコミット(提示したコミット)と、その範囲 `main..<提示したコミット>` に入るレーンの一覧である。承認の答えの時点で `develop` が進んでいれば、同じまとめた配布に 2 つ目の提示を加える。出し直しへの答えでは比べないため、提示は 2 つで終わる(I14)。 + +並列の進行は、配布の依頼を識別子でだけ参照する。依頼への答え(配布の結果)は並列の進行のイベントに置き、依頼のメッセージを書き換えない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 並列の計画 | レーン名は統括役の名前の形(`^[a-z0-9][a-z0-9_-]{0,31}$`)に合い、計画の中で重ならず、一時レーンの名前の形(`-rel` と数字で終わる)に当たらない。計画名は 24 字まで(`^[a-z0-9][a-z0-9_-]{0,23}$`)。一時レーンの名前 `<計画名>-rel<連番>`(連番は 1〜9999)が 32 字に収まり、通常レーンと重ならないようにするため | 検査が誤りとして返す(終了コード 1) | +| I2 | 並列の計画 | レーンの数は同時に動かせる数の上限以下、プランの本数の合計は `NDF_PARALLEL_MAX_PLANS`(既定 6)以下 | 検査が誤りとして返す。値が正の整数でなければ既定の 6 を使い、そのことを検査の結果に書く | +| I3 | 並列の計画 | 依存先は同じ計画のレーンで、依存は循環しない | 検査が誤りとして返す | +| I4 | 並列の計画 | 触る場所が重なるレーンの組は警告に留める | 検査が警告として返す(終了コード 0) | +| I19 | 並列の計画 | レーンの間で課題とマイルストーンが重ならない(`--milestones` の無いループのレーンは、ほかのレーンと重なるものとする) | 検査が誤りとして返す([issue-2436-design-failures.md](issue-2436-design-failures.md) の異常系 7) | +| I5 | 並列の計画 | 作る前から在る引継ぎ文書を上書きしない | `init` が 1 つも書かずに止まり、名前を返す | +| I6 | 並列の進行 | 検査に誤りがある計画では、レーンを 1 本も起動しない | `run` が耐久ワークフローを始めずに誤りを返す | +| I7 | 並列の進行 | 1 本のレーンは 1 つの計画の進行の中で 1 回だけ起動する(打ち直した `run` も二重に起動しない) | 起動は耐久ステップで、成功の記録(`started`)があるレーンは対象から外す。統括役に断られた記録だけのレーンは外さない | +| I8 | 並列の進行 | 依存のあるレーンは、依存先のレーンがすべて止まってから起動する | 依存先が止まるまで起動の対象から外す | +| I9 | 並列の進行 | 流れている本番の配布は 1 つの計画につき 1 本まで。依頼が届いた時点で流れている配布が無ければ、他のレーンを待たずに直ちに配布を組む(まとめるための待ち時間を置かない)。配布の中身は依頼の集合ではなく、提示したコミットの範囲 `main..` で決まる | 流れている間に届いた依頼は、範囲に入っていればその配布の答えを待ち、入っていなければ次の配布へ回る。次の配布は今の配布が終わった直後に組む(配布は 1 本ずつ直列) | +| I10 | 並列の進行 | 配布の依頼 1 件には最終の答え(`released` / `rejected` / `failed`)を 1 回だけ返す。次の配布へ回す知らせ(`deferred`)は答えではなく、別のイベント `defer-<依頼>` で返す | 答えは耐久ワークフローの中で立てるイベント `answer-<依頼>` で、打ち直しは記録から同じ値を返す。`defer-<依頼>` は知らせのたびに上書きしてよく、`answer-<依頼>` を立てた後は読まない | +| I11 | 並列の進行 | 耐久ワークフローが終わるのは、全レーンが止まり、答えていない依頼が無いときか、停止の指示を受けたときだけ | 条件を満たすまで終わらない | +| I12 | 配布の依頼 | 動いている並列の進行(耐久ワークフローが終わっていない)に属さないレーンは依頼を送らず、今の本番のプランを流す | 計画に属さないと判定し、今の本番のプランを組む | +| I13 | ラッパーの役割 | 同時に動かせる数から外すのは、オーガナイザーの印を持つ動いているラッパーのうち 1 本だけ。役割 `helper` の印を持つ動いているラッパーは 1 本だけ、上限を 1 本超えて許す | 2 本目以降のオーガナイザーの印は数に入れる。2 本目の `helper` は断る | +| I14 | 並列の進行 | 出すのは確定したコミット(承認したコミット)と版上げのコミットだけである。承認資料に載っていない変更を承認したことにしない | 承認の答えの時点で `develop` の先頭が提示したコミットから進んでいれば、新しい先頭で承認資料を作り直し、加わった範囲を添えて同じ承認ゲート 2 を 1 回だけ出し直す(「新しい範囲で出す」「最初の範囲だけ出す」「差し戻す」)。確定の後に `develop` へ入った変更は次の配布 | +| I15 | 並列の進行 | 範囲に入ったレーンは、依頼していなくてもその配布の結果を受ける。自分の Pull Request がすでに `main` にあるレーンは、依頼を送らずに `released` で続きへ進む | 配布ごとに範囲のイベント `range-<連番>` を立て、`request` が送る前に読む | +| I16 | 配布 | 本番チャネル(`main`)へ出すコミットは、すべて `develop` に含まれる。本番の Pull Request の head は `develop` か、先頭が `origin/develop` の祖先の `release/v<版>` | `scripts/check-pr-base.sh` が落とす([issue-2436-design-release-rule.md](issue-2436-design-release-rule.md)) | +| I17 | 並列の進行 | 並列の計画の下で、区分つきの問い(`ndf-gate:*`・`ndf-ask:*`)を出すのはオーガナイザーだけである。レーンと一時レーンは問いで止まらず、待たずに別の作業へ進む | レーンは設計の承認の依頼(`kind: gate1`)・承認ゲート 2 の依頼(`kind: gate2`)・問いの依頼(`kind: ask`。問いの hook が送る)を送る([issue-2436-design-approvals.md](issue-2436-design-approvals.md)) | +| I18 | 並列の進行 | オーガナイザーは作業を持たない。答えの後の処理は持ち主のレーンが、持ち主のいない共有の作業は一時レーンが行う。一時レーンは同時に 1 本まで | 一時レーンの 2 本目は統括役が断る。オーガナイザーの `answer` は答えのイベントだけを立てる | + +### ドメインイベント + +E1〜E13 の番号と意味は要求([issue-2436-requirements.md](issue-2436-requirements.md))と同じである。設計で足したイベントは、直前の要求のイベントの番号に枝番を付ける(E8a・E9a)。 + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 並列の計画を書いた | `parallel.py init` | 検査(E3) | +| E2 | レーンごとの引継ぎ文書を作った | `parallel.py init` | レーン(E6 で読む) | +| E3 | 並列の計画を検査した | `parallel.py check`(`init`・`run` の中でも打つ) | オーガナイザー(誤りなら始めない) | +| E4 | オーガナイザーが始まった | `parallel.py run`(耐久ワークフローの最初の耐久ステップ) | 統括役(`relay.py role`) | +| E5 | レーンを起動した | 耐久ステップ「起動」 | 統括役(`relay.py spawn`) | +| E6 | レーンが開発を始めた | レーンの conductor | — (状態は E7 で観る) | +| E7 | レーンの状態が変わった | 耐久ステップ「観測」(`relay.py ls --json`) | 耐久ワークフロー。承認待ちになったレーンはイベント `notice` で `parallel.py wait` へ | +| E8 | レーンが配布の依頼を送った | `parallel.py request`(話題 `inbox` のメッセージ) | 耐久ワークフロー | +| E8a | 配布を提示した | 耐久ステップ「配布を組む」(イベント `release`) | オーガナイザーの conductor(`parallel.py wait`) | +| E9 | 承認ゲート 2 を求めた | 本番のプランの承認ゲート 2 の判定 | 利用者(MVV 判定が通せば利用者を経ない) | +| E9a | 承認の答えの時点のコミットを確かめた | `parallel.py confirm`(話題 `resume` のメッセージ) | 耐久ワークフロー(イベント `confirmed` か、1 回だけの出し直しの `release`) | +| E10 | 本番を配布した | 本番のプラン | `parallel.py settle` | +| E11 | 範囲のレーンへ配布の結果を返した | 耐久ワークフロー(`settle` の続きを受けたもの) | レーン(周回の境の `parallel.py await --timeout 0`・`request`) | +| E12 | 計画名を指定して全レーンを止めた | `parallel.py stop` | 統括役(`relay.py stop <名前>`)・耐久ワークフロー | +| E13 | オーガナイザーが終わった | 耐久ワークフローの終わり | オーガナイザーの conductor(要約を出して終える) | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 並列の計画 | 1 つのリポジトリで同時に進めるレーンの一覧と、レーンに共通の決まりを書いた 1 つの文書。オーガナイザーが読む。名前(計画名)を持つ | 追加(要求の工程で反映済み) | +| レーン | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ | 追加(要求の工程で反映済み) | +| オーガナイザー | `--parallel <計画名>` で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別 | 追加(要求の工程で反映済み) | +| 配布の依頼 | レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件の依頼 | 意味を直す(ファイルからメッセージへ) | + +並列の進行・まとめた配布・提示・提示したコミット・ラッパーの役割は、この設計の中だけで使う構成要素の名前で、用語集へは足さない。 + +この文書が前提にする既存の語は次のとおりで、正本は用語集([docs/glossary.md](../docs/glossary.md))にある。 + +| 既存の語 | 意味 | +| --- | --- | +| ラッパー | 利用者の `claude` を包んで常駐し、`ndf-next` のブロックを拾ってセッションの切り替えと次のセッションの起動を行うプロセス(`relay.py`。Claude Code だけ) | +| 統括役 | 同じコンテナで最初に起動したラッパーが兼ねる役。ほかのラッパーの登録・起動・同時に動かす数の判定・一覧だけを持つ。統括役とラッパーは別のプロセスではなく、ラッパーの 1 本が統括役を兼ねる | +| 名前つきのラッパー | 統括役に名前で登録され、tmux の 1 つの window で動くラッパー。レーンはこれで動く | +| 承認ゲート | 人手の承認を求める点。設計 Pull Request のマージ(承認ゲート 1)と、本番へ届く操作(承認ゲート 2)の 2 つだけ | +| MVV 判定 | 承認ゲートの材料が NDF の共通原則・プロジェクト MVV([.ndf/mvv.md](../.ndf/mvv.md))・スプリント MVV に従うかの判定。`pace: auto`・`fast` では「従う」でレッドラインが無いときだけ承認ゲートを省く | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 並列の計画を作り、レーンごとの引継ぎ文書を作る | 利用者(`issue-plan-strategy` を通して conductor) | +| F2 | 並列の計画を検査する | conductor・オーガナイザー | +| F3 | `--parallel <計画名>` でオーガナイザーを始め、レーンを起動する | 利用者 | +| F4 | レーンの状態の変化と配布の依頼を待つ | オーガナイザー | +| F5 | 依頼が届いたら直ちに配布を組み、承認の答えの時点で `develop` が進んでいれば 1 回だけ出し直し、確定したコミットを出し、結果を範囲のレーンへ返す | オーガナイザー | +| F6 | 本番の配布の代わりに配布の依頼を送り、答えを待たずに次の周回へ進む(すでに配布済みなら依頼しない) | レーン | +| F7 | オーガナイザーを同時に動かせる数から外す | 統括役 | +| F8 | 計画名を指定して全レーンを止める | 利用者 | +| F9 | ラッパーの外・tmux が無いときに、手で起動するコマンドを示す | 利用者 | +| F10 | オーガナイザーを起動し直して記録から続け、全レーンが止まったら要約して終える | オーガナイザー | + +## 構成要素 + +| 要素 | 責務 | 新規 / 変更 | +| --- | --- | --- | +| `plugins/ndf/scripts/parallel.py` | 並列の計画と進行の CLI。副命令を下の部品へ振り分け、統一 JSON レポートを 1 行で返す。先に `deps.require("durable", "schema")` を呼ぶ | 新規 | +| `plugins/ndf/scripts/lib/parallel_plan.py` | 並列の計画の pydantic のモデルと検査(I1〜I4。循環は `graphlib`)・`plan.md` の描画・引継ぎ文書の作成(I5) | 新規 | +| `plugins/ndf/scripts/lib/parallel_run.py` | 耐久ワークフロー `parallel` と耐久ステップ(起動・観測・停止・要約)。副命令 `run`・`wait`・`confirm`・`settle`・`stop` | 新規 | +| `plugins/ndf/scripts/lib/parallel_release.py` | まとめた配布: 提示のプランを作る・範囲の Pull Request をレーンへ対応づける(`present`)・提示したコミットと `develop` の先頭を比べる 1 つの関数(`moved`)・答えを決める(I9・I14・I15) | 新規 | +| `plugins/ndf/scripts/lib/parallel_lane.py` | レーンの側: 自分が属する計画の判定(I12)・すでに配布済みかの判定(I15)・依頼を送る・答えを読む(待たずに読むのと待つのの両方)・プランの本数を返す | 新規 | +| 配布の規則の側(`scripts/check-pr-base.sh`・`.github/workflows/pr-base-guard.yml`・`release-steps.py`・`release_lib/`・`AGENTS.md`・`docs/versioning-and-distribution.md`・`release` の Skill の文書) | 本番の Pull Request の head を `release/v<版>` にし、`release/v<版>` を承認したコミットから切る。並列の計画の外の配布にも効く([issue-2436-design-release-rule.md](issue-2436-design-release-rule.md)) | 変更 | +| ループの側(`lib/sprint_loop*.py`・`sprint-state.py`・`loop.md`) | 周回を「配布待ち」で締めて次の周回へ進み、周回の境で答えの届いた配布待ちの周回の残りを流す([issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md)) | 変更 | +| `plugins/ndf/scripts/lib/durable.py` | 外のプロセスから送る・読む関数(`DBOSClient` で耐久の記録を開く。DBOS を起動しない)を足す | 変更 | +| 問いの側(`hook_lib/ask_guard.py`、`parallel_run.py` の `answer`・`request-gate1`・`request-ask`、`sprint_loop_gate.py` の `move_to_waiting`、`sprint_loop.py` の `loop answer --revise`) | 問いの依頼を受け、まとめて問い、答えを持ち主のレーンへ送る([issue-2436-design-approvals.md](issue-2436-design-approvals.md)) | 変更 | +| `plugins/ndf/scripts/relay_lib/hub.py` | `live()` が、オーガナイザーの印を持つ動いているラッパー 1 本を数から外し、判定が役割 `helper` の 1 本だけ上限を 1 本超えて許す(I13) | 変更 | +| `plugins/ndf/scripts/relay_lib/hub_client.py` | `ls` の行に役割を足し、`metrics` に統括役の有無と tmux の有無を足す。`role` の副命令。`spawn` に `--role helper --plan <計画名>`(起動するラッパーの作業ディレクトリへ役割のファイルを書く) | 変更 | +| `plugins/ndf/scripts/relay_lib/__init__.py` | 副命令 `role` の振り分け | 変更 | +| `plugins/ndf/scripts/supervise_lib/sprint_waves.py` | 配布の依頼のプラン(`plan_release_request`。ステップは `parallel-request` の 1 つ)と、まとめた配布の 2 つのプラン(提示のプランと本番のプラン)を作る | 変更 | +| `plugins/ndf/scripts/supervise_lib/release_templates.py` | 提示のプランが、今の開発版の承認資料のステップ(approval-facts・承認資料の説明の欄・他のプラグインの上げ幅)を版上げ無しで使えるように関数を分ける。提示のプランの承認ゲート 2 として、MVV 判定(`mvv`・`note`)の後に `approve` を置く組み立て関数 `present_gate_steps` を足す(今の本番のプランの、両者が排他の組み立ては変えない)。本番のプランの承認の記録が読む状態ディレクトリを引数で受ける | 変更 | +| `plugins/ndf/scripts/supervise_lib/queue_fill.py` | 差し込み `{queue_prs:<計画名>}` を足す。名前の一致する完了した計画の状態ディレクトリの `work/prs.txt`(空白区切りの Pull Request の番号)で埋め、無ければ埋めずにそのプランを流さない | 変更 | +| `plugins/ndf/scripts/supervise_lib/sprint.py` | 本番のステージを組む 4 か所が、レーンのときだけ配布の依頼のプランを置く(1 つの関数を通す) | 変更 | +| `plugins/ndf/scripts/supervise.py` | `queue` の `--max` を省いたとき、レーンならプランの本数を既定にする | 変更 | +| `issue-plan-strategy` の `SKILL.md` と `references/parallel-plan.md` | 並列の計画を作る手順(F1・F2) | 変更・新規 | +| `development-workflow` の `SKILL.md` と `references/parallel.md` | `--parallel <計画名>` のオーガナイザーの手順(F3〜F5・F8〜F10) | 変更・新規 | +| `development-workflow` の `references/relay.md`・`conductor-entrypoints.md`・`loop.md` | `ndf-spawn` の節から並列の計画へのリンク、`parallel.py` の行、レーンのときの本番の扱いの 1 行 | 変更 | +| `docs/glossary/glossary.json`・`docs/glossary.md` | 4 語の正本へのリンク先を新しい参照へ向け、「配布の依頼」の意味を直す。`docs/glossary.md` は `glossary.json` から `glossary.py render` で作り直す生成物で、手では直さない | 変更 | + +```mermaid +graph TD + subgraph 開発の工程 + P[parallel.py] + PL[parallel_plan] + PR[parallel_run] + RL[parallel_release] + LA[parallel_lane] + SV[supervise の
本番のステージ] + end + subgraph 耐久の記録 + DU[durable.py] + end + subgraph 統括役 + RC[relay.py の CLI] + end + P --> PL + P --> PR + P --> LA + PR --> RL + PR -->|耐久ワークフロー| DU + LA -->|DBOSClient| DU + PR -->|spawn・ls・stop・role| RC + SV -->|レーンか| LA + RL -->|提示と本番のプラン| SV +``` + +Skill の文書(`issue-plan-strategy`・`development-workflow`)と用語集は、スクリプトを呼ぶ手順を書く側で、図には含めない。`relay.py` の中の数え方(`hub`)と `supervise.py` の `queue` の本数・承認ゲート 2 の再開は、上の表のとおりで図からは外した。 + +### 置き場(パッケージ構成) + +```text +plugins/ndf/ +├── scripts/ +│ ├── parallel.py # 新規 +│ ├── lib/ +│ │ ├── parallel_plan.py # 新規 +│ │ ├── parallel_run.py # 新規 +│ │ ├── parallel_release.py # 新規 +│ │ ├── parallel_lane.py # 新規 +│ │ ├── durable.py # 変更 +│ │ └── sprint_loop_gate.py # 変更 +│ ├── relay_lib/{hub,hub_client,__init__}.py # 変更 +│ ├── supervise.py # 変更 +│ ├── supervise_lib/{sprint,sprint_waves,release_templates,queue_fill}.py # 変更 +│ └── tests/test_parallel_*.py # 新規 +└── skills/ + ├── issue-plan-strategy/references/parallel-plan.md # 新規 + └── development-workflow/references/parallel.md # 新規 +``` + +### システムの文脈と配置 + +```mermaid +graph TD + U[利用者の端末] -->|claude| H[統括役を兼ねる
ラッパー] + H --> O[オーガナイザーの
conductor] + H -->|tmux| L1[レーンの
ラッパー] + O -->|parallel.py run| D[耐久の記録
SQLite] + L1 -->|parallel.py request| D + O -->|本番のプラン| G[GitHub] +``` + +オーガナイザーは、利用者が端末で打った `claude` のセッション(多くは統括役を兼ねるラッパー)で動く。レーンは統括役が tmux の中に起動する名前つきのラッパーで動く。どちらも同じコンテナの同じ利用者で動き、同じ耐久の記録(SQLite のファイル 1 つ)と同じリポジトリのメインディレクトリを読み書きする。 + +## データ構造 + +置き場は 2 つある。どちらもコミットしない。 + +| 置き場 | 中身 | +| --- | --- | +| メインディレクトリの `.ndf/parallel/<計画名>/` | 並列の計画(`plan.json`)・人が読む描画(`plan.md`)・まとめた配布のプラン(`releases/`) | +| 耐久の記録 `~/.local/state/ndf/dbos/parallel-<12 字>.sqlite` | 並列の進行(耐久ワークフロー `parallel-<計画名>` の入力・耐久ステップの結果・メッセージ・イベント)。種類は `parallel`、識別は計画の置き場の絶対パス(`durable_keys.record_path`) | + +```mermaid +erDiagram + PLAN ||--|{ LANE : has + PLAN ||--|| PROGRESS : runs + PROGRESS ||--o{ RELEASE : batches + RELEASE ||--|{ PRESENTATION : presents + LANE ||--o{ REQUEST : sends + RELEASE ||--o{ REQUEST : answers +``` + +| ファイル・記録 | 中身 | 書き手 | 読み手 | +| --- | --- | --- | --- | +| `plan.json` | 並列の計画の正本 | `parallel.py init`(起動の前は人と AI も直してよい) | 全員 | +| `plan.md` | `plan.json` と並列の進行を人が読む形にした描画(レーンの表・決まり・配布の内訳) | 耐久ステップ(状態が変わるたび)・`init` | 人・レーンの conductor | +| `releases/<連番>/<提示>/present.json`・`release.json` | まとめた配布の 1 つの提示の 2 つのプラン(提示のプランと本番のプラン)。状態ディレクトリはそれぞれのプランの横にできる | 耐久ステップ「配布を組む」 | `supervise.py queue` | +| `releases/<連番>/<提示>/presented.json` | 提示したコミット・範囲の Pull Request・範囲のレーン(依頼済みの印つき)・出し直しなら加わった範囲 | `parallel.py present`(提示のプランのステップ) | 耐久ステップ・`plan.md` の描画 | +| 耐久ワークフロー `parallel-<計画名>` | 並列の進行(下の表) | 耐久ワークフローだけ | `parallel.py wait`・`await`・`request`(イベントを読む) | +| レーンのプランの `{state_dir}/work/request.txt` | 送った依頼の識別子か、すでに配布済みの結果 | `parallel.py request` | ループの周回の境・スプリントのレーンの conductor(`parallel.py await --from`) | + +### `plan.json` + +pydantic のモデル `ParallelPlan` が正本の形を持つ。最初の入力は `kind` で分ける判別つきの共用体である。 + +| 項目 | 型 | 必須 | 意味 | +| --- | --- | --- | --- | +| `version` | 整数 | ○ | 形の版。`1` | +| `name` | 文字列 | ○ | 計画名(I1) | +| `rules` | 文字列の配列 | ○ | レーンに共通の決まり(取り込みの衝突の解き方など)。空でもよい | +| `lanes[].name` | 文字列 | ○ | レーン名。ラッパーの名前と同じ(I1) | +| `lanes[].input.kind` | `loop` / `sprint` | ○ | 最初の入力の種類 | +| `lanes[].input.loop` | 文字列 | `loop` のとき | ループ名 | +| `lanes[].input.issues` | 整数の配列 | `sprint` のとき | スプリントの課題 | +| `lanes[].input.args` | 文字列の配列 | — | `--milestones` などの足す引数 | +| `lanes[].plans` | 整数(1 以上) | ○ | 同時に流すプランの本数(I2) | +| `lanes[].touches` | 文字列の配列 | ○ | 触る場所(パスか glob) | +| `lanes[].after` | 文字列の配列 | — | 依存先のレーン名(I3) | +| `lanes[].handoff` | 文字列 | `init` が書く | 引継ぎ文書の名(ループなら `loop-<ループ名>`、スプリントなら `issue-<最初の番号>`) | + +検査の誤りは pydantic の検証の誤りとして集める。I1〜I3 の検証は誤りの種類(`PydanticCustomError` の type)に `I1`〜`I3` を持ち、`check` はそれを `items[].rule` に写す。上限の値(`NDF_RELAY_MAX_NAMED`)は検証の文脈(`context`)で渡す。I4 の重なりは誤りにせず、モデルの検証の後に別に数える。 + +### 並列の進行(耐久ワークフローの記録) + +並列の進行は耐久ワークフローの中の値で、打ち直すと記録から同じ値に戻る。外へ見せるのはメッセージ・イベント・出力だけである。 + +| 種類 | 名前 | 送り手・書き手 | 受け手・読み手 | 中身 | +| --- | --- | --- | --- | --- | +| メッセージ | 話題 `inbox` | `request`・`request-gate1`・`request-ask`・一時レーン(`gate2`)・`stop` | 耐久ワークフロー | `{kind: request, id, lane, prs, issues, sprint_state, at}`・`{kind: gate1 / gate2 / ask, ...}`([issue-2436-design-approvals.md](issue-2436-design-approvals.md))・`{kind: stop}`。冪等の鍵は `:inbox:<依頼>` と `:inbox:stop` | +| メッセージ | 話題 `resume` | `confirm`・`settle`(`durable.resume`) | 止まっている耐久ワークフロー | `{seq, phase: confirm, take}` か `{seq, phase: settle, result, version, reason}` | +| イベント | `pause` | 耐久ワークフロー | `wait` | `{seq, action, ...}`。`action` は `running` / `notice` / `release` / `confirmed`。`wait` が返すのは `notice`・`release`・`confirmed` | +| イベント | `answer-<依頼>` | 耐久ワークフロー | `await` | `{result, version, release, reason, at}`。`result` は `released` / `rejected` / `failed` の最終の答えだけ(I10) | +| イベント | `defer-<依頼>` | 耐久ワークフロー | `await` | `{seq, reason, at}`。差し戻しの宛先でない依頼を次の配布へ回した知らせ。依頼は `pending` に残る(I10) | +| イベント | `range-<連番>` | 耐久ワークフロー | `request` | 出した配布の `{sha, version, prs, lanes}`(I15) | +| イベント | `lane-<レーン名>` | 耐久ワークフロー | オーガナイザーの状態の表示 | `{received, gate1_received, ask_received}`(受け取った依頼の数)。状態の表示にだけ使い、依頼の `id` の採番には使わない | +| イベント | `questions` | 耐久ワークフロー | `wait` | `{seq, items}`。答えていない問いの依頼 | +| イベント | `fix-<レーン名>-<連番>` | 耐久ワークフロー | 宛先のレーン(周回の境) | 配布の差し戻しの指摘 | +| 出力 | 耐久ワークフローの戻り値 | 耐久ワークフロー | `wait` | `{status: finished / stopped, lanes, releases}`(要約) | + +耐久ワークフローが中に持つ値は次のとおりである。 + +| 値 | 意味 | +| --- | --- | +| `lanes.<名>` | 起動の結果(`started` か統括役の断りの理由)・最後に観た状態・止まった時刻 | +| `pending` | 受け取ってまだ答えていない依頼 | +| `releases[]` | まとめた配布: 連番・提示の一覧(1 つか 2 つ。提示したコミット・範囲のレーン)・確定したコミット・状態(`presented` / `confirmed` / `released` / `rejected` / `failed`)・出した版 | +| `stop` | 停止の指示を受けたか | + +### 機能 × データ + +| 機能 | `plan.json` | 並列の進行 | 依頼 | 答え・範囲 | +| --- | --- | --- | --- | --- | +| F1 計画を作る | C | — | — | — | +| F2 検査 | R | — | — | — | +| F3 始める・起動 | R | C・U | — | — | +| F4 待つ | R | R | R | — | +| F5 まとめて配布 | R | U | R | C | +| F6 依頼と待ち(レーン) | R | R | C | R | +| F8 止める | R | U | C(`stop`) | — | +| F10 再開・要約 | R | R・U | R | R | + +## 入出力の契約 + +どの副命令も、結果は統一 JSON レポート(`tool`・`status`・`summary`・`items`・`metrics`)を 1 行で標準出力へ出す。終了コード 10 は、プランのステップが承認ゲートとして止まったことを表す(10〜19 を承認ゲートとして扱う今の決まり。`plugins/ndf/scripts/supervise_lib/plan.py` の「ステップの遷移」)。`request` が依頼を送って 10 で終わると、配布の依頼のプランは承認ゲートとして終わる。`<計画名>` の置き場は、カレントが worktree の中でもメインディレクトリに解決する。耐久ワークフローの ID は `parallel-<計画名>` で、計画名から決まる。 + +| 名前 | 入力 | 成功 | 失敗の形 | +| --- | --- | --- | --- | +| `parallel.py init <計画名>` | 標準入力に `plan.json` の下書き(`handoff` を除く) | 0。`plan.json`・`plan.md`・引継ぎ文書を書き、パスを `items` に返す | 1: 検査の誤り・在る引継ぎ文書(名前を返し、何も書かない)・在る計画名。2: 読めない・書けない | +| `parallel.py check <計画名>` | — | 0。警告は `items`(`level: warning`)に並べる | 1: 誤りを `items`(`level: error`、`rule` に I1〜I3 の種類)に並べる。2: 計画が無い・読めない | +| `parallel.py start <計画名>` | 環境変数 `NDF_RELAY_DIR` | 0。`run` をセッションから切り離して起動する(動いていれば今の pid を返す)。`metrics.next` に `wait` の 1 行 | 1: 別のセッションが持ち主として動いている(持ち主を返す)。ほかは `run` と同じ | +| `parallel.py run <計画名>` | 環境変数 `NDF_RELAY_DIR` | 0。耐久の記録を開いて耐久ワークフローを始めるか続け、終わるまで動き続ける長命のプロセス。終われば要約を返す。再開なら `metrics.resumed: true` | 1: 計画が無い(置き場を返す)・検査の誤り(耐久ワークフローを始めない)。2: 耐久の記録を開けない・形式の版が違う。3: ラッパーの外・tmux が無い(耐久ワークフローを始めず、`items` にレーンごとの手で起動する 1 行、`status: manual`)。5: 同じ計画の `run` が動いている(`metrics.next` に `wait` の 1 行) | +| `parallel.py wait <計画名> [--after ] [--timeout 秒]` | — | 0: イベント `pause` の `seq` が `--after` を超えて `action` が `notice` / `release` / `confirmed` になったか、イベント `questions` の `seq` が進んだか、耐久ワークフローが終わった(`metrics.action` が `finished` / `stopped`、`items` にレーンごとの結果)。`metrics.seq` を返す | 2: 耐久の記録が無い。3: 上限に達した(既定 3600 秒。同じ `wait` を打ち直す) | +| `parallel.py present <計画名> <連番> <提示> --approval <承認資料> --out <パス>` | 提示のプランの approval-facts が書いた承認資料 | 0。承認資料の承認したコミットと含む Pull Request を読み、レーンへ対応づけて `presented.json` を書き、範囲の Pull Request を `--out` へ空白区切りで書く。承認資料へレーンの内訳の節を足し、出し直しの提示なら加わった範囲(レーンと Pull Request)を載せる | 1: 承認資料に承認したコミットが無い。2: 読めない | +| `parallel.py confirm <計画名> <連番> [--take new / first]` | 承認ゲート 2 の答え(承認)。`--take` は出し直した提示への答えだけが持つ | 0。止まり(`action: release`)へ `phase: confirm` を送る | 1: その連番の配布が提示の止まりに無い・出し直した提示に `--take` が無い | +| `parallel.py settle <計画名> <連番> --done ` / `--rejected` | 本番のプランの done | 0: 止まりへ `phase: settle` を送る。`metrics.result` は `released` / `rejected` / `failed` | 4: 本番のプランが承認ゲートで止まっている(送らない)。1: その連番の配布が流れていない | +| `parallel.py stop <計画名>` | — | 0。`stop` を送り、各レーンへ `relay.py stop <名前>` を打つ。`run` が動いていなければ耐久の記録を開き、耐久ワークフローの終わりまで流す | 1: 止まらないレーンがある(名前を返す) | +| `parallel.py request <計画名> --lane <名> --prs <番号>... --issues <番号>... --state <パス> --out <パス>` | — | 0: `--prs` のマージのコミットがすべて `origin/main` にあり、送らずに `metrics.result: released`(版は範囲のイベントから)。10: 依頼を送った(配布待ち。`metrics.id`)。どちらも `--out` へ書く | 1: 計画が動いていない | +| `parallel.py await <計画名> --from <パス> [--timeout 秒]` | `request` の `--out` | 0: `released`(`metrics.version`)。`--timeout 0` は待たずに読む。返るのは `answer-<依頼>` が立ったときだけで、知らせ `defer-<依頼>` では返らず `answer-<依頼>` を待ち続ける(I10) | 10: `rejected`。1: `failed`。3: 上限に達した(`--timeout 0` なら答えがまだ無い)。どちらも `defer-<依頼>` が立っていれば `metrics.deferred` に知らせの `{seq, reason}` を載せ、無ければ `null` | +| `relay.py role organizer --plan <計画名>` / `relay.py role --clear` | `NDF_RELAY_DIR` | 0。作業ディレクトリへ役割のファイルを書く・消す | 1: ラッパーの外(`NDF_RELAY_DIR` が無い) | +| `relay.py spawn --role helper --plan <計画名>`(変更) | `NDF_RELAY_DIR` | 0。起動したラッパーの作業ディレクトリへ役割のファイルを書く | 今の断りに加え、動いている `helper` があれば断る | +| `relay.py ls --json`(変更) | — | 行に `role`、`metrics` に `hub`・`tmux` を足す | 今のまま | + +**`durable.py` に足す関数**(外のプロセス用。どれも DBOS を起動せず、`DBOSClient` で耐久の記録のファイルを開く): + +| 関数 | 中身 | +| --- | --- | +| `client(kind, identity)` | `record_path(kind, identity)` の SQLite を開いた `DBOSClient`。ファイルが無ければ `DurableError` | +| `send_to(kind, identity, wid, msg, topic, key)` | `DBOSClient.send`(冪等の鍵つき) | +| `event_of(kind, identity, wid, key, timeout)` | `DBOSClient.get_event`。`timeout` 秒待ち、無ければ None | +| `status_of(kind, identity, wid)` | `DBOSClient.retrieve_workflow` の状態(無ければ None) | +| `wait_from(kind, identity, wid, keys, after, timeout)` | `durable.wait` と同じ判定(終わりか、イベントの `seq` が `after` を超える)を外から行う。読むイベントは複数(`pause` と `questions`) | + +**`durable.py` に足す関数**(耐久ワークフローの中用): `recv(topic, timeout)` と `set_event(key, value)`。止まりの間に 2 つの話題を交互に読むために使う。 + +**互換性**: 既存の副命令・引数・`ls` の既存の項目は変えない。足すのは項目だけである。本番の配布の規則の改定(受け入れ条件 6b)は並列の計画の外の配布にも効き、承認の後に `develop` が進んでも止まらずに承認したコミットを出す([issue-2436-design-release-rule.md](issue-2436-design-release-rule.md) の「互換性」)。`queue` の `--max` を省いたときの既定は、レーンでなければ今どおり 3 である。並列の計画に属さないループとスプリントの本番のプランは変わらない(I12)。`durable.py` の既存の関数は変えない。 + +### オーガナイザーの起動のコマンド + +```text +/goal /ndf:development-workflow --parallel <計画名> -- 終わり: [ndf-parallel] finished <計画名> の行を出したとき +``` + +`--parallel` があれば、`development-workflow` は判定の手順へ進まず `references/parallel.md` の手順で進む(`--loop` と同じ形)。再開のコマンドも同じ 1 行である。 + +### レーンの最初の入力 + +耐久ステップ「起動」は `relay.py spawn --name <レーン名> --json -- <最初の入力>` を、メインディレクトリをカレントにして打つ。最初の入力は次の 1 行で、レーンの conductor は引継ぎ文書の「次にやること」の 1 つ目から始める(`handoff.md` の「読む」)。 + +```text +/goal /ndf:development-workflow .ndf/handoff/<引継ぎ文書の名>.md の続きから +``` + +`init` が作る引継ぎ文書は、「進め方(利用者の指示)」に計画名・レーン名・`plan.md` のパスを書き、決まりは写さない。「次にやること」の 1 つ目と「次に実行するコマンド」には、レーンの開始のコマンドを書く。 + +| 最初の入力の種類 | レーンの開始のコマンド | +| --- | --- | +| ループ | `/goal /ndf:development-workflow --loop <ループ名> -- 終わり: [ndf-loop] stopped <ループ名> の行を出したとき` | +| スプリント | `/goal /ndf:development-workflow #<課題>...` | + +## 処理の流れ + +### オーガナイザーの conductor の手順 + +conductor は `parallel.py start` で `run` をセッションから切り離して 1 本起動し、`wait` を背景で打って完了の通知を待つ。`run` は耐久ワークフローが終わるまで動き続け、止まりの間もメッセージをすぐに読む。返った `action` ごとに次の 1 手を打つ。オーガナイザーは作業を持たない。打つのは問いと答え・一時レーンの起動・終わったレーンを起こす 1 行だけで、起動・観測・まとめは耐久ワークフローが、配布のプランの実行は一時レーンが、答えの後の処理は持ち主のレーンが行う(I17・I18)。 + +| `wait` が返したもの | conductor がすること | +| --- | --- | +| `notice` | 承認待ちのレーンの `relay.py attach <名前>` の 1 行を出し、`wait --after ` を打ち直す | +| `questions` | 届いている依頼(設計の承認・承認ゲート 2・承認ゲートの外の問い)を区分ごとにまとめて問い、依頼ごとに `parallel.py answer` か `confirm` / `settle --rejected` を打つ([issue-2436-design-approvals.md](issue-2436-design-approvals.md))。`metrics.next` に `relay.py restart <レーン>` があれば打つ | +| `release` / `confirmed` | `metrics.spawn` の 1 行(一時レーンの起動)を打つ。一時レーンが配布のプランを流し、結果を耐久ワークフローへ返す | +| `finished` / `stopped` | レーンごとの結果を要約し、`[ndf-parallel] finished <計画名>` の行を出して終える | +| 終了コード 3 | 同じ `wait` を打ち直す | + +承認ゲート 2 の問いは、一時レーンが本番のプランの承認ゲート 2 の判定で止まったときに `kind: gate2` の依頼として届く。提示 1 なら「承認」「差し戻す(宛先のレーンを選ぶ)」で、出し直しの提示 2(`represent: true`)なら加わった範囲を添えて「新しい範囲で出す」「最初の範囲だけ出す」「差し戻す」で問い、`confirm <計画名> `・`confirm --take new` / `--take first`・`settle --rejected [--to <レーン>...]` を打つ。 + +`run` のプロセスが落ちたとき・オーガナイザーのセッションが切り替わったときは、`start` を打ち直す([issue-2436-design-failures.md](issue-2436-design-failures.md) の異常系 1)。耐久ワークフローは記録から続き、プロセスの居ない間に届いたメッセージを読む。 + +```mermaid +sequenceDiagram + participant O as オーガナイザー + participant W as 耐久ワークフロー + participant L as レーン + participant H as 一時レーン + L->>W: request(inbox)。レーンは次の周回へ + W-->>O: release(提示 1) + O->>H: spawn --role helper + H->>W: 承認ゲート 2 で止まった(gate2 の依頼) + W-->>O: questions + O->>W: confirm(承認の答え) + W-->>O: confirmed か出し直しの release + O->>H: spawn(続きから) + H->>H: 答えを記録し、提示のプランを approve から完了させ、本番のプランを流す + H->>W: settle(配布の結果) + W-->>L: answer・range +``` + +### 一時レーン + +持ち主のいない共有の作業(まとめた配布のプランの実行、その失敗の対応、`release/v<版>-sync` の版数の衝突の解消)は、オーガナイザーが起こす一時レーンが行う。 + +| 点 | 決まり | +| --- | --- | +| 起動 | `relay.py spawn --name <計画名>-rel<連番> --role helper --plan <計画名> --json -- <最初の入力>`。名前は I1 により 32 字に収まり(計画名 24 字・`-rel` 4 字・連番 4 桁まで)、通常レーンの名前と重ならない。連番が 9999 を超える配布は、耐久ステップ「配布を組む」が組まずに止め、要約に理由を書いて人へ戻す。最初の入力は `/goal /ndf:development-workflow --parallel-helper <計画名> <連番> <提示> -- 終わり: [ndf-parallel] helper-done <計画名> の行を出したとき` | +| 仕事 | イベント `release` の後は `metrics.pipeline`(提示のプランと本番のプラン)を流す。MVV 判定が通せば配布まで流し、`settle --done ` を打つ。承認ゲート 2 で止まれば `kind: gate2` の依頼(承認資料・内訳・提示)を送って止まる。イベント `confirmed` の後は、確定した提示の答えを記録し、提示のプランを `approve` のステップから続けて完了させてから、本番のプランを `supervise.py queue releases///release.json --done ` で初めて流し、`settle` を打つ(下の「まとめた配布」の確定の後の 3 手) | +| 失敗したとき | CI の失敗・衝突・版数の衝突は、今の配布のプランの直しの手順(judge・fix)で一時レーンが直す。直せなければ問いとして送り(問いの hook が送る)、答えを待つ間は止まる | +| 終わり | 結果を返したら `[ndf-parallel] helper-done` の行を出して終わる。統括役は止まったラッパーを数えない | +| 数え方 | 同時に 1 本まで。統括役は、役割 `helper` の印を持つ動いているラッパー 1 本だけを `NDF_RELAY_MAX_NAMED` を 1 本超えて許す(I13 と同じ作業ディレクトリの役割のファイル。`hub.py` の `live` と判定)。2 本目の `helper` は断る。同時に流すプランの合計の上限(I2)には数えない | +| レーンとの違い | 一時レーンは並列の計画の `lanes` に載らず、配布の依頼を送らない(`lane_of()` に当たらない)。問いの hook は役割のファイルで一時レーンと分かり、問いをオーガナイザーへ送る | + +### 耐久ワークフロー `parallel` の中身 + +耐久ワークフローの中の分岐は、耐久ステップの結果とメッセージだけで決まる。時刻・外のコマンド・ファイルを読むのはすべて耐久ステップである(打ち直しで同じ道を通るため。#1142 の I18)。 + +1. 計画を読み、検査し、`relay.py role organizer --plan <計画名>` を打つ(耐久ステップ) +2. 停止の指示を受けていれば、要約を出力して終わる(`status: stopped`) +3. 耐久ステップ「起動」: 起動に成功した記録(`started`)の無いレーンのうち、依存先がすべて止まったものを `relay.py spawn` で起動する。統括役が上限で断ったレーンは次の周で起動し直す(I7・I8) +4. 流れている配布が無く、答えていない依頼があれば、耐久ステップ「配布を組む」を通る。依頼のうち、範囲のイベントで答えられるもの(Pull Request がすでに出た配布の範囲にある)は答えて外す。残りがあれば、他のレーンを待たずに新しいまとめた配布(連番 n・提示 1)を組み、イベント `release` を立てて止まりに入る(I9。下の「まとめた配布」) +5. 全レーンが止まり、答えていない依頼が無ければ、役割の印を消して要約を出力して終わる(`status: finished`。I11) +6. `inbox` のメッセージを 60 秒まで待ち(`recv`)、来たものを `pending`・`questions_pending`・停止の指示に入れ、問いの依頼が増えればイベント `questions` を立てる。耐久ステップ「観測」で `relay.py ls --json` を読み、レーンの状態を更新する。承認待ちになったレーンがあればイベント `notice` を立てる。2 へ戻る + +止まりの中では、話題 `resume` の `confirm` と `settle` を 60 秒まで待ち、その合間に `inbox` を読む。問いの依頼はその場で `questions` を立て、配布の依頼は `pending` に入れて止まりを抜けた後に扱う。止まりは `durable.recv` と `durable.set_event` で書く(`durable.pause` は 1 つの話題だけを待つため使わない)。 + +### まとめた配布 + +```mermaid +stateDiagram-v2 + [*] --> 提示: 依頼が届き、流れている配布が無い + 提示 --> 配布中: MVV 判定が通した + 提示 --> 承認待ち: 承認ゲート 2 で止まった + 承認待ち --> 配布中: 答えの時点で develop が同じ + 承認待ち --> 出し直し: develop が進んでいた + 出し直し --> 配布中: 新しい範囲か最初の範囲 + 承認待ち --> 差し戻し: 承認されなかった + 出し直し --> 差し戻し: 差し戻す + 配布中 --> 配布済み + 配布中 --> 失敗 + 配布済み --> [*] + 差し戻し --> [*] + 失敗 --> [*] +``` + +1 つの提示は 2 つのプランを 2 つのステージで流す。`metrics.pipeline` は `supervise.py queue releases///present.json --then releases///release.json --done ` の 1 行である(`` は 1 か、出し直しの 2)。 + +**承認ゲート 2 は提示のプランが持ち、本番のプランは確定の後にだけ流れる。** 本番のプランは、queue がプランを流し始めるときに worktree と `release/v<版>` を作る。承認ゲート 2 を本番のプランに置くと、答えの前に提示 1 の起点で `release/v<版>` ができ、提示 2 の本番のプランが同じ名前のブランチを作り直せない。そのため次の形にする。 + +- 提示のプランの最後に承認ゲート 2 のステップを、MVV 判定(`mvv`・`note`)→ `approve` の順に置く。今の本番のプランでは MVV 判定(`_add_prod_mvv_gate`)と `approve_step` が排他(`plan_release_package_plugin` の `if mvv and not dev` / `elif not dev`)で、`note` の次は `bump` である。提示のプランには両方を同じプランへ並べる新しい組み立て関数(`release_templates.py` の `present_gate_steps`)を足し、`mvv_gate_steps(..., next_id="approve")` に続けて `approve_step` と同じ形の `approve` のステップ(`release-steps.py approve --approval <提示の承認資料> --facts-from <提示のプランの状態ディレクトリ> --answer-from releases///answer.json --by user`。`next` は `end`、`gate_next` は `end`)を置き、`handoff`・`reject` は今の `_add_prod_mvv_gate` と同じに置く。今の本番のプランの組み立ては変えない。承認の記録は提示のプランの状態ディレクトリに残る +- MVV 判定が通せば、判定(`mvv-gate.py`)が承認資料へ承認の記録(`by: mvv`)を書き(今の `approved_commit.record_all`)、`note` の後の `approve` は、承認資料に承認したコミットと一致する記録が既にあるため答えの記録(`answer.json`)を読まずに通す(今の `approve_from_facts` の「記録が既にある」の分岐)。提示のプランが完了し、queue が同じ 1 行の後のステージ(本番のプラン)を続けて流す。答え待ちが無いため提示は 1 つで、ブランチは 1 回だけ作られる +- 承認ゲート 2 で止まれば、提示のプランは承認ゲートとして終わり、queue は本番のプランを流さない。worktree も `release/v<版>` も作られない +- 確定(イベント `confirmed`)の後、一時レーンは確定した提示 `` について次の 3 手を順に打つ。確定しなかった提示のプランは承認ゲートのまま残し、その本番のプランは流さない + 1. 利用者の答えを、提示の答えの記録 `releases///answer.json`(スプリントの状態ファイルの形)へ `sprint-state.py gate releases///answer.json '関門 2' --what <要約> --by user --outcome approved` で書く(今の承認ゲート 2 の答えの書き方。`sprint_routes.py` の `user_gate_cmd`)。このファイルは、耐久ステップ「配布を組む」が提示のプランと同時に `{"gates": []}` で作る + 2. 提示のプランを `supervise.py run releases///present.json --from approve` で続きから流す。MVV 判定の後に置いた `approve` のステップ(上の `present_gate_steps` が置くもの)が、提示の approval-facts の承認したコミットと 1 の記録を照らして承認資料へ承認の記録を書き、提示のプランは完了で終わる。記録が欠ければ承認ゲート(10)で終わり、一時レーンは 3 を打たずに問いとして送る + 3. 本番のプランを `supervise.py queue releases///release.json --done ` で流す。queue はこのときに `{queue_approved:present}`・`{queue_prs:present}` を、2 で完了した同じディレクトリの提示のプランから埋め、確定したコミットから worktree と `release/v<版>` を作る。本番のプランは 2 が承認資料へ書いた承認の記録を読む +- 本番のプランは承認ゲート 2 を持たず、承認の記録を提示のプランの状態ディレクトリから読む(下の 2)。1 つのまとめた配布で流れる本番のプランは 1 つだけで、`release/v<版>` を作るのも 1 回だけである + +1. 前のステージの**提示のプラン**は、今の開発版の承認資料のステップ(`release-steps.py approval-facts`・承認資料の説明の欄・他のプラグインの上げ幅)を版上げ無しで流し、最後に承認ゲート 2 のステップを置く。approval-facts は `origin/develop` の先頭を承認したコミットとし、前のタグからそのコミットまでの範囲の Pull Request を「含む PR」に並べる(今の作り。#1274)。続く `parallel.py present` が承認資料を読み、範囲の Pull Request をレーンへ対応づけて承認資料へ内訳の節を足し、範囲の Pull Request を `work/prs.txt` へ書く +2. 後のステージの**本番のプラン**は `plan_mvv_release(..., version=<出す版>, channel="prod", prs=["{queue_prs:present}"])` で作り、承認の記録が読む状態ディレクトリに提示のプランの状態ディレクトリを渡す。worktree と `release/v<版>` の起点は、確定した提示のプランの approval-facts が承認したコミットにする(項目「起点のコミット」の差し込み `{queue_approved:present}`。版の判定が読む「起点」は `origin/develop` のまま。配布の規則の改定は [issue-2436-design-release-rule.md](issue-2436-design-release-rule.md))。queue はこのステージを流す前に、差し込み `{queue_prs:<計画名>}` を名前の一致する完了した計画の `work/prs.txt` の番号の並びで埋める。`work/prs.txt` が無ければ埋めずに、本番のプランを流さない(#2079 の既存の扱い) + +出す版は、耐久ステップ「配布を組む」が `origin/develop` の `plugin.json` の開発版の版数から接尾辞を外して決め、明示の版としてプランに書く。今の queue の版の差し込みは、明示の版のタグがあれば止める(`fill_release_version`)。 + +**範囲をレーンへ対応づける。** 依頼のあるレーンは、依頼の `prs` が範囲にあるかで決める(依頼済みの印をつける)。依頼の無いレーンは、範囲の Pull Request が閉じる課題(`closingIssuesReferences`)がスプリントのレーンの `input.issues` にあるかで決める。どちらにも当たらない Pull Request は「レーンの無い Pull Request」として内訳に並べる。範囲が配布の中身であり、内訳は承認する人が読むための表示である。 + +**承認の答えを受けたら、1 度だけ比べる。** オーガナイザーの conductor は `confirm` を打つ。耐久ワークフローは耐久ステップで `origin/develop` の先頭を読み、提示したコミットと比べる(`parallel_release.moved()` の 1 つの関数)。出し直しの提示への `confirm` では比べない(I14)。 + +| 場面 | 耐久ワークフローの扱い | 次の 1 手 | +| --- | --- | --- | +| 提示 1 への承認。先頭が同じ | 提示 1 のコミットで確定し、イベント `confirmed`(`plan` に提示 1 の本番のプラン)を立てる | オーガナイザーが一時レーンを起こし、一時レーンが提示 1 の本番のプランを初めて流す | +| 提示 1 への承認。先頭が進んでいる | 同じまとめた配布に提示 2 を組み、イベント `release`(`represent: true`)を立てる。提示 2 のプランが今の承認資料の生成を新しい先頭で呼び直し、`present` が加わった範囲(`<提示 1 のコミット>..<新しいコミット>` の Pull Request とレーン)を載せる | オーガナイザーが一時レーンを起こし、一時レーンが提示 2 のプランを流して承認ゲート 2 の依頼を送る。オーガナイザーは 3 つの選択肢で同じ承認ゲート 2 を出し直す | +| 提示 2 への「新しい範囲で出す」 | 提示 2 のコミットで確定し、`confirmed`(提示 2 の本番のプラン)を立てる | 一時レーンが提示 2 の本番のプランを初めて流す(提示 1 の本番のプランは流さない) | +| 提示 2 への「最初の範囲だけ出す」 | 提示 1 のコミットで確定し、`confirmed`(提示 1 の本番のプラン)を立てる | 一時レーンが提示 1 の本番のプランを初めて流す(提示 2 の本番のプランは流さない) | + +`pace: auto` と `pace: fast` で MVV 判定が承認ゲート 2 を通すときは答え待ちが無く、比べずに提示 1 つで配布になる。本番のプランは、確定したコミットから切った `release/v<版>`(確定したコミットと版を上げたコミット)を `develop` へマージした後、その先頭を `main` へ出す。確定の後に `develop` が進んでも本番のプランは止まらず、確定したコミットを出す。確定より後に `develop` へ入った変更は次の配布になる。出し直しの後に `develop` が進んでも、出し直しで承認したコミットを出すため、出し直しは連鎖しない。 + +**答えを返す。** `settle` を受けた耐久ワークフローは、確定したコミットの範囲に Pull Request が入る依頼すべてへ答え(`answer-<依頼>`)を立て、範囲のイベント `range-` を立てる。範囲に入らない依頼は `pending` に残り、手順 4 が今の配布の直後に次の配布を組む。差し戻し(`--rejected`)は最後に提示した範囲に入る依頼へ `rejected` を返す。 + +### レーンの側 + +```mermaid +graph TD + A[本番のステージを組む] --> B{動いている計画の
レーンか} + B -->|いいえ| C[今の本番のプラン] + B -->|はい| D[配布の依頼のプラン] + D --> E[parallel-request] + E -->|すでに main にある| G[まとめへ進む] + E -->|依頼を送った| F[配布待ちで終わる] + F --> H[次の周回へ
答えは周回の境で読む] +``` + +レーンかどうかは `parallel_lane.lane_of()` が決める。環境変数 `NDF_RELAY_NAME` の名前が、耐久ワークフローが終わっていない(状態が `PENDING`)計画のレーンにあればレーンである。判定はプランを組む時点に 1 度だけ行い、組んだプランに計画名を書く。 + +配布の依頼のプランは run のステップ `parallel-request` の 1 つだけを持ち、プランの「フェーズ」は `配布の依頼` である。自分の Pull Request のマージのコミットがすべて `origin/main` にあれば依頼を送らずに `released` を `--out` へ書き、0 で終わる(I15)。版は範囲のイベントから読む。無ければ依頼を送り、10 で終わる。依頼の `id` は、ステップが最初の実行で `<レーン名>-r<乱数 12 桁>` を作ってプランの状態ディレクトリの `work/request-id` へ書き、打ち直しでは同じファイルから読む。受け取った依頼の数からは採番しない(受け取りの前後で打ち直すと別の値になるため)。プランは承認ゲートとして終わり、queue は後のステージ(まとめ)を流さない。ステップを打ち直しても同じ `id` になり、冪等の鍵が 2 度目を数えない。設計の承認と承認ゲートの外の問いの `id` の決め方は [issue-2436-design-approvals.md](issue-2436-design-approvals.md) の「設計の承認の依頼」にある。 + +ループのレーンは、プランが `配布の依頼` で承認ゲートとして終わったら、周回を「配布待ち」で締めて次の周回へ進む。答えは周回の境で待たずに読み、届いていれば残りの工程(まとめ)を流す。ループの側の設計は [issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md) にある。スプリントのレーンは、conductor が `parallel.py await --from <パス>` を背景で打って答えを待ち、`released` ならまとめを流す。 + +**承認の答えの前に、次の周回の変更が同じ配布に入ることがある。** レーンが配布待ちのまま次の周回で `develop` へマージした変更は、承認の答えの時点の比べで提示 2 に入り得る(「新しい範囲で出す」を選んだとき)。その次の周回が後で配布の依頼のステップに来ると、自分の Pull Request がすでに `main` にあるため依頼を送らずに `released` で進む(I15)。依頼の有無と配布の中身が一致しないのは、中身が `develop` の先頭で決まるためで、この扱いで出し直しは起きない。 + +## 非機能の実現方式 + +| 大項目 | 条件 | 実現方式 | +| --- | --- | --- | +| 可用性 | オーガナイザーが止まってもレーンは動き続ける。依頼したレーンは答えまで待つ | レーンは tmux のサーバが親で、オーガナイザーと独立している(#2346)。依頼は SQLite に残るメッセージで、`run` の打ち直しが耐久ワークフローを記録から続けて読む | +| 性能・拡張性 | プランの本数の合計は `NDF_PARALLEL_MAX_PLANS`(既定 6 本)まで。レーンの数は `NDF_RELAY_MAX_NAMED` に従う | 検査が I2 で止める。レーンの `queue` の `--max` の既定がレーンのプランの本数になる。観測の間隔は 60 秒で、レーンの停止は最長 60 秒遅れて起動と終わりの判定に効く | +| 運用・保守性 | スクリプトは統一 JSON レポートを返す。置き場はコミットしない | 全副命令が 1 行の JSON を返す。`plan.md` を描画して人が読む形を残す。耐久の記録は #1142 の置き場と掃除(30 日)に従う | +| システム環境 | Claude Code とラッパーと tmux があるときだけ起動まで行う | `run` が `NDF_RELAY_DIR` と `relay.py ls --json` の `metrics.hub`・`metrics.tmux` を見て、欠ければ `manual` を返す | + +## 決定の記録 + +[issue-2436-design-decisions.md](issue-2436-design-decisions.md) に置く。 + +## テスト設計 + +耐久ワークフローのテストは、`NDF_DBOS_DIR` を一時ディレクトリにして本物の DBOS(SQLite)で流す。`relay.py` と `git`・`gh` は差し替える。 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・2、I5 | `init` が `plan.json`・`plan.md`・レーンの数だけの引継ぎ文書を作り、引継ぎ文書は計画のパスを持ち決まりを写さない。1 本でも在れば何も書かない | 在る引継ぎ文書を上書きするように壊す・一部だけ書いて止まるように壊すと落ちる | +| 受け入れ条件 3、I1〜I4・I19 | 名前の形(計画名の 24 字の上限、`-rel` と数字で終わるレーン名)・重なり・上限・合計 6・未知の依存・循環(`CycleError`)が誤り(終了コード 1、`rule` に I1〜I3)、触る場所の重なりが警告(0) | 25 字の計画名か `<計画名>-rel1` のレーン名を通す・循環の判定を外す・重なりを誤りにする・合計の比較を「より大きい」から「以上」へずらすと落ちる | +| 受け入れ条件 4、I6・I8 | 検査に誤りがあると耐久ワークフローを始めず `relay.py spawn` を 1 回も打たない。依存のあるレーンは依存先が `stopped` になった後の周で起動する | 誤りがあっても起動する・依存を無視して起動すると落ちる | +| 受け入れ条件 5 | `wait` が `notice`・`release`・終わりのいずれかで 0、無ければ上限で 3 を返す。レーンの承認待ちと停止は観測の耐久ステップが拾う | 変化を見落とす・上限を待たずに返すと落ちる | +| 受け入れ条件 6、I9 | 依頼が 1 件届き配布が流れていなければ、他のレーンの依頼を待たずに提示 1 を組む。範囲は依頼の集合でなく提示したコミットの `main..` で、依頼していないレーンの Pull Request も内訳に載る | 依頼が揃うまで待つ・範囲を依頼の Pull Request の和で作る・依頼の無いレーンを内訳から落とすと落ちる | +| 受け入れ条件 6、I14(変わらない) | 承認の答えの時点で `origin/develop` の先頭が提示したコミットと同じなら、`confirm` が `confirmed` を返し、そのコミットで確定する。MVV 判定が通す場合は比べずに提示 1 つで配布になる | 同じなのに出し直す・比べずに確定すると落ちる | +| 受け入れ条件 6、I14(変わった) | 先頭が進んでいれば `confirm` が提示 2 を組み、加わった範囲(Pull Request とレーン)を載せた `release`(`represent: true`)を返す。`--take new` なら提示 2 の、`--take first` なら提示 1 のコミットで確定し、`confirmed` の本番のプランがそのコミットから `release/v<版>` を切る。承認ゲート 2 で止まった提示のプランの後は、確定まで worktree も `release/v<版>` も作られず、確定の後に流れる本番のプランは 1 つだけである | 進んだのに確定する・加わった範囲を載せない・選んだのと違うコミットで確定する・答えの前に本番のプランを流して `release/v<版>` を作ると落ちる | +| 受け入れ条件 6、I14(出し直しの後にまた変わった) | 提示 2 への `confirm` は比べずに確定し、提示 3 を組まない。出し直しで承認したコミットを出し、確定の後に入った Pull Request の依頼は `pending` に残り、配布の直後の次の配布に入る | 2 回目の出し直しを組む・後の依頼を今の配布の答えにすると落ちる | +| 受け入れ条件 6、I15(Pull Request 2420 の件) | レーン A の配布の範囲にレーン B の Pull Request が入ると、B は依頼していなくても範囲のイベントを受け、後で `request` に来ると依頼を送らずに `released` で進む。B のための 2 本目の本番のプランは組まれない | B の依頼で 2 本目の配布を組む・すでに `main` にあるのに待つと落ちる | +| 受け入れ条件 6、I12 | 動いている計画に属さないレーンでは今の本番のプランが組まれる | 属さないレーンに依頼のプランを組むと落ちる | +| 受け入れ条件 6a | 周回を「配布待ち」で締めて次の周回へ進む・配布待ちと質問待ちの周回の課題を選ばない・答えの後に残りを流す・差し戻しで締める([issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md) の「テスト設計」) | 同じ文書の表のとおり | +| 受け入れ条件 6b、I16 | `check-pr-base.sh` の通す・落とす、承認したコミットと `release/v<版>` の先頭の照合、版数の衝突の解消、Pull Request 2420 の件が起きないこと([issue-2436-design-release-rule.md](issue-2436-design-release-rule.md) の「テスト設計」) | 同じ文書の表のとおり | +| 受け入れ条件 6c〜6f、I17・I18 | 問いの依頼をまとめて 1 回で問う・答えの時点の新しい依頼を続けて問う・レーンは待たずに残りへ進む・hook の送り先・質問待ちの再開・設計の 3 つの答え・配布の差し戻しの宛先・持ち主のレーンが答えの後の処理を行う・終わったレーンを起こす([issue-2436-design-approvals.md](issue-2436-design-approvals.md) の「テスト設計」) | 同じ文書の表のとおり | +| 受け入れ条件 6g・6h、I19 | 一時レーンの失敗と異常系の各行([issue-2436-design-failures.md](issue-2436-design-failures.md) の「テストの観点」) | 同じ文書の表のとおり | +| 受け入れ条件 7、I13 | オーガナイザーの印を持つ動いているラッパー 1 本を除いて数え、既定の 3 でレーン 3 本が起動できる。印が 2 本あれば 1 本だけ除く | 印を数えない・印をすべて除くと落ちる | +| 受け入れ条件 6f、I13(一時レーンの上限) | レーン 3 本が動いている既定の 3 で、`--role helper` の起動は通り、2 本目の `helper` は断られる。印の無いラッパーの 4 本目は今どおり断られる | `helper` を数えずに何本でも通す・`helper` を通さない・印の無いラッパーが通ると落ちる | +| 受け入れ条件 6f、I18(一時レーンの結果) | `release` と `confirmed` で `metrics.spawn` が一時レーンの 1 行を返す。一時レーンの `settle` で答えが立ち、`gate2` の依頼で `questions` が立つ。オーガナイザーは配布のプランを流さない | オーガナイザーがプランを流す・一時レーンの結果を受け取れないと落ちる | +| 受け入れ条件 8 | `stop` が計画のレーンすべてへ停止を打ち、止まらない名前を返し、耐久ワークフローが `stopped` で終わる | 一部のレーンに打たない・失敗を 0 で返す・耐久ワークフローが残ると落ちる | +| 受け入れ条件 9 | `NDF_RELAY_DIR` が無い・tmux が無いと、耐久ワークフローを始めず、レーンごとの 1 行と終了コード 3 を返す | 起動を試みる・コマンドがレーンの数に足りないと落ちる | +| 受け入れ条件 11、I7・I10 | 起動の耐久ステップの後でプロセスを落として `run` を打ち直すと、起動に成功したレーンを起動せず、断られたレーンは起動し直す。プロセスが抜けている間に送った依頼は続きで読まれ、答えは 1 回だけ立つ | 起動し直す・断られたレーンを起動しない・依頼を取りこぼす・答えが 2 度変わると落ちる | +| 受け入れ条件 12、I11 | 全レーンが止まり答えていない依頼が無いときだけ `finished` と要約(配布した版・止まった理由)を返す | 依頼が残るのに終わる・止まっていないレーンがあるのに終わると落ちる | +| 受け入れ条件 10 | `relay.md` の `ndf-spawn` の節と用語集から `parallel-plan.md` へのリンクがある | (リンクの検査は参照切れのチェックスクリプトが見る。文言のテストは書かない) | +| レーンのプランの本数 | レーンで `queue` の `--max` を省くと計画の本数、レーンでなければ 3 | レーン判定を外すと落ちる | +| `durable.py` の外の関数 | `DBOSClient` で送ったメッセージを、別のプロセスの耐久ワークフローが `recv` で読み、立てたイベントを `event_of` で読める(試行の形をテストにする) | 冪等の鍵を外すと 2 度目の送信が数えられて落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2436 | 実装する | — | `plugins/ndf/scripts/parallel.py`、`plugins/ndf/scripts/lib/parallel_plan.py`、`plugins/ndf/scripts/lib/parallel_run.py`、`plugins/ndf/scripts/lib/parallel_release.py`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/scripts/lib/durable.py`、`plugins/ndf/scripts/relay_lib/hub.py`、`plugins/ndf/scripts/relay_lib/hub_client.py`、`plugins/ndf/scripts/relay_lib/__init__.py`、`plugins/ndf/scripts/supervise.py`、`plugins/ndf/scripts/supervise_lib/sprint.py`、`plugins/ndf/scripts/supervise_lib/sprint_waves.py`、`plugins/ndf/scripts/supervise_lib/release_templates.py`、`plugins/ndf/scripts/supervise_lib/queue_fill.py`、`plugins/ndf/scripts/supervise_lib/paths.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/contracts/forms/parallel-request.json`、`plugins/ndf/skills/issue-plan-strategy/`、`plugins/ndf/skills/development-workflow/`、`docs/glossary/glossary.json`、`docs/glossary.md`。ループの側の触るファイルは [issue-2436-design-lane-loop.md](issue-2436-design-lane-loop.md)、異常系の側は [issue-2436-design-failures.md](issue-2436-design-failures.md)、承認ゲート 1 の側は [issue-2436-design-approvals.md](issue-2436-design-approvals.md)、配布の規則の側は [issue-2436-design-release-rule.md](issue-2436-design-release-rule.md) の「触るファイル」にある | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 提示の答えの記録 | `sprint-state.py gate` を、スプリントの状態ファイルでない提示の答えの記録(`{"gates": []}` だけを持つ)へ打ったとき、覆しの記録(`record_override`)が MVV の項目の無いファイルで通るかは未確認。通らなければ、実装で提示の答えの記録に要る項目を足す | +| 依頼の無いレーンの対応づけ | 範囲の Pull Request をレーンへ対応づけるのは依頼の `prs` と、閉じる課題とスプリントのレーンの `input.issues` の照合だけである。ループのレーンが依頼の前に出した Pull Request は「レーンの無い Pull Request」として並ぶ。答えはレーンの名前によらず範囲のイベントで届く(I15)ため、配布の結果は欠けない | +| 提示のプランの組み立て | 今の開発版の承認資料のステップ(`_dev_approval_steps`)を版上げ無しで流せるか、本番のプランの承認の記録(`approve` の `--facts-from`)が提示のプランの状態ディレクトリを読めるかは、実装の最初に確かめる | +| 複数のプロセスが同じ SQLite に書くときの待ち | `run` のプロセスと、`DBOSClient` を開く `request`・`stop`・`confirm`・`settle`・`wait` のプロセスが同じファイルへ書く。試行は 3 プロセスで通ったが、ロックの待ち(busy)の長さと失敗の形は未確認。#1142 の I17(1 つの SQLite を開くプロセスは 1 つ)は DBOS を起動するプロセスに限る読み方にする(決定 12) | +| 長く動く耐久ワークフローの記録の量 | 観測の周ごとに `recv` と耐久ステップの記録が 2 件増え、1 日でおよそ 2,880 件になる。SQLite の大きさと打ち直しの速さへの効きは未確認 | +| 起動の耐久ステップの二重の実行 | `relay.py spawn` が成功してから記録が書かれる前にプロセスが落ちると、打ち直しで同じ起動が流れ直す。統括役が同じ名前の 2 本目を断ることを前提に、その断りを `started` として扱う。断りの形は実装で確かめる | +| 上限の値の読み手 | 検査は conductor のプロセスの環境変数 `NDF_RELAY_MAX_NAMED` を読み、統括役は統括役のプロセスの環境変数を読む。オーガナイザーが統括役のセッションで動く通常の形では同じ値になる。別の端末から値を変えて起動した場合の食い違いは未確認で、統括役の断りは次の周の起動し直しとして扱う | +| 一時レーンの手元の負荷 | 一時レーンは同時に流すプランの合計の上限(I2)に数えない。本番のプランは GitHub の CI の待ちが主で手元の負荷は小さい見込みだが、レーン 3 本と一時レーンが同時に動くときのメモリと CPU はリリース後テストで確かめる | +| 開発版の版数 | 配布待ちの間と承認の後に、他のレーンの開発版が `develop` の版数を上げる。本番の版上げとの衝突の解き方は [issue-2436-design-release-rule.md](issue-2436-design-release-rule.md) の「版数の衝突」にある | +| 起動の後のレーンの追加 | 動いている計画へレーンを足す手順は持たない。新しい計画名で `init` し直す | +| 承認待ちのレーンの扱い | オーガナイザーは承認待ちのレーンを知らせる(`relay.py attach <名前>` の 1 行)だけで、代わりに答えない。知らせの形はリリース後テストで確かめる | +| 通しの動き | 起動から配布までの通しは単体テストで縛れない。リリース後テストで 2 レーンの計画を `--parallel` で起動して確かめる(要求の「検証手段」) | diff --git a/issues/issue-2436-requirements.md b/issues/issue-2436-requirements.md new file mode 100644 index 000000000..4d7ff472e --- /dev/null +++ b/issues/issue-2436-requirements.md @@ -0,0 +1,190 @@ +# #2436: tmux の並列開発を計画から始める(並列の計画・レーンごとの引継ぎ文書・オーガナイザーへの指示書・--parallel で起動) + +正は課題の本文(#2436)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 具体例 + +2026-11-02、利用者は 3 本の開発ループ(lroot2・lflow・lfactory2)を tmux のセッションとして**手で**起動した。起動の前に、3 本の引継ぎ文書(`.ndf/handoff/loop-*.md`)へ「3 本並列の決まり」(1 本のループが同時に流すプランは 2 本まで・合計 6 本、触る場所、取り込みの衝突は base-sync で解く)を**それぞれ手で**書き写した。 + +起きたこと: + +- 本番の配布がぶつかった。lflow が ndf v10.27.14 に lfactory2-3 と lroot2-5 の変更もまとめて出したため、lroot2 が出していた本番の Pull Request https://github.com/devbasex/ai-plugins/pull/2420 は閉じることになった(伝言 `.ndf/handoff/notice-to-lroot2.md`) +- 統括役(#2346、v10.27.14)で `ndf-spawn` のブロックからセッションを起動できるようになったが、「並列開発をどう計画し、どう指示すれば始まるか」を定めた手順が無い。`ndf-spawn` の説明は `relay.md` の 1 節だけである +- 同時に動かせる数の上限 `NDF_RELAY_MAX_NAMED`(既定 3)は統括役も数える。取りまとめのセッションを独立させると、レーンは 2 本までになる + +この課題が終わると、利用者は並列の計画を 1 度立て、`/goal /ndf:development-workflow --parallel <計画名>` と打てば、オーガナイザー(取りまとめのセッション)がレーンを起動し、各レーンが開発を始め、本番の配布はオーガナイザーが 1 本にまとめる。 + +## 依頼(原文) + +> relay.pyはtmuxによる並列セッションが可能となりましたが、並列開発を開始するためにどのような指示の仕方を出せばよいかの定義と受け皿が無いです。 +> tmuxセッションを複数利用した並列開発のためのSkillを作成し、 +> +> * 事前に並列開発計画を立て、並列数分の引継ぎメモを起こす +> * オーガナイザーに対する指示書を作成する +> * /goal /{skill} {オーガナイザーに対する指示書} と指定することでオーガナイザーがセッションを起動し、それぞれのセッションが開発を始める +> +> というようなフローで動くようするのはどうでしょうか? +> +> それ以外の案も検討して方式を確立してください + +(書き手: 利用者、日付: 2026-11-02) + +## 起票者の推定 + +利用者の原文に無く、起票者が決めた条件。判断の記録 83d49e0eb451(`ask.py decide`)。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。S5〜S7 は要求の工程(2026-11-02)で足した。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 新しい Skill を作らず、計画は `issue-plan-strategy`(実行計画)、起動は `development-workflow` の `--parallel <計画名>` で受ける | 既存の Skill へ統合してから新設を考える方針。分割と並行の手順は `issue-plan-strategy` の担当(`parallel-work.md` の境界)。`--loop` が同じ形の前例 | 受け入れ条件 1・4 | +| S2 | オーガナイザーは起動の後も残り、レーンを監視し、本番の配布を 1 本にまとめる | 11-02 の配布の衝突は、調整する役が無いと再発する | 受け入れ条件 5・6 | +| S3 | レーンの最初の入力は開発ループ(`--loop <名>`)でも 1 スプリント(`#<課題>`)でもよい | 11-02 の実運用はループ。スプリントも入力の違いだけで受けられる | 受け入れ条件 2 | +| S4 | オーガナイザーは同時に動かせる数に数えない | 開発のプランを流さず負荷が小さい。数えるとレーンが 2 本になり、実運用の 3 本を下回る | 受け入れ条件 7 | +| S5 | 原文の「オーガナイザーに対する指示書」を「並列の計画」(1 つの文書)と呼ぶ | 用語集の「指示書」は `AGENTS.md` / `CLAUDE.md` などの規約の文書を指す(`docs/glossary/glossary.json`)。1 語 1 意味(Value 8)を守るため | 用語・受け入れ条件 1〜4 | +| S6 | オーガナイザーを起動し直すと、`.ndf/parallel/<計画名>/` の記録から続ける | オーガナイザーもカットポイントで区間を切り替える。記録から続けられないと、配布の依頼を取りこぼす | 受け入れ条件 11 | +| S7 | 全レーンが止まり、処理していない配布の依頼が無ければ、オーガナイザーは要約を出して終わる | 終わる条件が無いと、オーガナイザーが待ち続ける | 受け入れ条件 12 | + +案の比較(新しい Skill・既存 Skill へ統合・オーガナイザーを置かない)は設計文書の「決定の記録」に置く。 + +## 目的 + +1 つのリポジトリで複数の開発の流れ(レーン)を同時に進めるとき、利用者が並列の計画を 1 度書いて 1 行で始められ、本番の配布がレーンどうしでぶつからない状態にする。11-02 の手作業(引継ぎ文書への決まりの書き写し・セッションの手での起動)と、配布の衝突(Pull Request 2420 を閉じた件)を繰り返さない。 + +## 前提 + +1. 統括役・名前つきのラッパー・`ndf-spawn`・`relay.py spawn/ls/attach/stop/restart` は #2346 のとおりに動く(v10.27.14) +2. 同時に流すプランの合計の上限は、環境変数 `NDF_PARALLEL_MAX_PLANS` で変えられ、既定は 6 本(2026-09-25 の実測。8 コア・30 GB。2026-11-03 の承認ゲート 1 で利用者が「設定にする」と決めた) +3. 承認ゲートは 2 つのまま増やさない。並行したレーンの本番の配布は、承認ゲート 2 の 1 回の承認へまとめる(`parallel-work.md` の「承認は承認ゲートでまとめる」) +4. Claude Code だけで動く(ラッパーが Claude Code だけのため)。ラッパーの外・tmux が無いときは、手で起動するためのコマンドを示して終わる +5. 本番の配布の中身は依頼の集合ではなく、develop に入ったコミットで決まる。依頼の有無によらず、承認したコミットまでに develop にある変更はすべて入る +6. レーンどうしは同じ `develop` へマージする。本番の配布は承認したコミットまでの `develop` を出すため、そこまでに `develop` にある他のレーンの変更も含む(承認資料にレーンごとの内訳を載せる) + +## 対象範囲 + +含む: +- 並列の計画の形と、それを作る手順(`issue-plan-strategy`) +- レーンごとの引継ぎ文書を作る手順 +- 並列の計画の検査(スクリプト) +- `--parallel <計画名>` の起動、レーンの起動、レーンの監視 +- 配布の依頼の形と、オーガナイザーによるまとめ +- 同時に動かせる数の数え方(オーガナイザーを数えない) +- 計画名を指定した一括の停止 + +含まない: +- 別のマシン・別のコンテナにまたがる並列 +- 統括役の起動の仕組みそのもの(#2346) +- レーンの中の工程(`loop.md`・スプリントの手順は、配布の依頼の分岐と配布待ちの周回の扱い(受け入れ条件 6a)のほかは変えない) +- Codex で動かす並列(ラッパーが Claude Code だけのため) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 並列の計画を書いた | 利用者が `issue-plan-strategy` で並列の計画を頼んだ | — | — | +| E2 | レーンごとの引継ぎ文書を作った | E1 の手順の続き | 同じ名前の引継ぎ文書が既にある: 上書きせず止め、名前を示す(受け入れ条件 2) | E1 | +| E3 | 並列の計画を検査した | E1 の手順の最後と、E4 の直後 | 誤りがある: 統一 JSON レポートで項目を返し、レーンを起動しない(受け入れ条件 3・4) | E1 | +| E4 | オーガナイザーが起動した | 利用者が `/goal /ndf:development-workflow --parallel <計画名>` と打った | 計画名の並列の計画が無い: 起動せず、計画の置き場を示して終わる | E1 | +| E5 | レーンを起動した | E3 が通った。依存のあるレーンは、依存先のレーンが止まった時点 | 統括役が断った・tmux が無い: そのレーンを手で起動するコマンドを示す(受け入れ条件 9) | E3・E4 | +| E6 | レーンが開発を始めた | レーンが引継ぎ文書を読んだ | 引継ぎ文書が無い: レーンは止まり、オーガナイザーが状態の変化として受け取る | E5 | +| E7 | レーンの状態が変わった(承認待ち・止まった) | レーンの工程 | — | E6 | +| E8 | レーンが配布の依頼を置いた | レーンが承認ゲート 2 の手前に来た | — | E6 | +| E9 | オーガナイザーが承認ゲート 2 を求めた | E8 を受け取った | 承認されない: 依頼を残し、依頼したレーンへ差し戻しを返す | E8 | +| E10 | 本番を配布した | E9 の承認 | 配布が失敗した: 依頼を残し、依頼したレーンへ失敗を返す | E9 | +| E11 | 依頼したレーンへ配布の結果を返した | E10 か E9・E10 の失敗 | — | E9 | +| E12 | 計画名を指定して全レーンを止めた | 利用者の停止の指示 | 止まらないレーンがある: 名前を返す | E5 | +| E13 | オーガナイザーが終わった | 全レーンが止まり、処理していない配布の依頼が無い | — | E7・E11 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 並列の計画 | 1 つのリポジトリで同時に進めるレーンの一覧と、レーンに共通の決まりを書いた 1 つの文書。オーガナイザーが読む。名前(計画名)を持つ | +| レーン | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ | +| オーガナイザー | `--parallel <計画名>` で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役(ラッパーの役)とは別 | +| 配布の依頼 | レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼む 1 件の依頼(DBOS のメッセージ) | + +## 受け入れ条件 + +1. `issue-plan-strategy` が並列の計画を作る手順を持つ。並列の計画は、レーンごとに名前・最初の入力・触る場所・同時に流すプランの本数・始める条件(他のレーンへの依存)を決め、1 つの文書に書く +2. 並列の計画を作る手順が、レーンの数だけ引継ぎ文書を作る。レーンに共通の決まり(プランの本数の配分・触る場所・配布の担当)は並列の計画の 1 箇所に置き、引継ぎ文書は並列の計画を参照する(各文書へ写さない)。同じ名前の引継ぎ文書が既にあれば上書きせずに止める +3. 並列の計画をスクリプトが検査する。レーンの数が上限を超える・プランの本数の合計が 6 を超える・名前の形が違う・依存先が並列の計画に無いか循環する・触る場所が重なる(重なりは警告)を、統一 JSON レポートで返す。誤りがあれば終了コードは 0 以外 +4. `/goal /ndf:development-workflow --parallel <計画名>` で起動したセッションがオーガナイザーになり、検査を通った並列の計画のレーンをすべて起動する(依存のあるレーンは依存先が止まってから)。各レーンは引継ぎ文書を読んで開発を始める。検査に誤りがあれば、レーンを 1 本も起動しない +5. オーガナイザーは、レーンの状態が変わる(承認待ち・止まった・配布の依頼)まで、スクリプトで待つ。手書きのループや前景の sleep で待たない +6. 並列の計画の下のレーンは、本番の配布を自分で行わず、オーガナイザーへ依頼する。**配布のために他のレーンを待たない。** 依頼が届いた時点で流れている配布が無ければ、オーガナイザーは直ちに配布を組む。配布の中身は依頼の集合ではなく、承認資料を作る時点の develop の SHA で決まる(`main..`)。承認の答えを受けた時点で develop が提示した SHA より進んでいれば、加わった範囲を添えて同じ承認ゲート 2 を 1 回だけ出し直す(「新しい範囲で出す」「最初の範囲だけ出す」「差し戻す」)。出し直しの後に develop が進んでも、出すのは出し直しで承認した SHA である。develop へのマージは保留しない(レーンの開発版の配布と確認を止めない)。承認した SHA より後に develop へ入った変更・届いた依頼は次の配布になる(今の配布が終わった直後に組む)。範囲に入っていたレーンには、依頼の有無によらず配布済みと版を返し、そのレーンは本番を出し直さずに続きの工程(振り返り・まとめ)へ進む。並列の計画の外で動くループ・スプリントの配布も、受け入れ条件 6b の規則(承認したコミットを `release/v<版>` から出す)で動く。それ以外の手順は変わらない +6a. レーンは配布の依頼を置いた後、答えを待たずに次の周回(スプリント)へ進む。配布待ちの周回の課題は次の周回の選定に入れない。答えが届いたら、レーンは周回の境で配布待ちの周回の残りの工程(リリース後テスト・振り返り・課題を閉じる・棚卸し)を流してから続ける。差し戻されたら、その周回を今の差し戻しの扱いで締める +6b. 承認した SHA をそのまま出せるよう、本番チャネルへの PR の規則を改める。main 宛の PR は「develop から出たものだけ」から「develop に含まれるコミットだけ」にし、head を `release/v<版>`(承認したコミット + 版を上げたコミット。先に develop へマージ済み)とする。`scripts/check-pr-base.sh`・`release-steps.py` の本番の PR・#815 の承認の照合・`AGENTS.md` の Git 運用ルール・`docs/versioning-and-distribution.md` を合わせて直す。版を上げたコミットを develop へ戻すときの版数の衝突は配布のステップが本番の版を取って解く +6c. 並列の計画の下では、承認ゲート 1(設計の承認)と承認ゲート 2 の問いはオーガナイザーだけが出す。レーンは AskUserQuestion で止まらない。設計の承認が要るとき、レーンはオーガナイザーへ依頼を送り、その課題を人の判断待ちへ移して残りの課題か次の周回へ進む(ループでないレーンは依頼の後に終わり、承認の後にオーガナイザーが起こし直す)。オーガナイザーは届いている設計の承認の依頼を 1 回の問いにまとめ、答えの時点で新しい依頼が届いていれば続けて問う。承認の後の操作(承認ラベル・設計 Pull Request のマージ・`loop answer`)は受け入れ条件 6f のとおり持ち主のレーンが行い、その課題は次の周回の選定へ戻る。承認ゲートの外の問いも受け入れ条件 6d のとおりオーガナイザーへ集める +6d. 並列の計画の下のレーンでは、承認ゲートの外の区分つきの問い(費用・取り消せない操作・規約・レッドライン・人にしかできない操作)も、問いの hook がオーガナイザーへ送る。レーンはその作業を質問待ちにして別の作業へ進み、答えが届いたら周回の境で再開する。オーガナイザーは承認ゲートの問いと同じ回にまとめて出し、答えをレーンへ返す。人にしかできない操作は `relay.py attach <レーン>` の案内を問いの一覧に出す +6e. 差し戻しの後に直す作業は、変更の持ち主のレーンが行う。オーガナイザーが行うのは、問いを集める・示す・答えを配ることと、本番の配布だけである。設計の承認の答えに「直して出し直す」(指摘つき)を足し、レーンは次の周回でその課題を最優先に選んで同じ設計 Pull Request を直し、承認の依頼を送り直す(「やらない」は今の差し戻しの扱い)。承認された設計 Pull Request のマージもレーンが行う。配布の差し戻しは、指摘をレーンごとに返し、該当するレーンが直す Pull Request か外す revert の Pull Request を develop へ入れ、次の配布で出す。直すレーンが終わっていれば、オーガナイザーが起こして答えを渡す +6f. オーガナイザーは作業を持たない。答えの後の処理は持ち主のレーンが行い、オーガナイザーは答えを送るだけである。持ち主のレーンが終わっていれば、オーガナイザーは `relay.py restart <名前>` で起こすだけにする。持ち主のいない共有の作業(本番の配布のプランの実行・その失敗の対応・版数の衝突の解消)は、その時だけの一時レーンが行う。一時レーンは同時に 1 本までで、同時に動かせる数(`NDF_RELAY_MAX_NAMED`)を 1 本だけ超えてよい。作業が終われば自分で止まり、結果をオーガナイザーへ返す。同時に流すプランの合計(6 本)には数えない +6g. 一時レーンの失敗もオーガナイザーへ問いを集める。直せない失敗は 6d の経路で問いになり、一時レーン自体が止まればオーガナイザーが見つけて問う。main へのマージの後に止まった配布は、差し戻しを選ばせず続きから流す +6h. 異常系を次のとおり扱う: オーガナイザーのセッションが切り替わっても進行のプロセスは残り、打ち直しで二重にならない/同じ計画の二重起動を断る/一定の時間動かないレーンを問う/配布を差し戻した変更が develop に残る間は、オーガナイザーは自動で配布を組まず資料に示す/同じ原因の問いは 1 つにまとめて答えを配る/手元の作業が待ちだけになったレーンは止まり、答えで起こされる/レーンの間で課題とマイルストーンが重なれば検査の誤りにする/メインディレクトリの git の操作はレーンの間で 1 本ずつにする/設計の直しが 3 回続けば方針を問う/答えに問いの時点の印を付け、前提が変わっていればレーンは問い直す/止まったレーンの配布待ちのまとめは計画を終えるときに一時レーンで流す/配布の途中の停止は配布を終えてから止める/利用上限で全レーンが止まれば 1 回だけ知らせる +7. オーガナイザーのセッションは `NDF_RELAY_MAX_NAMED` に数えない。既定のままで、オーガナイザー 1 本とレーン 3 本が同時に動く +8. 計画名を指定して全レーンを止められる。1 本のレーンだけを止めるのは今の `relay.py stop <名前>` で行う +9. ラッパーの外・tmux が無いときは、オーガナイザーはレーンを起動せず、手で起動するためのコマンド(レーンごとの 1 行)を示して終わる +10. `relay.md` の `ndf-spawn` の節と用語集から、並列の計画の手順へ辿れる +11. オーガナイザーを起動し直すと、`.ndf/parallel/<計画名>/` の記録から続ける。起動済みのレーンを二重に起動せず、処理していない配布の依頼を取りこぼさない +12. 全レーンが止まり、処理していない配布の依頼が無ければ、オーガナイザーはレーンごとの結果(配布した版・止まった理由)を要約して終わる + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 可用性 | オーガナイザーが止まっても、レーンは動き続ける。配布の依頼を置いたレーンは、結果が返るまで待つ(受け入れ条件 11 で再開する) | +| 性能・拡張性 | 同時に流すプランの合計は `NDF_PARALLEL_MAX_PLANS`(既定 6 本)まで(前提 2)。レーンの数の上限は `NDF_RELAY_MAX_NAMED` に従う | +| 運用・保守性 | 検査・待ち・停止のスクリプトは統一 JSON レポートを返す。並列の計画と配布の依頼はメインディレクトリの `.ndf/parallel/<計画名>/` に置き、コミットしない | +| システム環境 | Claude Code とラッパー(relay.py)と tmux があるときだけ起動まで行う(前提 4) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。`development-workflow` の引数に `--parallel <計画名>` が増え、`relay.py` に計画名で止める指定が増える。既存の引数・サブコマンドの振る舞いは変えない(互換あり) | +| データ | `.ndf/parallel/<計画名>/` を新しく置く。既存の `.ndf/handoff/`・`.ndf/loop/` の形は変えない | +| 既存の振る舞い | 統括役の上限の判定が、オーガナイザーの印を持つラッパーを数から外す。ループ・スプリントの配布の手前に、並列の計画の下のときだけ依頼の分岐が入る | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(検査・待ち・上限の数え方・計画名での停止・再開の単体テスト) | +| 静的解析 | `python3 scripts/check-skill-frontmatter.py`、`python3 plugins/ndf/scripts/instructions-check.py --root .`、`claude plugin validate .` | +| 用語 | `python3 plugins/ndf/scripts/glossary.py check --file issues/issue-2436-requirements.md` が当たり 0 件 | +| 手動確認 | リリース後テストで、tmux のある端末から 2 レーンの並列の計画を `--parallel` で起動し、両レーンが始まること・配布の依頼が 1 回の承認ゲート 2 にまとまることを見る | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | Skill の手順は `plugins/ndf/skills/issue-plan-strategy/`・`development-workflow/` の本文と references、スクリプトは `plugins/ndf/scripts/`(統括役は `relay_lib/`)。新しい Skill を作らない(S1) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」、`plugins/ndf/skills/AUTHORING.md`。待ちは `waiting.md` の形 | +| テスト戦略 | スクリプトの判定(検査・上限・停止・再開)は単体テストで担保する。`.md` の文言を照合するテストは書かない。起動から配布までの通しはリリース後テストで見る | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、統一 JSON レポートの形に合わせる、用語集への 4 語の追加 | +| 確認してから行う | `development-workflow` の引数と統括役の上限の判定の変更(承認ゲート 1・2 で人が見る) | +| 行わない | 統括役の起動の仕組みの作り直し、レーンの中の工程の変更(配布の依頼の分岐を除く)、別のマシンにまたがる並列 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| オーガナイザーの印の持ち方(環境変数か登録簿の役割か) | 設計の工程 | 承認ゲート 1 | +| 並列の計画の書式(Markdown の表か JSON か)と、レーンが自分が並列の計画の下にいると知る方法 | 設計の工程 | 承認ゲート 1 | +| レーンの数の上限を `NDF_RELAY_MAX_NAMED` から読むか、別に持つか | 設計の工程 | 承認ゲート 1 | + +## 設計の手がかり(起票者が調べたこと) + +設計の工程はこれを出発点にし、比べた結果を「決定の記録」へ書く。 + +| 論点 | 調べた事実 | 起票者の見立て | +| --- | --- | --- | +| 案の比較 | A: 新しい Skill(依頼の形)/B: 既存 Skill へ統合(S1)/C: オーガナイザーを置かず、計画の手順が `ndf-spawn` のブロックを並べて出すだけ | B。C は 11-02 の配布の衝突を防げない | +| オーガナイザーの居場所 | 端末で最初に打った `claude` のラッパーが統括役(`relay_lib/hub.py`)。上限の判定は `Hub.judge` の `now.count >= max_named()` だけで、統括役も数える | オーガナイザーは統括役のセッションで動かし、印(環境変数か登録簿の役割)を持つラッパーを数から外す | +| レーンの起動 | 応答の ` ```ndf-spawn <名前> ` のブロック(中身が最初の入力)か `relay.py spawn --name <名前> --json -- <入力>` | スクリプトから `relay.py spawn` を打つ(結果を JSON で受け取れる) | +| 監視 | `relay.py ls --json` が名前・状態(running / awaiting_approval / stopped)を返す。待つコマンドは無い | 状態が変わるか配布の依頼が届くまで戻らない待ちのコマンドを足す(`supervise.py wait` と同じ使い方) | +| 配布のまとめ | レーンのループの `pipeline` は開発版 → 承認ゲート 2 → 本番まで流す(`loop.md`)。11-02 は先に出したレーンが develop にある他のレーンの変更も出した | 並列の計画の下では、レーンは承認ゲート 2 の手前で依頼のファイル(メインディレクトリの `.ndf/parallel/<計画名>/`)を置いて待ち、オーガナイザーが 1 回の承認ゲート 2 で 1 本の本番を出し、レーンへ結果を返す | +| 置き場 | 引継ぎ文書は `.ndf/handoff/`(`handoff.md`)、ループの記録は `.ndf/loop/<名>/`。どちらもメインディレクトリに置きコミットしない | 並列の計画と配布の依頼は `.ndf/parallel/<計画名>/`、レーンの引継ぎ文書は今の名前(ループなら `loop-<名>.md`)のままにする | +| 語 | 「統括役」は relay.py のハブ(ラッパー)を指す。実行主体の 3 層は conductor → supervisor → worker | 「オーガナイザー」は並列の計画を受け持つ conductor の役割名として用語集へ足す。統括役と混ぜない |