diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 797d090b2..7cc7875d6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.27.15): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.27.16): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/.ndf/mvv.json b/.ndf/mvv.json index 1f51e8fe0..9570521d6 100644 --- a/.ndf/mvv.json +++ b/.ndf/mvv.json @@ -98,6 +98,73 @@ } ], "body": "# ai-plugins のプロジェクト MVV\n\nこのリポジトリ(ai-plugins。主要プラグインは NDF。NDF を Web から使うサービス Factory も置く)の Mission(何のためにやるか)・Vision(目指す姿)・Value(判断の基準)。AI と人がこのプロジェクトで判断するときの基準にする。判断の記録から候補を抽出した根拠は `issues/mvv-candidates-ai-plugins.md` にある。`.ndf/` へ置く形は #1366 の設計が決める。\n\n## NDF の共通原則との関係\n\nこの MVV は、NDF の共通原則(`issues/ndf-common-principles.md`。上位の原則「人を守り、人の発展を支える」、優先順位、AI が自分で取れる行動は「進める / 止めて人へ戻す」の 2 つだけ、人と AI の対話、必ず承認が要る操作 C1〜C8)の範囲で定める。ai-plugins は NDF を作るプロジェクトであると同時に、NDF を使うプロジェクトの 1 つでもある。**共通原則の優先順位と「必ず承認が要る操作」C1〜C8 は、この MVV のどの項目よりも先に効く。** 以下の Vision と Value は、その範囲でだけ読む。\n\n## Mission(何のためにやるか)\n\nClaude Code / Codex を使うチームに、プラグインとして、また Web から操作するサービス(Factory)として、要求から本番の配布までの開発工程を「人の判断が要る所だけで止まる」形で届け、開発者が価値を利用者へ届けるまでの時間と費用を減らす。Kiro と agy へは配らず、レビューと提案の相手役として起動するだけにする。\n\n## Vision(目指す姿)\n\n開発者が課題を 1 つ指せば、設計・実装・レビュー・検査・配布までを NDF が回し、人が手を止めるのは、設計の承認と本番への配布の承認の 2 回(と、共通原則が必ず承認を要するとする操作)に寄る。その 2 回には、決めたこと・未検証のまま残ること・MVV に照らした判定がそろって届き、人は問い直さずに 1 回で決められる。2 回の承認を MVV 判定に任せるかは、利用者が進め方ごとに事前に選ぶ(`.ndf/pace.json` の許可と MVV の承認。利用者の承認による委任であり、MVV が利用者の承認に代わるのではない)。共通原則の C1〜C8 と固有の P1 に当たる操作は任せず、配布が他のリポジトリへの公開(C5)に当たるときも任せない。任せた判定の記録と、人が判定を覆した回数は版ごとに見える。言語・テスト・ブランチ運用・配布の手段が違うどのリポジトリでも、プロジェクトごとの設定(`.ndf/`)を 1 つ置けば同じ工程がそのまま通る。効果は版ごとの実測(費用・秒・件数)で示され、チームの内外の誰もがその数値を見て版を選べる。Factory では、GitHub のリポジトリを登録すれば同じスプリントが並列に動き、進捗が Web の一覧と詳細で見え、承認ゲートで知らせが届く。マーケットプレイスの他のプラグイン(playwright-kit・mcp-*)と Factory も同じ基準で判断する。\n\n## Value(判断の基準)\n\n1. **まず届ける**: 完璧なものより「たぶん動く」ものを早く出す。開発版のチャネル・導入の確認・自動テストで守り、見つけた不具合は 4 条件(原因が分かっている・既存の約束事を壊さない・既存の方針の範囲・1 回の revert で戻せる)を満たせばその場で直す。処理の改善・削減・高速化に寄与しない指摘は直さない。ただし、人を害する・秘密・利用者のデータ・戻せない操作に関わる指摘(共通原則の範囲)は、効率に寄与しなくても直す。\n2. **利用者の手を止めない**: 人の承認は「設計」と「本番への配布」の 2 回に寄せ、再起動・告知・確認で操作を求めない。承認を求めるときは、判断の材料(決めたこと・未検証のまま残ること・残る危険)を資料に集めてから求め、材料が無いときはどれが無いかを示す。共通原則の C1〜C8 と固有の P1 に当たる操作の承認は、この 2 回とは別に必ず残す。それ以外の操作を実行前に確認するかどうかは「取り消せるか・他者が読む内容を外へ出すか・判断を問うか」の 3 つの問いで決める。\n3. **測って決める**: 効果は実測(費用・秒・件数)で示し、測らずに良くなったとは言わない。手順やコードに書くコマンドは書く前に実行して確かめる。\n4. **決まった手順はスクリプトに、判断と中身は AI に**: 待ち・集計・検査・記録・整形・起動の組み立てのように判断の要らない手順はスクリプトが進め、AI(LLM)は判断と中身(コード・設計文書)だけを担う。conductor がプランの外で手当てをしたら、その形と回数を記録に残す。同じ手当てが 2 度目になった時点で、雛形へ入れる課題を起こす。守れているかは、振り返りで手当ての件数として数える。\n5. **どのプロジェクトでも使える形を保つ**: スクリプトにするのは手順の骨組みだけにし、プロジェクトごとに違うもの(テストのコマンド・起点のブランチ・配布の手段・言語・ランタイム・課題の置き場)は設定か引数で受ける。ai-plugins の形を既定に埋め込まない。\n6. **根本原因の場所で直す**: 現象ごとに直さず、直す場所が現象の場所と違うときは共通の層へ寄せてから設計する。同じ役割の定数・関数を Skill ごとに分けない。\n7. **判断の理由を残す**: 変更点の列挙とは別に「何を決め、なぜそう決めたか」を版・設計文書・振り返りに残し、同じ判断を繰り返さない。正本は 1 か所に置き、写さずに指す。\n8. **語は 1 語 1 意味**: 業界に流通している語を用語集で 1 語 1 意味に揃える。使う言語は `AGENTS.md` のポリシーが決める。\n9. **試すものと本体を分ける**: 呼んだときだけ働く仕組みは実験版として、課題を起こさずその場で試し、台帳に使った結果と行き先を残す。効いたと実測で示せたものだけを、設計・レビュー・版の経路で本体へ移す(`AGENTS.md` の「安定版と実験版」)。\n10. **費用 > 速さ > 品質の順で決める**: 品質を上げる工程(レビューのラウンド・検査・やり直し)は、変更や周回ごとに決めた費用と時間の上限の中でだけ行う。上限は実測(費用・秒)で決めて見直す。上限を超えてまで行いたい品質の向上は、その場で続けずに別の Issue へ起こし、後の作業へ回す。ただし品質を置き去りにしない。後へ回した品質の Issue は、棚卸しで決めた基準(利用者へ届いた不具合か・同じ手当てや失敗の繰り返しの回数・後へ回してからの期間など。閾値は棚卸しの手順が持つ)で順位を調整する。この順位は効率の範囲の中での順位であり、人を害する・秘密・利用者のデータ・戻せない操作に関わる品質(共通原則の範囲)と、承認資料に載せる材料の正しさには当てない。\n\n## 必ず人の承認が要る操作(ai-plugins に固有のもの)\n\nNDF の共通原則の C1〜C8 に加えて、このプロジェクトでは次の操作も人の承認を省かない。\n\n| # | 操作 | 理由 |\n| --- | --- | --- |\n| P1 | 配布物の取得元・版の規則を変える(マーケットプレイスの登録の置き換え、既定ブランチ・版の規則の変更) | 利用者全員に効き、戻すには利用者側の操作が要る(`AGENTS.md` の版と配布の方針) |\n\n## 改訂\n\nMVV は進化する。改訂は利用者の承認で成立し、版・理由・前の版との差分を記録に残す。方向の誤りは、人が AI の判断を覆した回数・AI が決められなかった回数・検査をすり抜けて利用者へ届いた不具合の件数で検出し、閾値を超えたら改訂を提案する。手順の正本は `development-workflow` の `references/project-mvv.md` の「改訂」にある。\n" + }, + { + "version": 5, + "sha256": "e74e0d3b32838c6803902e783bca260e75d6f3c7b7a512fa4a05ed59bcd51ee9", + "approved_at": "2026-11-04T04:41:46+00:00", + "approved_by": "user", + "reason": "無駄な作業の根本原因の対策(#2436 の振り返りで覆し 4 件が閾値 3 を超えた。量の上限・設計の承認の先頭で要否・やめる既定・外の利用で確かめる)", + "vet": { + "verdict": "follow", + "at": "2026-11-04T04:38:52+00:00", + "accepted_unknown": false + }, + "changes": [ + { + "item": "Mission", + "kind": "changed" + }, + { + "item": "Vision", + "kind": "changed" + }, + { + "item": "Value 1", + "kind": "changed" + }, + { + "item": "Value 2", + "kind": "changed" + }, + { + "item": "Value 3", + "kind": "changed" + }, + { + "item": "Value 4", + "kind": "changed" + }, + { + "item": "Value 5", + "kind": "changed" + }, + { + "item": "Value 6", + "kind": "changed" + }, + { + "item": "Value 7", + "kind": "changed" + }, + { + "item": "Value 8", + "kind": "changed" + }, + { + "item": "Value 9", + "kind": "changed" + }, + { + "item": "P1", + "kind": "changed" + }, + { + "item": "Value 10", + "kind": "removed" + } + ], + "body": "# ai-plugins のプロジェクト MVV\n\nこのリポジトリ(ai-plugins。主要プラグインは NDF。NDF を Web から使うサービス Factory も置く)の Mission(何のためにやるか)・Vision(目指す姿)・Value(判断の基準)。AI と人がこのプロジェクトで判断するときの基準にする。判断の記録から候補を抽出した根拠は `issues/mvv-candidates-ai-plugins.md` にある。`.ndf/` へ置く形は #1366 の設計が決める。\n\n## NDF の共通原則との関係\n\nこの MVV は、NDF の共通原則(`issues/ndf-common-principles.md`。上位の原則「人を守り、人の発展を支える」、優先順位、AI が自分で取れる行動は「進める / 止めて人へ戻す」の 2 つだけ、人と AI の対話、必ず承認が要る操作 C1〜C8)の範囲で定める。ai-plugins は NDF を作るプロジェクトであると同時に、NDF を使うプロジェクトの 1 つでもある。**共通原則の優先順位と「必ず承認が要る操作」C1〜C8 は、この MVV のどの項目よりも先に効く。** 以下の Vision と Value は、その範囲でだけ読む。\n\n## Mission\n\nClaude Code / Codex を使うチームに、プラグインとして、また Web から操作するサービス(Factory)として、要求から本番の配布までの開発工程を「人の判断が要る所だけで止まる」形で届け、開発者が価値を利用者へ届けるまでの時間と費用を減らす。良くなったかは、NDF の外のプロジェクトで実際に使われた結果で確かめる。Kiro と agy へは配らず、レビューと提案の相手役として起動するだけにする。\n\n## Vision\n\n開発者が課題を 1 つ指せば、設計・実装・レビュー・検査・配布までを NDF が回す。人が手を止めるのは、設計の承認と本番への配布の承認の 2 回と、必ず承認が要る操作に限られる。設計の承認では、設計の中身より先に、それを作る要否を数行で決める。1 回のスプリントで作る量(課題の数と承認資料の分量)は、人が 1 回の承認で読み切れる量に収まる。2 回の承認を MVV 判定に任せるかは、利用者が進め方ごとに事前に選ぶ(`.ndf/pace.json` の許可と MVV の承認。利用者の承認による委任であり、MVV が利用者の承認に代わるのではない)。共通原則の C1〜C8 と固有の P1 に当たる操作は任せず、配布が他のリポジトリへの公開(C5)に当たるときも任せない。承認には、決めたこと・未検証のまま残ること・MVV に照らした判定が、番号でなく中身の分かる語でそろって届く。出した版は実際に使って確かめ、確かめた結果が次に何を作り何をやめるかを決める。ai-plugins の中より、NDF の外のリポジトリでの利用の手がかりを先に拾う。言語・テスト・ブランチ運用・配布の手段が違うどのリポジトリでも、プロジェクトの設定(`.ndf/`)を 1 つ置けば同じ工程が通る。Factory でも同じ基準で判断する。\n\n## Value\n\n1. **小さく出して使って確かめる**: 完璧なものより、小さく「たぶん動く」ものを早く出す。開発版のチャネル・導入の確認・自動テストで守る。出した後は実際に使って受け入れ条件を確かめ、確かめていないものを済んだと記録しない。見つけた不具合は 4 条件を満たせばその場で直す。人を害する・秘密・利用者のデータ・戻せない操作に関わる指摘は、効率に寄与しなくても直す。\n2. **人が判断できる量に収める**: 一度に作る量は、スプリントの課題の数と承認資料の分量で測り、その上限を人が 1 回の承認で読み切れる量で決める(数値は実測から決めて見直す)。上限に収まらないものは、始める前に分けるか縮める。承認は「設計」と「本番への配布」の 2 回に寄せ、設計の承認の先頭で作る要否を数行で問う。再起動・告知・確認で操作を求めない。AI が決められないときは、理由と根拠を添えて人へ戻す。共通原則の C1〜C8 と固有の P1 に当たる操作の承認は、この 2 回とは別に必ず残す。承認を求めるときは判断の材料を先に集め、番号と内部の呼び名だけで書かない。\n3. **やめる・縮めるを既定にする**: 上限(費用・秒・ラウンド・量)に当たったら、続けずにやめるか縮める。それを既定にし、残りを新しい Issue へ送ることを既定にしない。起票するのは、利用者へ届いた不具合か、人の安全・秘密・利用者のデータ・戻せない操作・承認資料の正しさに関わるものに限る。役に立たない繰り返し(同じ指摘の出直し、指摘 0 件でのラウンドの追加)は始めない。\n4. **外の利用で測って決める**: 効果は実測(費用・秒・件数)で示し、測らずに良くなったとは言わない。何を作るかの順位は、NDF の外のプロジェクトでの利用と、そこで見つかった不具合を先に置く。手順やコードに書くコマンドは、書く前に実行して確かめる。\n5. **決まった手順はスクリプトに、判断と中身は AI に**: 判断の要らない手順(待ち・集計・検査・記録・整形・起動の組み立て)はスクリプトが進め、AI は判断と中身だけを担う。同じ手当てが 2 度目になったら、まずその手順自体を消せないかを問う。消せないときだけ雛形へ入れる。決まりを足すより、要らない決まりを減らす方を選ぶ。\n6. **どのプロジェクトでも使える形を保つ**: スクリプトにするのは手順の骨組みだけにする。プロジェクトごとに違うもの(テストのコマンド・起点のブランチ・配布の手段・言語・ランタイム・課題の置き場)は、設定か引数で受ける。ai-plugins の形を既定に埋め込まない。\n7. **根本原因の場所で直す**: 現象ごとに直さない。直す場所が現象の場所と違うときは、共通の層へ寄せてから設計する。同じ役割の定数・関数を Skill ごとに分けない。\n8. **判断の理由を残し、語をそろえる**: 変更点の列挙とは別に、「何を決め、なぜそう決めたか」を、版・設計文書・振り返りに残す。正本は 1 か所に置き、写さずに指す。語は業界に流通している語を用語集で 1 語 1 意味にそろえ、番号には題か中身を添える。\n9. **試すものと本体を分ける**: 呼んだときだけ働く仕組みは実験版として、課題を起こさずその場で試し、台帳に使った結果と行き先を残す。実際に使って効いたと示せたものだけを、本体へ移す。\n\n## 必ず人の承認が要る操作\n\n| # | 操作 | 理由 |\n| --- | --- | --- |\n| P1 | 配布物の取得元・版の規則を変える(マーケットプレイスの登録の置き換え、既定ブランチ・版の規則の変更) | 利用者全員に効き、戻すには利用者側の操作が要る |\n\n## 改訂\n\nMVV は進化する。改訂は利用者の承認で成立し、版・理由・前の版との差分を記録に残す。方向の誤りは、人が AI の判断を覆した回数・AI が決められなかった回数・検査をすり抜けて利用者へ届いた不具合の件数で検出し、閾値を超えたら改訂を提案する。手順の正本は `development-workflow` の `references/project-mvv.md` の「改訂」にある。\n" } ] } \ No newline at end of file diff --git a/.ndf/mvv.md b/.ndf/mvv.md index ae7dc40a9..66272b0c7 100644 --- a/.ndf/mvv.md +++ b/.ndf/mvv.md @@ -6,34 +6,31 @@ この MVV は、NDF の共通原則(`issues/ndf-common-principles.md`。上位の原則「人を守り、人の発展を支える」、優先順位、AI が自分で取れる行動は「進める / 止めて人へ戻す」の 2 つだけ、人と AI の対話、必ず承認が要る操作 C1〜C8)の範囲で定める。ai-plugins は NDF を作るプロジェクトであると同時に、NDF を使うプロジェクトの 1 つでもある。**共通原則の優先順位と「必ず承認が要る操作」C1〜C8 は、この MVV のどの項目よりも先に効く。** 以下の Vision と Value は、その範囲でだけ読む。 -## Mission(何のためにやるか) +## Mission -Claude Code / Codex を使うチームに、プラグインとして、また Web から操作するサービス(Factory)として、要求から本番の配布までの開発工程を「人の判断が要る所だけで止まる」形で届け、開発者が価値を利用者へ届けるまでの時間と費用を減らす。Kiro と agy へは配らず、レビューと提案の相手役として起動するだけにする。 +Claude Code / Codex を使うチームに、プラグインとして、また Web から操作するサービス(Factory)として、要求から本番の配布までの開発工程を「人の判断が要る所だけで止まる」形で届け、開発者が価値を利用者へ届けるまでの時間と費用を減らす。良くなったかは、NDF の外のプロジェクトで実際に使われた結果で確かめる。Kiro と agy へは配らず、レビューと提案の相手役として起動するだけにする。 -## Vision(目指す姿) +## Vision -開発者が課題を 1 つ指せば、設計・実装・レビュー・検査・配布までを NDF が回し、人が手を止めるのは、設計の承認と本番への配布の承認の 2 回(と、共通原則が必ず承認を要するとする操作)に寄る。その 2 回には、決めたこと・未検証のまま残ること・MVV に照らした判定がそろって届き、人は問い直さずに 1 回で決められる。2 回の承認を MVV 判定に任せるかは、利用者が進め方ごとに事前に選ぶ(`.ndf/pace.json` の許可と MVV の承認。利用者の承認による委任であり、MVV が利用者の承認に代わるのではない)。共通原則の C1〜C8 と固有の P1 に当たる操作は任せず、配布が他のリポジトリへの公開(C5)に当たるときも任せない。任せた判定の記録と、人が判定を覆した回数は版ごとに見える。言語・テスト・ブランチ運用・配布の手段が違うどのリポジトリでも、プロジェクトごとの設定(`.ndf/`)を 1 つ置けば同じ工程がそのまま通る。効果は版ごとの実測(費用・秒・件数)で示され、チームの内外の誰もがその数値を見て版を選べる。Factory では、GitHub のリポジトリを登録すれば同じスプリントが並列に動き、進捗が Web の一覧と詳細で見え、承認ゲートで知らせが届く。マーケットプレイスの他のプラグイン(playwright-kit・mcp-*)と Factory も同じ基準で判断する。 +開発者が課題を 1 つ指せば、設計・実装・レビュー・検査・配布までを NDF が回す。人が手を止めるのは、設計の承認と本番への配布の承認の 2 回と、必ず承認が要る操作に限られる。設計の承認では、設計の中身より先に、それを作る要否を数行で決める。1 回のスプリントで作る量(課題の数と承認資料の分量)は、人が 1 回の承認で読み切れる量に収まる。2 回の承認を MVV 判定に任せるかは、利用者が進め方ごとに事前に選ぶ(`.ndf/pace.json` の許可と MVV の承認。利用者の承認による委任であり、MVV が利用者の承認に代わるのではない)。共通原則の C1〜C8 と固有の P1 に当たる操作は任せず、配布が他のリポジトリへの公開(C5)に当たるときも任せない。承認には、決めたこと・未検証のまま残ること・MVV に照らした判定が、番号でなく中身の分かる語でそろって届く。出した版は実際に使って確かめ、確かめた結果が次に何を作り何をやめるかを決める。ai-plugins の中より、NDF の外のリポジトリでの利用の手がかりを先に拾う。言語・テスト・ブランチ運用・配布の手段が違うどのリポジトリでも、プロジェクトの設定(`.ndf/`)を 1 つ置けば同じ工程が通る。Factory でも同じ基準で判断する。 -## Value(判断の基準) +## Value -1. **まず届ける**: 完璧なものより「たぶん動く」ものを早く出す。開発版のチャネル・導入の確認・自動テストで守り、見つけた不具合は 4 条件(原因が分かっている・既存の約束事を壊さない・既存の方針の範囲・1 回の revert で戻せる)を満たせばその場で直す。処理の改善・削減・高速化に寄与しない指摘は直さない。ただし、人を害する・秘密・利用者のデータ・戻せない操作に関わる指摘(共通原則の範囲)は、効率に寄与しなくても直す。 -2. **利用者の手を止めない**: 人の承認は「設計」と「本番への配布」の 2 回に寄せ、再起動・告知・確認で操作を求めない。承認を求めるときは、判断の材料(決めたこと・未検証のまま残ること・残る危険)を資料に集めてから求め、材料が無いときはどれが無いかを示す。共通原則の C1〜C8 と固有の P1 に当たる操作の承認は、この 2 回とは別に必ず残す。それ以外の操作を実行前に確認するかどうかは「取り消せるか・他者が読む内容を外へ出すか・判断を問うか」の 3 つの問いで決める。 -3. **測って決める**: 効果は実測(費用・秒・件数)で示し、測らずに良くなったとは言わない。手順やコードに書くコマンドは書く前に実行して確かめる。 -4. **決まった手順はスクリプトに、判断と中身は AI に**: 待ち・集計・検査・記録・整形・起動の組み立てのように判断の要らない手順はスクリプトが進め、AI(LLM)は判断と中身(コード・設計文書)だけを担う。conductor がプランの外で手当てをしたら、その形と回数を記録に残す。同じ手当てが 2 度目になった時点で、雛形へ入れる課題を起こす。守れているかは、振り返りで手当ての件数として数える。 -5. **どのプロジェクトでも使える形を保つ**: スクリプトにするのは手順の骨組みだけにし、プロジェクトごとに違うもの(テストのコマンド・起点のブランチ・配布の手段・言語・ランタイム・課題の置き場)は設定か引数で受ける。ai-plugins の形を既定に埋め込まない。 -6. **根本原因の場所で直す**: 現象ごとに直さず、直す場所が現象の場所と違うときは共通の層へ寄せてから設計する。同じ役割の定数・関数を Skill ごとに分けない。 -7. **判断の理由を残す**: 変更点の列挙とは別に「何を決め、なぜそう決めたか」を版・設計文書・振り返りに残し、同じ判断を繰り返さない。正本は 1 か所に置き、写さずに指す。 -8. **語は 1 語 1 意味**: 業界に流通している語を用語集で 1 語 1 意味に揃える。使う言語は `AGENTS.md` のポリシーが決める。 -9. **試すものと本体を分ける**: 呼んだときだけ働く仕組みは実験版として、課題を起こさずその場で試し、台帳に使った結果と行き先を残す。効いたと実測で示せたものだけを、設計・レビュー・版の経路で本体へ移す(`AGENTS.md` の「安定版と実験版」)。 -10. **費用 > 速さ > 品質の順で決める**: 品質を上げる工程(レビューのラウンド・検査・やり直し)は、変更や周回ごとに決めた費用と時間の上限の中でだけ行う。上限は実測(費用・秒)で決めて見直す。上限を超えてまで行いたい品質の向上は、その場で続けずに別の Issue へ起こし、後の作業へ回す。ただし品質を置き去りにしない。後へ回した品質の Issue は、棚卸しで決めた基準(利用者へ届いた不具合か・同じ手当てや失敗の繰り返しの回数・後へ回してからの期間など。閾値は棚卸しの手順が持つ)で順位を調整する。この順位は効率の範囲の中での順位であり、人を害する・秘密・利用者のデータ・戻せない操作に関わる品質(共通原則の範囲)と、承認資料に載せる材料の正しさには当てない。 +1. **小さく出して使って確かめる**: 完璧なものより、小さく「たぶん動く」ものを早く出す。開発版のチャネル・導入の確認・自動テストで守る。出した後は実際に使って受け入れ条件を確かめ、確かめていないものを済んだと記録しない。見つけた不具合は 4 条件を満たせばその場で直す。人を害する・秘密・利用者のデータ・戻せない操作に関わる指摘は、効率に寄与しなくても直す。 +2. **人が判断できる量に収める**: 一度に作る量は、スプリントの課題の数と承認資料の分量で測り、その上限を人が 1 回の承認で読み切れる量で決める(数値は実測から決めて見直す)。上限に収まらないものは、始める前に分けるか縮める。承認は「設計」と「本番への配布」の 2 回に寄せ、設計の承認の先頭で作る要否を数行で問う。再起動・告知・確認で操作を求めない。AI が決められないときは、理由と根拠を添えて人へ戻す。共通原則の C1〜C8 と固有の P1 に当たる操作の承認は、この 2 回とは別に必ず残す。承認を求めるときは判断の材料を先に集め、番号と内部の呼び名だけで書かない。 +3. **やめる・縮めるを既定にする**: 上限(費用・秒・ラウンド・量)に当たったら、続けずにやめるか縮める。それを既定にし、残りを新しい Issue へ送ることを既定にしない。起票するのは、利用者へ届いた不具合か、人の安全・秘密・利用者のデータ・戻せない操作・承認資料の正しさに関わるものに限る。役に立たない繰り返し(同じ指摘の出直し、指摘 0 件でのラウンドの追加)は始めない。 +4. **外の利用で測って決める**: 効果は実測(費用・秒・件数)で示し、測らずに良くなったとは言わない。何を作るかの順位は、NDF の外のプロジェクトでの利用と、そこで見つかった不具合を先に置く。手順やコードに書くコマンドは、書く前に実行して確かめる。 +5. **決まった手順はスクリプトに、判断と中身は AI に**: 判断の要らない手順(待ち・集計・検査・記録・整形・起動の組み立て)はスクリプトが進め、AI は判断と中身だけを担う。同じ手当てが 2 度目になったら、まずその手順自体を消せないかを問う。消せないときだけ雛形へ入れる。決まりを足すより、要らない決まりを減らす方を選ぶ。 +6. **どのプロジェクトでも使える形を保つ**: スクリプトにするのは手順の骨組みだけにする。プロジェクトごとに違うもの(テストのコマンド・起点のブランチ・配布の手段・言語・ランタイム・課題の置き場)は、設定か引数で受ける。ai-plugins の形を既定に埋め込まない。 +7. **根本原因の場所で直す**: 現象ごとに直さない。直す場所が現象の場所と違うときは、共通の層へ寄せてから設計する。同じ役割の定数・関数を Skill ごとに分けない。 +8. **判断の理由を残し、語をそろえる**: 変更点の列挙とは別に、「何を決め、なぜそう決めたか」を、版・設計文書・振り返りに残す。正本は 1 か所に置き、写さずに指す。語は業界に流通している語を用語集で 1 語 1 意味にそろえ、番号には題か中身を添える。 +9. **試すものと本体を分ける**: 呼んだときだけ働く仕組みは実験版として、課題を起こさずその場で試し、台帳に使った結果と行き先を残す。実際に使って効いたと示せたものだけを、本体へ移す。 -## 必ず人の承認が要る操作(ai-plugins に固有のもの) - -NDF の共通原則の C1〜C8 に加えて、このプロジェクトでは次の操作も人の承認を省かない。 +## 必ず人の承認が要る操作 | # | 操作 | 理由 | | --- | --- | --- | -| P1 | 配布物の取得元・版の規則を変える(マーケットプレイスの登録の置き換え、既定ブランチ・版の規則の変更) | 利用者全員に効き、戻すには利用者側の操作が要る(`AGENTS.md` の版と配布の方針) | +| P1 | 配布物の取得元・版の規則を変える(マーケットプレイスの登録の置き換え、既定ブランチ・版の規則の変更) | 利用者全員に効き、戻すには利用者側の操作が要る | ## 改訂 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e82861d9..7639ea194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.27.16] - 2026-11-04 + +- Docs: #2436 の並列のオーガナイザーを確定仕様にする(#2471) +- Update: プロジェクト MVV を版 5 にする(量の上限・やめる既定・使って確かめる)(#2472) +- Docs: #2439 の要求の写しを本文に合わせる(#2473) +- Docs: リリース後テストの印は最初の時刻を保つことを確定仕様に書く(#2476) +- 並列の実行(`parallel.py`)に `--external` があり、NDF の外のオーガナイザーへ切り替えられる(#2483)(#2485) +- Factory のオーガナイザーは、配布の依頼をまとめて実行し、結果を待ち、途中から再開できる。配布の一覧と履歴も確認できる(#2483)(#2485) +- Factory の説明文と画面の文言は「手元のマシン」と「料金プラン」の語で表示する(#2483)(#2485) +- factory の README に、Factory のオーガナイザーの置き場と経路を載せている(#2483)(#2485) +- スプリントの一覧で、各スプリントが今どのフェーズにあるかを確認できる。フェーズの名前は 5 つに揃えて表示する(#2484)(#2485) +- スプリントの詳細画面の工程の詳細パネルで、その工程で触ったファイル・決めたこと・作業の報告を確認できる。これらの中身は伏せ字の処理を通してから表示する(#2484)(#2485) +- バックログの画面で、スプリントのバックログを確認できる(#2484)(#2485) +- スプリントの一覧・詳細・バックログの画面は、表示している内容を一定の間隔で読み直す(#2484)(#2485) +- スプリントの一覧を返す経路は、保存に失敗したときに 503 を返す(#2484)(#2485) + +### 移行の手順 + +- 無し(工程の中身は任意の項目として足すため、事象の形式の版(`schema_version`)は上げない。今のランナーが出す事象と、既に残っている事象はそのまま読める)(#2484)(#2485) + ## [ndf 10.27.15] - 2026-11-03 - Docs: 発表資料「AI が回すソフトウェア工場」を 2026-11-03 時点で更新する(#2450) diff --git a/README.md b/README.md index 8de4787b8..ceceef30f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.15 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.16 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | | **playwright-kit** | 4.0.0 | Playwright による E2E テストの計画・スクリプト作成・証跡のレポート | [README](./plugins/playwright-kit/README.md) | 外部サービスへつなぐ MCP サーバーの設定のプラグインが 10 個あります(Claude Code / Codex 向け)。 @@ -69,7 +69,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.27.15** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.16** に入る Skill の数は、AI ツールごとに違います。 Claude Code向け core 49個、Codex向け core 45個です。 元Skills(49個)の内訳は次のとおりです。 - Pull Request とレビュー (7): pr, pr-tests, fix, pr-review, cherry-pick-pr, deploy, merged diff --git a/docs/glossary.md b/docs/glossary.md index 2f6071b3f..a3ddcf79f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -892,10 +892,10 @@ NDF を Web UI から操作できるサービスとして提供するときの | スプリントの詳細 | — | 1 つのスプリントの実行の、課題ごとの工程・Pull Request・CI の結果・承認ゲートの状態を出す画面(#2172) | — | — | — | | 詳細パネル | — | スプリントの詳細で工程の行を押すと右側に出る、その工程の中身(触ったファイル・決めたこと・終了コード・秒・費用・作業の報告)の欄(#2172) | — | — | — | | バックログの画面 | — | 取り込んだリポジトリの順位の表と棚卸しの記録を、スプリントの外で読む画面(#2172) | — | — | — | -| フェーズの名前 | — | phase.changed と step.recorded の phase が運ぶ、設計・実装・検査・取り込み・仕上げの 5 つ。factory/contracts/src/phases.ts の列挙が正。ほかの名前も捨てずに残し、画面は「その他(<名前>)」と出す(#2172) | — | — | — | -| 工程の記録 | — | スプリントの詳細に並ぶ step.recorded の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる(#2172) | — | — | — | -| 工程の中身 | — | 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す(#2172) | — | — | — | -| スプリント全体の欄 | — | スプリントの詳細で、課題の番号を持たない工程の記録を並べる欄(#2172) | — | — | — | +| フェーズの名前 | — | phase.changed と step.recorded の phase が運ぶ、設計・実装・検査・取り込み・仕上げの 5 つ。factory/contracts/src/phases.ts の列挙が正。ほかの名前も捨てずに残し、画面は「その他(<名前>)」と出す(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | +| 工程の記録 | — | スプリントの詳細に並ぶ step.recorded の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | +| 工程の中身 | — | 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | +| スプリント全体の欄 | — | スプリントの詳細で、課題の番号を持たない工程の記録を並べる欄(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | | 区間 | — | 1 つの実行ジョブの中でランナーが起動する claude -p の 1 回分。conductor の ndf-next で次の区間へ移る | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 区間の上限 | — | 1 つの実行ジョブで起動してよい区間の数の上限(既定 20)。超えたジョブは section_limit で終わる | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 同時のジョブの数 | `max_jobs` | 手元のマシンが同時に動かす実行ジョブの数の上限。手元のマシンが登録と心拍で申告し、サービスはそれを超えて指示を渡さない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index f34e1760d..59f37c2d1 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4735,29 +4735,25 @@ "term": "フェーズの名前", "context": "ndf-factory", "meaning": "phase.changed と step.recorded の phase が運ぶ、設計・実装・検査・取り込み・仕上げの 5 つ。factory/contracts/src/phases.ts の列挙が正。ほかの名前も捨てずに残し、画面は「その他(<名前>)」と出す(#2172)", - "source": "", - "pending_source": "issues/issue-2172-design.md" + "source": "docs/specifications/ndf-factory-service-boundary.md" }, { "term": "工程の記録", "context": "ndf-factory", "meaning": "スプリントの詳細に並ぶ step.recorded の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる(#2172)", - "source": "", - "pending_source": "issues/issue-2172-design.md" + "source": "docs/specifications/ndf-factory-service-boundary.md" }, { "term": "工程の中身", "context": "ndf-factory", "meaning": "1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す(#2172)", - "source": "", - "pending_source": "issues/issue-2172-design.md" + "source": "docs/specifications/ndf-factory-service-boundary.md" }, { "term": "スプリント全体の欄", "context": "ndf-factory", "meaning": "スプリントの詳細で、課題の番号を持たない工程の記録を並べる欄(#2172)", - "source": "", - "pending_source": "issues/issue-2172-design.md" + "source": "docs/specifications/ndf-factory-service-boundary.md" }, { "term": "相手役", diff --git a/docs/metrics/ndf-token-usage/2026-11-04.json b/docs/metrics/ndf-token-usage/2026-11-04.json new file mode 100644 index 000000000..f05b5d7c7 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-04.json @@ -0,0 +1,1512 @@ +{ + "per_pr": [ + { + "version": "10.27.14", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.14", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 8, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 12, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "sessions": 3, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "sessions": 6, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 4171825.35, + "supervisor_cost": 230606.95, + "worker_cost": 15982.45, + "context": 33455140.0, + "out": 113847.0, + "minutes": 124.12879999876023, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 1450376.1500000001, + "supervisor_cost": 93896.65, + "worker_cost": 2128754.45, + "context": 35761494.0, + "out": 129836.0, + "minutes": 185.8829666654269, + "codex_input": 2762603.0, + "codex_out": 12374.0, + "kiro_credit": 0.0, + "claude_seat_cost": 1397183.6500000001 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "sessions": 2, + "sessions_with_pr": 1, + "prs": 3, + "conductor_cost": 575824.1999999998, + "supervisor_cost": 1125546.6666666667, + "worker_cost": 398174.8166666667, + "context": 17323703.666666668, + "out": 80678.66666666667, + "minutes": 66.12105555534363, + "codex_input": 6232840.0, + "codex_out": 13218.333333333334, + "kiro_credit": 0.0, + "claude_seat_cost": 326214.6166666667 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 19, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 3, + "conductor_cost": 802454.7666666666, + "supervisor_cost": 634856.4166666666, + "worker_cost": 0.0, + "context": 13893558.333333334, + "out": 47368.333333333336, + "minutes": 51.90099444521798, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 23, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 648905.75, + "supervisor_cost": 0.0, + "worker_cost": 0.0, + "context": 5325894.0, + "out": 23884.0, + "minutes": 10.57671666542689, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + } + ], + "per_role": [ + { + "version": "10.27.14", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 5834211.699999999, + "context": 67467324.0, + "out": 192891.0, + "minutes": 396.5979500055313, + "p": 59125.0, + "k": 286.0, + "w5": 0.0, + "w1h": 767066.0, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 162133, + "rewrite_tokens_after_5m": 162133, + "read_tokens_after_5m": 2442763, + "rewrite_gap_median": 251.38503333528837 + }, + { + "version": "10.27.14", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 8, + "cost": 597208.2312500001, + "context": 4112205.25, + "out": 30221.0, + "minutes": 5.422079166769981, + "p": 63245.125, + "k": 29.375, + "w5": 0.0, + "w1h": 123300.375, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.14", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 3, + "cost": 24053.666666666668, + "context": 11432.0, + "out": 238.33333333333334, + "minutes": 0.08499999999999999, + "k": 1.0, + "w5": 0.0, + "w1h": 11430.0 + }, + { + "version": "10.27.14", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "Explore", + "count": 1, + "cost": 479496.25, + "context": 3645205.0, + "out": 16873.0, + "minutes": 2.733216667175293, + "p": 22421.0, + "k": 30.0, + "w5": 177345.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.14", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 1, + "cost": 355876.9, + "context": 2410698.0, + "out": 18300.0, + "minutes": 3.5766666666666667, + "k": 35.0, + "w5": 0.0, + "w1h": 73732.0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 12, + "cost": 631608.525, + "context": 5363592.75, + "out": 20661.5, + "minutes": 22.369047223528227, + "p": 58379.416666666664, + "k": 41.166666666666664, + "w5": 0.0, + "w1h": 133351.25, + "rewrites": 2, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 238086, + "rewrite_tokens_after_5m": 203643, + "read_tokens_after_5m": 1259355, + "rewrite_gap_median": 1180.558641664187 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 3, + "cost": 26260.666666666668, + "context": 12366.333333333334, + "out": 306.0, + "minutes": 0.10555555555555556, + "k": 1.0, + "w5": 0.0, + "w1h": 12364.333333333334 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 31806.0, + "context": 13501.5, + "out": 961.0, + "minutes": 0.17749999999999996, + "k": 1.0, + "w5": 0.0, + "w1h": 13499.5 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 2, + "cost": 95555.575, + "context": 81119.5, + "out": 5064.0, + "minutes": 0.8375750005245208, + "p": 25971.0, + "k": 2.0, + "w5": 55146.5, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 35745, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.42241666714350384 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 6, + "cost": 156385.69166666668, + "context": 773600.6666666666, + "out": 7048.0, + "minutes": 1.4133333333333333, + "k": 17.166666666666668, + "w5": 0.0, + "w1h": 42274.166666666664 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 3, + "cost": 2066599.8, + "context": 20189527.666666668, + "out": 81084.0, + "minutes": 92.74148333337573, + "p": 42265.666666666664, + "k": 115.33333333333333, + "w5": 0.0, + "w1h": 334075.6666666667, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 326267, + "rewrite_tokens_after_5m": 326267, + "read_tokens_after_5m": 983941, + "rewrite_gap_median": 310.9080333332221 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 4, + "cost": 28995.0, + "context": 13547.25, + "out": 380.5, + "minutes": 0.10458333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 13545.25 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 3, + "cost": 1329678.2666666666, + "context": 12323186.666666666, + "out": 85079.33333333333, + "minutes": 14.021288888984257, + "p": 39828.666666666664, + "k": 55.0, + "w5": 240009.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 8, + "cost": 155325.53125, + "context": 931603.125, + "out": 8316.5, + "minutes": 12.26875, + "k": 15.125, + "w5": 0.0, + "w1h": 34428.25 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 6, + "cost": 1709260.3500000003, + "context": 15375793.833333334, + "out": 50932.333333333336, + "minutes": 98.16985555489858, + "p": 46304.5, + "k": 81.66666666666667, + "w5": 0.0, + "w1h": 351605.1666666667, + "rewrites": 5, + "rewrites_after_5m": 5, + "rewrites_untimed": 0, + "rewrite_tokens": 977888, + "rewrite_tokens_after_5m": 977888, + "read_tokens_after_5m": 4246791, + "rewrite_gap_median": 82.70849999984105 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 1, + "cost": 37832.0, + "context": 14572.0, + "out": 1738.0, + "minutes": 0.29, + "k": 1.0, + "w5": 0.0, + "w1h": 14570.0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 4, + "cost": 439571.5625, + "context": 3467574.75, + "out": 18937.25, + "minutes": 5.604016666611035, + "p": 39808.75, + "k": 38.75, + "w5": 142860.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 120320, + "rewrite_tokens_after_5m": 120320, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 6.833383333683014 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 13, + "cost": 388545.8884615385, + "context": 2894218.4615384615, + "out": 18046.0, + "minutes": 12.107051282051282, + "k": 32.23076923076923, + "w5": 0.0, + "w1h": 78742.84615384616 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1450376.1500000001, + "context": 17406221.0, + "out": 42594.0, + "minutes": 185.8829666654269, + "p": 42054.0, + "k": 108.0, + "w5": 0.0, + "w1h": 188136.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 1169837, + "rewrite_gap_median": null + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 23471.0, + "context": 10569.0, + "out": 467.0, + "minutes": 0.12000000000000001, + "k": 1.0, + "w5": 0.0, + "w1h": 10567.0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 28970.225, + "context": 14794.0, + "out": 1618.0, + "minutes": 0.2641666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 10327.5 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "layer": "supervisor", + "role": "slow", + "agent_type": "claude -p", + "count": 2, + "cost": 6242.6, + "context": 4466.0, + "out": 333.0, + "minutes": 0.10416666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 2232.0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 9, + "cost": 236528.27222222224, + "context": 2034020.4444444445, + "out": 9208.111111111111, + "minutes": 7.2122222222222225, + "k": 19.11111111111111, + "w5": 0.0, + "w1h": 45513.666666666664 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 2, + "cost": 1219972.7249999999, + "context": 12898339.0, + "out": 45364.5, + "minutes": 142.62632499933244, + "p": 43382.5, + "k": 86.0, + "w5": 0.0, + "w1h": 178473.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 1897810, + "rewrite_gap_median": null + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 2, + "cost": 23871.5, + "context": 11320.5, + "out": 246.5, + "minutes": 0.08499999999999999, + "k": 1.0, + "w5": 0.0, + "w1h": 11318.5 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:supervisor-waits", + "count": 2, + "cost": 683517.6500000001, + "context": 6714933.0, + "out": 20952.5, + "minutes": 43.530224998792015, + "p": 47219.5, + "k": 58.0, + "w5": 0.0, + "w1h": 124553.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 666373, + "rewrite_gap_median": null + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 5, + "cost": 479728.9800000001, + "context": 3296146.4, + "out": 19737.8, + "minutes": 14.766906666755677, + "p": 38888.0, + "k": 34.0, + "w5": 180137.2, + "w1h": 0.0, + "rewrites": 4, + "rewrites_after_5m": 4, + "rewrites_untimed": 0, + "rewrite_tokens": 363420, + "rewrite_tokens_after_5m": 363420, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 10.28914166688919 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 10, + "cost": 272037.95, + "context": 1873421.2, + "out": 13462.5, + "minutes": 9.409333333333333, + "k": 25.5, + "w5": 0.0, + "w1h": 56927.0 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "layer": "worker", + "role": "その他", + "agent_type": "ndf:worker", + "count": 5, + "cost": 95287.73000000001, + "context": 417721.4, + "out": 3117.6, + "minutes": 0.6927400000890096, + "p": 38091.0, + "k": 8.2, + "w5": 48998.4, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 19, + "cost": 681193.5789473684, + "context": 4973849.052631579, + "out": 27582.57894736842, + "minutes": 17.362010525820548, + "p": 60439.10526315789, + "k": 36.21052631578947, + "w5": 0.0, + "w1h": 151034.63157894736, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 248519, + "rewrite_tokens_after_5m": 248519, + "read_tokens_after_5m": 1182029, + "rewrite_gap_median": 93.8481916685899 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 25246.0, + "context": 12034.0, + "out": 236.0, + "minutes": 0.08666666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 12032.0 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 31303.225, + "context": 14633.0, + "out": 2149.0, + "minutes": 0.35083333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 10166.5 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "その他", + "count": 1, + "cost": 19460.15, + "context": 12955.0, + "out": 1441.0, + "minutes": 0.1465333342552185, + "p": 3426.0, + "k": 2.0, + "w5": 9527.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 9, + "cost": 688351.4611111111, + "context": 6201794.444444444, + "out": 31866.555555555555, + "minutes": 6.383148148148147, + "k": 51.888888888888886, + "w5": 0.0, + "w1h": 112226.33333333333 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 2407364.3, + "context": 22167274.0, + "out": 58600.0, + "minutes": 155.70298333565395, + "p": 44902.0, + "k": 119.0, + "w5": 0.0, + "w1h": 515774.0, + "rewrites": 3, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 342605, + "rewrite_tokens_after_5m": 295416, + "read_tokens_after_5m": 811441, + "rewrite_gap_median": 263.9299999992053 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 2, + "cost": 952284.6249999999, + "context": 9756700.5, + "out": 41752.5, + "minutes": 12.307008334000905, + "p": 39178.0, + "k": 72.0, + "w5": 212957.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 23, + "cost": 376147.10869565216, + "context": 2509537.6086956523, + "out": 13296.173913043478, + "minutes": 13.077833333049995, + "p": 58005.391304347824, + "k": 23.17391304347826, + "w5": 0.0, + "w1h": 94432.73913043478, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 101247, + "rewrite_tokens_after_5m": 101247, + "read_tokens_after_5m": 723101, + "rewrite_gap_median": 344.88306666612624 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 2, + "cost": 27939.0, + "context": 13383.0, + "out": 235.0, + "minutes": 0.12666666666666665, + "k": 1.0, + "w5": 0.0, + "w1h": 13381.0 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 3, + "cost": 67787.03333333334, + "context": 29877.333333333332, + "out": 1755.3333333333333, + "minutes": 0.33611111111111114, + "k": 1.0, + "w5": 0.0, + "w1h": 29494.666666666668 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 5, + "cost": 18465.760000000002, + "context": 13548.0, + "out": 1060.2, + "minutes": 0.22599999999999998, + "k": 1.0, + "w5": 0.0, + "w1h": 6402.8 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 2, + "cost": 102377.875, + "context": 104781.5, + "out": 4193.0, + "minutes": 0.7202416678269704, + "p": 36575.5, + "k": 2.0, + "w5": 63475.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 7, + "cost": 85059.90714285713, + "context": 254364.7142857143, + "out": 3732.5714285714284, + "minutes": 0.7362214275768825, + "p": 32645.0, + "k": 5.0, + "w5": 44724.42857142857, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 25338, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.5286000013351441 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 14, + "cost": 479790.4464285714, + "context": 4414161.5, + "out": 21382.428571428572, + "minutes": 4.576904761904761, + "k": 30.142857142857142, + "w5": 0.0, + "w1h": 78009.42857142857 + } + ], + "external": [ + { + "version": "10.27.14", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 2, + "context": 287756.0, + "out": 4982.0, + "cost": 139235.95, + "minutes": 0.9312750001748403, + "p": 25461.0, + "k": 6.5, + "w5": 0.0, + "w1h": 51244.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 2 + }, + { + "version": "10.27.14", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-refactoring", + "cli_model": "claude-opus-5-5", + "count": 11, + "context": 317418.36363636365, + "out": 8708.90909090909, + "cost": 120470.91363636364, + "minutes": 1.459824242736354, + "p": 12836.0, + "k": 9.363636363636363, + "w5": 0.0, + "w1h": 31301.363636363636, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 25038, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.2872333327929179, + "call_seats": 11 + }, + { + "version": "10.27.14", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 3, + "input": 272337.6666666667, + "cached": 226560.0, + "out": 2245.0, + "minutes": 0.9223055548138088, + "p": 23312.333333333332, + "k": 7.0, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 29637, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.050349998474121097, + "call_seats": 3 + }, + { + "version": "10.27.14", + "mode": "light", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-refactoring", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 316125.0, + "cached": 267776.0, + "out": 5091.0, + "minutes": 2.1235833326975504, + "p": 19588.0, + "k": 7.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 13, + "context": 291746.3846153846, + "out": 4742.076923076923, + "cost": 119866.56923076922, + "minutes": 0.8594807701233106, + "p": 27253.615384615383, + "k": 6.538461538461538, + "w5": 881.0769230769231, + "w1h": 41281.61538461538, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 13 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 13, + "input": 589642.7692307692, + "cached": 519817.8461538461, + "out": 3031.3846153846152, + "minutes": 1.379767948541886, + "p": 27049.384615384617, + "k": 8.692307692307692, + "rewrites": 4, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 105330, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.05647499958674113, + "call_seats": 13 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "runtime": "claude", + "skill": "cross-refactoring", + "cli_model": "claude-opus-5-5", + "count": 12, + "context": 337833.3333333333, + "out": 4251.833333333333, + "cost": 97335.18333333335, + "minutes": 1.0118541657924651, + "p": 21327.333333333332, + "k": 6.833333333333333, + "w5": 3765.3333333333335, + "w1h": 28027.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 12 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "runtime": "codex", + "skill": "cross-refactoring", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 2318796.0, + "cached": 2189568.0, + "out": 10045.0, + "minutes": 4.696166666348775, + "p": 28049.0, + "k": 22.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 1, + "context": 93639.0, + "out": 2097.0, + "cost": 42145.25, + "minutes": 0.43571666479110716, + "p": 13987.0, + "k": 5.0, + "w5": 22474.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "runtime": "claude", + "skill": "cross-refactoring", + "cli_model": "claude-opus-5-5", + "count": 13, + "context": 434776.53846153844, + "out": 4106.615384615385, + "cost": 104233.72307692308, + "minutes": 0.9215076920313713, + "p": 21753.0, + "k": 6.846153846153846, + "w5": 3144.3846153846152, + "w1h": 29833.615384615383, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 13 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude", + "runtime": "codex", + "skill": "cross-refactoring", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 2762603.0, + "cached": 2599168.0, + "out": 12374.0, + "minutes": 6.470816663901011, + "p": 27952.0, + "k": 24.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 16, + "context": 754047.375, + "out": 5900.0, + "cost": 167805.3625, + "minutes": 1.1417895836134753, + "p": 33245.3125, + "k": 8.8125, + "w5": 7330.6875, + "w1h": 47071.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 16 + }, + { + "version": "10.27.14", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 12, + "input": 2373908.25, + "cached": 2234048.0, + "out": 5096.25, + "minutes": 2.996583333280351, + "p": 34933.833333333336, + "k": 16.666666666666668, + "rewrites": 2, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 132293, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.5240333338578542, + "call_seats": 12 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "runtime": "claude", + "skill": "cross-refactoring", + "cli_model": "claude-opus-5-5", + "count": 11, + "context": 405299.9090909091, + "out": 4969.727272727273, + "cost": 122104.31818181813, + "minutes": 0.8947151505585873, + "p": 26883.81818181818, + "k": 9.090909090909092, + "w5": 0.0, + "w1h": 39473.545454545456, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 11 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "runtime": "codex", + "skill": "cross-refactoring", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 2153346.0, + "cached": 1998464.0, + "out": 10087.0, + "minutes": 4.944216664632162, + "p": 27887.0, + "k": 19.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + } + ], + "meta": { + "by": [ + "version", + "mode", + "model", + "cc", + "reviewers" + ], + "min_version": "10.27.14", + "until": "2026-11-04T14:16:00Z", + "sessions": 76, + "sessions_with_pr": 5, + "unlinked_external": 3127, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 877, + "版を判定できない": 20 + }, + "released": "10.27.16", + "previous": "2026-11-03.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-11-04.md b/docs/metrics/ndf-token-usage/2026-11-04.md new file mode 100644 index 000000000..9ea88ab14 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-04.md @@ -0,0 +1,129 @@ +# ndf の版ごとのトークン消費と所要時間(2026-11-04 集計・10.27.16 の配布) + +**10.27.16 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-11-04T14:16:00Z`。表の最初の行は 10.27.14(前の記録: 2026-11-03.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.27.16 --until 2026-11-04T14:16:00Z --min-version 10.27.14 +python3 scripts/token-usage.py --min-version 10.27.14 --until 2026-11-04T14:16:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 10.27.15(2 件)。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.27.16。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.27.15。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(PR 1 本あたり) + +換算の合計は conductor・supervisor・worker の換算の和。前の版との差は、表の 1 つ上の行との比である。±30% を超えた版を下の「読み取り」で扱う。 + +| ndf の版 | 会話 | PR | 換算の合計 | 前の版との差 | conductor | supervisor | worker | 入力 | 所要 | codex 入力 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.14 | 3 | 5 | 2.88M | - | 1.47M | 0.74M | 0.67M | 24.24M | 101.7 | 4.29M | +| 10.27.15 | 2 | 4 | 1.24M | -57% | 0.76M | 0.48M | 0.00M | 11.75M | 41.6 | 0.00M | + +## 版と層ごとの呼び出しとキャッシュ(1 起動あたり) + +P と k と書き込みは 1 起動あたり、書き直しとうち 5 分超は合計の回数である。 + +| ndf の版 | 層 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | うち 5 分超 | 1 起動あたりの書き直し | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.14 | conductor | 33 | 54.5k | 64.6 | 0.00M | 0.21M | 11 | 10 | 0.33 | +| 10.27.14 | supervisor | 37 | 37.8k | 17.8 | 0.07M | 0.01M | 6 | 5 | 0.35 | +| 10.27.14 | worker | 52 | 38.1k | 22.0 | 0.00M | 0.05M | 0 | 0 | 0.00 | +| 10.27.15 | conductor | 43 | 58.8k | 31.2 | 0.00M | 0.13M | 6 | 5 | 0.14 | +| 10.27.15 | supervisor | 25 | 32.0k | 7.9 | 0.04M | 0.01M | 1 | 0 | 0.08 | +| 10.27.15 | worker | 23 | - | 38.7 | 0.00M | 0.09M | - | - | - | + +## 読み取り + +差の大きい版: 10.27.15(-57%)。手引き(README.md)に従い、版ごとに 1〜3 行をここへ書く。 + +## 集計の出力 + +対象の会話: 76 件 / PR を作った会話: 5 件 / 寄せ先の無い外部 CLI: 3127 件 / 寄せ先の無い帳簿の行: 0 件 + +換算は input を 1 とした費用(cache read はモデル別: claude-opus-5-5 0.05 / claude-fable-5-1 0.025 / 他 0.1。cache write 5 分 1.25・1 時間 2 / output 5)。入力は input + cache read + cache write。所要は分。 + +### PR 1 本あたり + +| 動いた版 | 会話 | PR | conductor 換算 | supervisor 換算 | worker 換算 | 入力 | 出力 | 所要 | codex 入力 | codex 出力 | kiro credit | claude 席 換算 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.14 | 3 | 5 | 1.47M | 0.74M | 0.67M | 24.24M | 97.1k | 101.7 | 4.29M | 10.4k | 0.00 | 0.48M | +| 10.27.15 | 2 | 4 | 0.76M | 0.48M | 0.00M | 11.75M | 41.5k | 41.6 | 0.00M | 0.0k | 0.00 | 0.00M | + +### フェーズごと(1 起動あたり) + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | 換算 | 入力 | 出力 | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.14 | conductor | - | - | 33 | 1.17M | 10.93M | 41.4k | 62.0 | +| 10.27.14 | supervisor | judge | claude -p | 13 | 0.03M | 0.01M | 0.3k | 0.1 | +| 10.27.14 | supervisor | pr | claude -p | 5 | 0.03M | 0.01M | 1.4k | 0.2 | +| 10.27.14 | supervisor | slow | claude -p | 2 | 0.01M | 0.00M | 0.3k | 0.1 | +| 10.27.14 | supervisor | その他 | Explore | 1 | 0.48M | 3.65M | 16.9k | 2.7 | +| 10.27.14 | supervisor | その他 | ndf:supervisor-waits | 2 | 0.68M | 6.71M | 21.0k | 43.5 | +| 10.27.14 | supervisor | その他 | ndf:worker | 14 | 0.60M | 4.82M | 31.4k | 10.0 | +| 10.27.14 | worker | work | claude -p | 47 | 0.26M | 1.90M | 12.3k | 9.1 | +| 10.27.14 | worker | その他 | ndf:worker | 5 | 0.10M | 0.42M | 3.1k | 0.7 | +| 10.27.15 | conductor | - | - | 43 | 0.56M | 4.06M | 20.7k | 18.3 | +| 10.27.15 | supervisor | judge | claude -p | 3 | 0.03M | 0.01M | 0.2k | 0.1 | +| 10.27.15 | supervisor | mvv | claude -p | 3 | 0.07M | 0.03M | 1.8k | 0.3 | +| 10.27.15 | supervisor | pr | claude -p | 7 | 0.02M | 0.01M | 1.4k | 0.3 | +| 10.27.15 | supervisor | その他 | general-purpose | 2 | 0.10M | 0.10M | 4.2k | 0.7 | +| 10.27.15 | supervisor | その他 | ndf:worker | 9 | 0.28M | 2.37M | 12.2k | 3.3 | +| 10.27.15 | supervisor | その他 | その他 | 1 | 0.02M | 0.01M | 1.4k | 0.1 | +| 10.27.15 | worker | work | claude -p | 23 | 0.56M | 5.11M | 25.5k | 5.3 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.14 | conductor | - | - | 33 | 54.5k | 64.6 | 0.00M | 0.21M | 11 | 10 | 1.67M | 12.00M | 105.3 | +| 10.27.14 | supervisor | judge | claude -p | 13 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.14 | supervisor | pr | claude -p | 5 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.14 | supervisor | slow | claude -p | 2 | - | 1.0 | 0.00M | 0.00M | - | - | - | - | - | +| 10.27.14 | supervisor | その他 | Explore | 1 | 22.4k | 30.0 | 0.18M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.14 | supervisor | その他 | ndf:supervisor-waits | 2 | 47.2k | 58.0 | 0.00M | 0.12M | 0 | 0 | 0.00M | 0.67M | - | +| 10.27.14 | supervisor | その他 | ndf:worker | 14 | 37.5k | 35.3 | 0.16M | 0.00M | 6 | 5 | 0.48M | 0.00M | 8.7 | +| 10.27.14 | worker | work | claude -p | 47 | - | 23.5 | 0.00M | 0.06M | - | - | - | - | - | +| 10.27.14 | worker | その他 | ndf:worker | 5 | 38.1k | 8.2 | 0.05M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | conductor | - | - | 43 | 58.8k | 31.2 | 0.00M | 0.13M | 6 | 5 | 0.65M | 2.72M | 191.9 | +| 10.27.15 | supervisor | judge | claude -p | 3 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.15 | supervisor | mvv | claude -p | 3 | - | 1.0 | 0.00M | 0.03M | - | - | - | - | - | +| 10.27.15 | supervisor | pr | claude -p | 7 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.15 | supervisor | その他 | general-purpose | 2 | 36.6k | 2.0 | 0.06M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | supervisor | その他 | ndf:worker | 9 | 34.1k | 19.9 | 0.08M | 0.00M | 1 | 0 | 0.00M | 0.00M | 0.5 | +| 10.27.15 | supervisor | その他 | その他 | 1 | 3.4k | 2.0 | 0.01M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | worker | work | claude -p | 23 | - | 38.7 | 0.00M | 0.09M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.14 | claude | cross-review | claude-opus-5-5 | 32 | 0.52M | 0.00M | 5.3k | 0.00 | 1.0 | +| 10.27.14 | claude | cross-refactoring | claude-opus-5-5 | 36 | 0.37M | 0.00M | 5.6k | 0.00 | 1.1 | +| 10.27.14 | codex | cross-review | gpt-6.1-sol | 28 | 1.32M | 1.22M | 3.8k | 0.00 | 2.0 | +| 10.27.14 | codex | cross-refactoring | gpt-6.1-sol | 3 | 1.80M | 1.69M | 9.2k | 0.00 | 4.4 | +| 10.27.15 | claude | cross-refactoring | claude-opus-5-5 | 11 | 0.41M | 0.00M | 5.0k | 0.00 | 0.9 | +| 10.27.15 | codex | cross-refactoring | gpt-6.1-sol | 1 | 2.15M | 2.00M | 10.1k | 0.00 | 4.9 | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.14 | claude | cross-review | claude-opus-5-5 | 32 | 29.7k | 7.6 | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.14 | claude | cross-refactoring | claude-opus-5-5 | 36 | 18.9k | 7.6 | 1 | 0 | 0.00M | 0.00M | 0.3 | +| 10.27.14 | codex | cross-review | gpt-6.1-sol | 28 | 30.0k | 11.9 | 7 | 0 | 0.00M | 0.00M | 0.1 | +| 10.27.14 | codex | cross-refactoring | gpt-6.1-sol | 3 | 25.2k | 17.7 | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | claude | cross-refactoring | claude-opus-5-5 | 11 | 26.9k | 9.1 | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | codex | cross-refactoring | gpt-6.1-sol | 1 | 27.9k | 19.0 | 0 | 0 | 0.00M | 0.00M | - | diff --git a/docs/specifications/README.md b/docs/specifications/README.md index 76ebe80d3..7dc9e7fa2 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -99,7 +99,7 @@ | [cross-refactoring-scope-template-probe.md](cross-refactoring-scope-template-probe.md) | cross-refactoring の init が着手前のテストの後に範囲テストのテンプレートを試走し(suite ごとにテストのファイルを最大 2 本)、`{paths}` に入るパスを受けない・確かめられないなら直す箇所を出して改修の前に止まる理由と規則(候補の選び方・判定の表・`template-check.json` の形)。止まり方は `cross-refactoring` の SKILL.md、`{paths}` に入るパスの種類は `development-workflow` の `references/project-analysis.md` が正 | | [cross-review-no-test-declaration-sweep.md](cross-review-no-test-declaration-sweep.md) | テストの無い宣言(`{"suites": []}` を含む)で cross-review の最終スイープが検証のコマンドを名指しせずに進み、駆動の件数に継続的統合の判定 `ci`・`ci_reason` を載せ、supervise の止まった理由に元の終了コードを添える理由と境界(`verify_commands` の判定の順・種別と雛形の誤りは止める)。件数の一覧は `cross-review` の SKILL.md、drive のステップの読み方は `supervise_lib/plan.py` が正 | | [ndf-factory-service-boundary.md](ndf-factory-service-boundary.md) | Neural Development Factory: サービスのディレクトリ(factory/)と境界の検査(check-service-boundary.py)の入出力・常に成り立つ条件と、子課題 #2170〜#2174 が実装するサービス全体の契約(構成要素・配置・進捗の正本・実行ジョブの指示・進捗の事象と受け口の認証と応答)。#2169 | -| [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) | Neural Development Factory: Cognito の認証(Google・GitHub と GitHub の仲立ち)・アカウントの解決・アカウントと席とプランと実行の形態の API と表・使える量の表と判定・factory/ のパッケージの管理と CI(factory-check)。#2170 | +| [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) | Neural Development Factory: Cognito の認証(Google・GitHub と GitHub の仲立ち)・アカウントの解決・アカウントと席と料金プランと実行の形態の API と表・使える量の表と判定・factory/ のパッケージの管理と CI(factory-check)。#2170 | | [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) | Neural Development Factory: 契約の定義(JSON Schema・生成した型・ingest.yaml)・進捗の受け口の 3 つの経路と出所ごとの認証と照合・冪等な追記と事象の表・GitHub の Webhook の翻訳・実行ジョブの token(EdDSA)の発行と検証・appendControlEvent・appendIssueRequestResolved と照合(I37・I44・I45)。#2171・#2216 | | [ndf-factory-progress-data.md](ndf-factory-progress-data.md) | Neural Development Factory: 進捗の記録の事象の表・冪等の鍵の表・環境変数・契約のパッケージ(ndf-factory-contracts-and-progress.md から分けた)。#2171 | | [ndf-factory-issue-requests.md](ndf-factory-issue-requests.md) | Neural Development Factory: 起票の依頼(issue.requested)と起票の代行(判定の順序・下書きの理由・台帳・起票した数の上限・1 リポジトリと Issues の書き込みだけの token)と、ランナーと NDF の依頼の書き出し。#2216 | @@ -123,8 +123,9 @@ | [ndf-relay-multi-session-supervisor.md](ndf-relay-multi-session-supervisor.md) | 同じコンテナで最初のラッパーが統括役を兼ね、2 本目以降の claude・`relay.py spawn`・起動のブロックで名前つきのラッパーを tmux に起動し、`ls` で一覧し、`stop` / `restart` / `attach` で 1 本ずつ扱う入出力の契約・状態の置き場所・決定と理由。手順は `development-workflow` の `references/relay.md` の「複数のループを並べる(統括役)」が正 | | [ndf-backlog-refinement-depths.md](ndf-backlog-refinement-depths.md) | 棚卸しの 3 つの深さ(差分の棚卸し・見直し・大整理)の起動の判定・見直しの持ち主・ニーズとシーズの調査・起票と付け直しと戻しについて、`upkeep.py` の副命令の契約、`.ndf/backlog/` の記録、`materials`・`depths` の設定と、そう決めた理由。手順は `backlog-refinement` の SKILL.md と `references/depths.md`・`review.md`・`research.md`・`overhaul.md` が正 | | [ndf-context-restart-policy.md](ndf-context-restart-policy.md) | 再起動の方針(現行方式と試行方式)の適用値の解決と優先順位、工程境界の帯・容量・費用の判定、判定の記録と「引継ぎ開始」「復元完了」の印、`context-policy.py` の副命令と終了コード、`.ndf/context.json`・状態ディレクトリのファイル・環境変数、作業ごとの集計の区分。手順は `development-workflow` の `references/context-window.md` の「再起動の方針」が正 | -| [ndf-factory-dev-loops.md](ndf-factory-dev-loops.md) | Neural Development Factory: 開発ループ(集約・状態の写し方・周回の開始)・同じリポジトリで並行する実行の取り合い(課題・マイルストーンの持ち分・NDF の版・同時の数)・移行の埋め戻し・ランナーの区間と ndf-next・形態 local の利用上限・手元のランナーの同時のジョブの数・loops.yaml の経路。#2347 | -| [ndf-factory-dev-loops-data-and-contracts.md](ndf-factory-dev-loops-data-and-contracts.md) | Neural Development Factory: 開発ループの実行の表の行・移行の済みの行と埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・ndf-next の受け渡しの形・手元のランナーの申告・loops.yaml の経路の契約(ndf-factory-dev-loops.md から分けた)。#2347 | +| [ndf-factory-dev-loops.md](ndf-factory-dev-loops.md) | Neural Development Factory: 開発ループ(集約・状態の写し方・周回の開始)・同じリポジトリで並行する実行の取り合い(課題・マイルストーンの持ち分・NDF の版・同時の数)・移行の埋め戻し・ランナーの区間と ndf-next・形態 local の利用上限・手元のマシンの同時のジョブの数・loops.yaml の経路。#2347 | +| [ndf-factory-dev-loops-data-and-contracts.md](ndf-factory-dev-loops-data-and-contracts.md) | Neural Development Factory: 開発ループの実行の表の行・移行の済みの行と埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・ndf-next の受け渡しの形・手元のマシンの申告・loops.yaml の経路の契約(ndf-factory-dev-loops.md から分けた)。#2347 | | [ndf-factory-ui-components.md](ndf-factory-ui-components.md) | Neural Development Factory: Web の画面の部品の一覧と使い分け・デザインのトークン・端末の幅・文言と書式(国際化)・API クライアントとダミー・つながりの状態・常に成り立つ条件・検査(check:source・check:bundle・E2E)。#2382 | +| [ndf-parallel-organizer.md](ndf-parallel-organizer.md) | 並列の計画(plan.json)を 1 度書き、--parallel のオーガナイザーが耐久ワークフローでレーンを起動・観測し、本番の配布を develop のコミットの範囲で 1 本にまとめ、区分つきの問いを集める設計判断・常に成り立つ条件・メッセージとイベントの形・置き場。手順は development-workflow の references/parallel.md と issue-plan-strategy の references/parallel-plan.md が正 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-execution-plan-and-parallel-capacity.md b/docs/specifications/ndf-execution-plan-and-parallel-capacity.md index 6397c845f..95219ed06 100644 --- a/docs/specifications/ndf-execution-plan-and-parallel-capacity.md +++ b/docs/specifications/ndf-execution-plan-and-parallel-capacity.md @@ -329,4 +329,5 @@ by_memory = running + max(0, ⌊(空き − reserve) ÷ per_lane_used⌋) - [PR #751](https://github.com/devbasex/ai-plugins/pull/751) / [PR #758](https://github.com/devbasex/ai-plugins/pull/758) / [PR #761](https://github.com/devbasex/ai-plugins/pull/761) — 実装 - [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) — 本数の 1 本 = supervisor 1 つ - [ndf-workflow-unit-and-gates.md](ndf-workflow-unit-and-gates.md) — 並行開発の必須ルール +- [ndf-parallel-organizer.md](ndf-parallel-organizer.md) — tmux のレーンを並列の計画で並べ、オーガナイザーが本番の配布を 1 本にまとめる(スプリントより上の層の並列) - 要求・設計・契約・計画の元の文書は `issues/old/milestone-18-unattended/` にある diff --git a/docs/specifications/ndf-factory-accounts-and-plans.md b/docs/specifications/ndf-factory-accounts-and-plans.md index 1de13af88..9f5f5006f 100644 --- a/docs/specifications/ndf-factory-accounts-and-plans.md +++ b/docs/specifications/ndf-factory-accounts-and-plans.md @@ -1,6 +1,6 @@ -# Neural Development Factory: 認証(Google・GitHub)とアカウント・席・プランごとの使える量 +# Neural Development Factory: 認証(Google・GitHub)とアカウント・席・料金プランごとの使える量 -Neural Development Factory の利用者が Google か GitHub のアカウントで Cognito を経てログインし、要求 1 つから「どの利用者が、どのアカウントとして操作しているか」を決め、プランと席の種類ごとの使える量を 1 か所で問い合わせて判定する仕組みを定める。あわせて、`factory/` の TypeScript のパッケージの管理と検査の形を定める。サービス全体の構成・集約の持ち主・実行の形態の意味は [サービスのディレクトリと境界の検査、サービス全体の契約](ndf-factory-service-boundary.md)(以下「全体の契約」)にあり、本書はそこで集約「アカウント」の持ち主とされた部分(#2170)の確定形である。 +Neural Development Factory の利用者が Google か GitHub のアカウントで Cognito を経てログインし、要求 1 つから「どの利用者が、どのアカウントとして操作しているか」を決め、料金プランと席の種類ごとの使える量を 1 か所で問い合わせて判定する仕組みを定める。あわせて、`factory/` の TypeScript のパッケージの管理と検査の形を定める。サービス全体の構成・集約の持ち主・実行の形態の意味は [サービスのディレクトリと境界の検査、サービス全体の契約](ndf-factory-service-boundary.md)(以下「全体の契約」)にあり、本書はそこで集約「アカウント」の持ち主とされた部分の確定形である。 ## 概要 @@ -12,7 +12,7 @@ Neural Development Factory の利用者が Google か GitHub のアカウント ## 用語 -定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`)。この文書で使う語は「利用者」「アカウント」「席」「プラン」「使える量」「使える量の判定」「チームの持ち主」「実行の形態」「GitHub の仲立ち」である。 +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`)。この文書で使う語は「利用者」「アカウント」「席」「料金プラン」「使える量」「使える量の判定」「チームの持ち主」「実行の形態」「GitHub の仲立ち」である。 ## コンテキスト @@ -33,8 +33,8 @@ Neural Development Factory の利用者が Google か GitHub のアカウント ## 対象範囲 -- **含む**: `factory/web/` のログイン・ログアウトとアカウントの画面、`factory/api/` の token の検証・アカウントの解決・アカウントと席とプランと実行の形態の読み書き・使える量の問い合わせと判定・GitHub の仲立ち、`factory/contracts/openapi/accounts.yaml`、`factory/` のパッケージの管理と CI のジョブ `factory-check` -- **含まない**: Cognito の利用者プール・DynamoDB の表・Lambda の束ね方などインフラの定義(private リポジトリが作る)、決済・料金・請求とプランを有料へ変える経路(#2174)、使用量の台帳とその月の token の集計(#2174)、スプリントの開始を断る処理そのもの(#2186。本書は判定を返す)、実行の形態ごとのランナー・持ち込んだキーの保存(#2186・#2189)、2 つの IdP の利用者をまとめる機能、招待のメール +- **含む**: `factory/web/` のログイン・ログアウトとアカウントの画面、`factory/api/` の token の検証・アカウントの解決・アカウントと席と料金プランと実行の形態の読み書き・使える量の問い合わせと判定・GitHub の仲立ち、`factory/contracts/openapi/accounts.yaml`、`factory/` のパッケージの管理と CI のジョブ `factory-check` +- **含まない**: Cognito の利用者プール・DynamoDB の表・Lambda の束ね方などインフラの定義(private リポジトリが作る)、決済・料金・請求と料金プランを有料へ変える経路、使用量の台帳とその月の token の集計、スプリントの開始を断る処理そのもの(本書は判定を返す)、実行の形態ごとのランナー・持ち込んだキーの保存、2 つの IdP の利用者をまとめる機能、招待のメール - プラグインの配布物・Skill・hook は変わらない。`plugins/` は `factory/` を参照しない(全体の契約の I1) ## 仕様 @@ -43,8 +43,8 @@ Neural Development Factory の利用者が Google か GitHub のアカウント | 集約 | 持ち主 | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | -| 利用者 | 認証とプラン(`factory/api/`) | 利用者 | — | 利用者の ID(Cognito の `sub`)・IdP の種類 | -| アカウント | 認証とプラン(`factory/api/`) | アカウント | 席 | 種類(個人・チーム)・プラン・席の種類・使える量・実行の形態・履歴の行 | +| 利用者 | 認証と料金プラン(`factory/api/`) | 利用者 | — | 利用者の ID(Cognito の `sub`)・IdP の種類 | +| アカウント | 認証と料金プラン(`factory/api/`) | アカウント | 席 | 種類(個人・チーム)・料金プラン・席の種類・使える量・実行の形態・履歴の行 | 初めてのログインは「利用者」と「個人のアカウント」の 2 つを 1 つのトランザクションで作る。2 つは同じ利用者の作成でしか生まれず、片方だけがある状態を作らないためで、「1 つの処理が書き換える集約は 1 つ」(全体の契約の「集約」)の例外はこの作成の時だけである。 @@ -57,8 +57,8 @@ Neural Development Factory の利用者が Google か GitHub のアカウント | アカウントの画面 | `factory/web/src/pages/AccountPage.tsx`・`NewTeamPage.tsx`・`TeamPage.tsx` | 自分のアカウント・チームを作る・チームのアカウントの 3 画面。API の 401 で token を消してログインへ戻す | | token の検証 | `factory/api/src/auth/verify.ts` | `Authorization: Bearer` の access token を検証し、利用者を返す。失敗は 401 | | アカウントの解決 | `factory/api/src/auth/resolve.ts`・`accounts/service.ts` | 経路の前に token を検証する中間処理と、利用者とアカウントの ID から権限つきの文脈を返す処理。利用者が無ければ作る | -| アカウントの操作 | `factory/api/src/accounts/service.ts` | チームの作成・席の追加と種類の変更と削除・プランの変更・実行の形態の変更 | -| 使える量の表と判定 | `factory/api/src/accounts/limits.ts` | プランと席の種類ごとの上限の表と、実行の形態に合わせた問い合わせと判定 | +| アカウントの操作 | `factory/api/src/accounts/service.ts` | チームの作成・席の追加と種類の変更と削除・料金プランの変更・実行の形態の変更 | +| 使える量の表と判定 | `factory/api/src/accounts/limits.ts` | 料金プランと席の種類ごとの上限の表と、実行の形態に合わせた問い合わせと判定 | | アカウントの保存 | `factory/api/src/accounts/store.ts` | DynamoDB の 1 つの表への読み書き。条件つきの書き込みとトランザクション | | API の入口 | `factory/api/src/handlers/api.ts` | API Gateway の HTTP API(payload 2.0)から呼ばれる Lambda の handler。ログ・CORS・認証の中間処理・エラーの形・経路の登録 | | GitHub の仲立ち | `factory/api/src/handlers/github-oidc.ts`・`github-oidc/{app,keys}.ts` | Cognito から OIDC の IdP として呼ばれ、GitHub の OAuth へ取り次ぎ、署名した ID token を返す | @@ -103,7 +103,7 @@ graph TD | `resolveAccount(caller, accountId?)` | `Caller`・アカウントの ID(省くと個人のアカウント) | `AccountContext`(`accountId`・`userId`・`kind`・`plan`・`role`(`owner` / `member`)・`seatType`(チームのときだけ、ほかは `null`)・`executionMode`)。#2186・#2189・#2174 は実行の形態をここから読む | 403 の `ForbiddenError` | | `getLimits(ctx)` | `plan`・`seatType`・`executionMode` | `Limits`(`concurrentSprintRuns`・`monthlyTokens`)。`monthlyTokens` は `managed` のときだけ表の値で、ほかは `null`(掛けない) | 例外を投げない。表に無い組と、`managed` なのに表に月の token の値が無い組は `null` を返す | | `judgeLimits(ctx, current)` | 文脈・`current = {runningSprintRuns, tokensThisMonth?, runningRepoRuns?, repoMaxRuns?}`(後ろの 2 つは同じリポジトリで動いている実行の数と登録の同時の実行の数の宣言。省けば掛けない。#2347) | `Judgment`(`allowed`・`limits`・`reasons`)。`reasons` は `{quantity, limit, current}` の並び | — | -| `setPlan(accountId, plan)` | アカウントの ID・プラン | 無し | `PlanError`(`unknown_plan` / `plan_kind_mismatch` / `account_not_found`)。版の競合で 3 回書けなければ `AccountUnavailableError` | +| `setPlan(accountId, plan)` | アカウントの ID・料金プラン | 無し | `PlanError`(`unknown_plan` / `plan_kind_mismatch` / `account_not_found`)。版の競合で 3 回書けなければ `AccountUnavailableError` | | `createApiApp(deps)`・`requireCaller(deps)`・`resolveFromRequest(c, accountId?)` | — | API の入口の Hono のアプリ・認証の中間処理・経路の中でアカウントの文脈を得る関数 | 上と同じ | あわせて `LIMITS_TABLE`・`AccountService`・エラーの型(`ApiError`・`AuthError`・`ForbiddenError`・`PlanError`)と、`AccountContext`・`Caller`・`ExecutionMode`・`Plan`・`SeatType`・`Limits`・`Judgment`・`CurrentUsage`・`LimitReason`・`AuthEnv`・`ApiApp` の型を export する。 @@ -133,7 +133,7 @@ graph TD CTX --> H ``` -**初回の作成は「利用者の行が無いこと」を条件にしたトランザクションで書く。** 利用者・個人のアカウント(プラン `free`・形態 `local`・版 1)・`account.created` の履歴の行を一緒に書く。並べて 2 回来ても条件で片方だけが通り、失敗した側は利用者を読み直して先にできた方を返す。読み直しても無いか、条件以外で失敗したときは 503 `account_unavailable` で、次の要求で作り直す。 +**初回の作成は「利用者の行が無いこと」を条件にしたトランザクションで書く。** 利用者・個人のアカウント(料金プラン `free`・形態 `local`・版 1)・`account.created` の履歴の行を一緒に書く。並べて 2 回来ても条件で片方だけが通り、失敗した側は利用者を読み直して先にできた方を返す。読み直しても無いか、条件以外で失敗したときは 503 `account_unavailable` で、次の要求で作り直す。 ### ログインの流れ @@ -181,24 +181,24 @@ sequenceDiagram | # | 集約 | 条件 | 破れそうなときの扱い | | --- | --- | --- | --- | | I12 | 利用者 | 1 人の利用者の個人のアカウントは 1 つだけ | 2 つ目の作成は条件つきの書き込みで失敗し、先にできた方を読み直して返す | -| I13 | アカウント | 個人のアカウントは席を持たず、プランは `free`・`pro`・`max` のどれか。チームのアカウントのプランは `team` | 個人のアカウントへの席の操作は 409 `not_a_team`。合わない組の `setPlan` は `plan_kind_mismatch` で変えない | +| I13 | アカウント | 個人のアカウントは席を持たず、料金プランは `free`・`pro`・`max` のどれか。チームのアカウントの料金プランは `team` | 個人のアカウントへの席の操作は 409 `not_a_team`。合わない組の `setPlan` は `plan_kind_mismatch` で変えない | | I14 | アカウント | チームのアカウントでは、持ち主が必ず席を 1 つ持つ | 持ち主の席を外す要求は 409 `owner_seat_protected` | | I15 | アカウント | 1 人の利用者は 1 つのチームに席を 1 つまで持つ | 2 つ目の追加は 409 `seat_exists` | | I16 | アカウント | 席が指す利用者は、既に 1 度ログインした利用者である | 422 `user_not_found` | | I17 | アカウント | 席を足す・種類を変える・外すのは、チームの持ち主だけ | 403 `forbidden`。席の有無を確かめる前に返す | | I18 | アカウント | 個人のアカウントの持ち主でもチームの席を持つ利用者でもない者には、アカウントの有無も中身も応答に出ない | 存在しないアカウントと同じ 403 と同じ本文 | | I19 | アカウント | 使える量の値は使える量の表 1 か所からだけ決まる | 表に無い組は判定を断る側へ倒し、理由に `not_in_limits_table` を返す | -| I20 | アカウント | プラン・席・実行の形態を変えるたびに、同じトランザクションで履歴の行を 1 つ足し、履歴の行は書き換えない | 履歴を書けなければ変更ごと書かない | -| I21 | アカウント | プランが `free` のアカウントの実行の形態は `managed` でない | 形態を `managed` へ変える要求は 409 `managed_not_allowed_on_free`。`setPlan` で `free` へ下げるとき形態が `managed` なら、同じ更新で `local` へ変え、`plan.changed` と `execution_mode.changed` の 2 つの履歴の行を足す | +| I20 | アカウント | 料金プラン・席・実行の形態を変えるたびに、同じトランザクションで履歴の行を 1 つ足し、履歴の行は書き換えない | 履歴を書けなければ変更ごと書かない | +| I21 | アカウント | 料金プランが `free` のアカウントの実行の形態は `managed` でない | 形態を `managed` へ変える要求は 409 `managed_not_allowed_on_free`。`setPlan` で `free` へ下げるとき形態が `managed` なら、同じ更新で `local` へ変え、`plan.changed` と `execution_mode.changed` の 2 つの履歴の行を足す | | I22 | アカウント | 実行の形態を変えるのは、アカウントの持ち主(個人は本人、チームはチームの持ち主)だけ | 403 `forbidden` | | I23 | アカウント | 月の token の上限を照らすのは、実行の形態が `managed` のときだけ。同時に動かせるスプリントの実行の数は 3 つの形態すべてで照らす | `local`・`byok` の判定は月の token の今の値を見ない | | I24 | アカウント | 新しいアカウント(個人もチームも)の実行の形態は `local` で始まる | 作成はいつも `local` で書く | -**プランと形態の更新は、読んだアカウントの行の版が変わっていないことを条件に書く。** アカウントの行は数の `version` を持ち、`setPlan` と形態の変更はどちらも「行があり、`version` が読んだ値と等しいこと」を条件にして、同じ更新で `version` を 1 増やす。条件で失敗したら読み直して I13・I21 を判定し直し、もう一度書く(3 回まで)。これが無いと、プロ・`local` を読んだ `setPlan` が形態を変えないと決めた後に別の要求が `managed` を保存し、続けて `setPlan` が `free` を保存して、`free`・`managed` が残る。席の種類の変更と削除も同じく、読んだ席の種類と足した時刻が変わっていないことを条件にし、競合したら読み直す(3 回まで)。外して足し直した新しい席を古い要求で消さないためである。3 回で書けなければ 503 `account_unavailable` を返す。 +**料金プランと形態の更新は、読んだアカウントの行の版が変わっていないことを条件に書く。** アカウントの行は数の `version` を持ち、`setPlan` と形態の変更はどちらも「行があり、`version` が読んだ値と等しいこと」を条件にして、同じ更新で `version` を 1 増やす。条件で失敗したら読み直して I13・I21 を判定し直し、もう一度書く(3 回まで)。これが無いと、プロ・`local` を読んだ `setPlan` が形態を変えないと決めた後に別の要求が `managed` を保存し、続けて `setPlan` が `free` を保存して、`free`・`managed` が残る。席の種類の変更と削除も同じく、読んだ席の種類と足した時刻が変わっていないことを条件にし、競合したら読み直す(3 回まで)。外して足し直した新しい席を古い要求で消さないためである。3 回で書けなければ 503 `account_unavailable` を返す。 ### 使える量の表と判定 -| プラン・席の種類(表の鍵) | 同時に動かせるスプリントの実行 | 月の token の上限(`managed` だけ) | +| 料金プラン・席の種類(表の鍵) | 同時に動かせるスプリントの実行 | 月の token の上限(`managed` だけ) | | --- | --- | --- | | Free(`free`) | 1 | 掛けない(`managed` を選べない) | | 個人 Pro(`pro`) | 3 | 20,000,000 | @@ -206,11 +206,11 @@ sequenceDiagram | Team standard(`team:standard`) | 3 | 25,000,000 | | Team premium(`team:premium`) | 10 | 125,000,000 | -- **鍵は個人のアカウントではプラン、チームでは `team:<席の種類>` である。** チームの使える量は、要求した利用者の席の種類で決まる +- **鍵は個人のアカウントでは料金プラン、チームでは `team:<席の種類>` である。** チームの使える量は、要求した利用者の席の種類で決まる - **月の token は入力と出力の和で、cache の読み書きを含めて数える。** Bedrock の請求と同じ単位で数えるためである。cache を除くと、NDF の実行ではほぼ全部が出力になり、同じ上限でも費用が大きく変わる - **値の比は依頼の「Claude.aiと類似の」に合わせた。** Pro と Max の比を保ち、Team standard を個人 Pro の少し上、Team premium を個人 Max の少し上に置く - **判定は「今の値が上限より小さければ許す」である。** 同時のスプリントの実行は新しい 1 つを始める前の数を渡し、上限 3 なら今が 2 で許し、3 で断る。月の token はその UTC の暦月に使った数(チームではその席の利用者の分)を渡し、上限ちょうどで断る。`managed` で `tokensThisMonth` を省くと断る -- 断ったときの `reasons` の `quantity` は `concurrent_sprint_runs` / `concurrent_repo_runs` / `monthly_tokens` / `not_in_limits_table` で(`concurrent_repo_runs` の上限はプランの同時の数と登録の宣言の小さい方で、宣言が無くアカウントの上限と同時に当たるときは重ねない)、`limit` と `current` に上限と今の値を入れる。画面と #2186 は同じ値から利用者に出す文を作る +- 断ったときの `reasons` の `quantity` は `concurrent_sprint_runs` / `concurrent_repo_runs` / `monthly_tokens` / `not_in_limits_table` で(`concurrent_repo_runs` の上限は料金プランの同時の数と登録の宣言の小さい方で、宣言が無くアカウントの上限と同時に当たるときは重ねない)、`limit` と `current` に上限と今の値を入れる。画面と #2186 は同じ値から利用者に出す文を作る ### API の経路 @@ -221,14 +221,14 @@ sequenceDiagram | `GET /me` | — | 200 `{user_id, idp, personal_account_id, accounts: [{account_id, kind, name, plan, role, seat_type, execution_mode}]}`。先頭は個人のアカウント。初回は個人のアカウントを作る | 401・503 `account_unavailable` | | `GET /accounts/{account_id}` | — | 200 `{account_id, kind, name, plan, role, seat_type, execution_mode, limits: {concurrent_sprint_runs, monthly_tokens}, seats}`。`monthly_tokens` は `managed` のときだけ数で、ほかは `null`。`seats` はチームだけで `[{user_id, idp, seat_type, added_at}]`、個人では `null` | 401・403 `forbidden` | | `POST /teams` | `{name: 1〜64 字(空白だけは不可), seat_type}` | 201 アカウントの本文(上と同じ形)。作った利用者が持ち主で、その席が 1 つある | 400 `invalid_request` | -| `PUT /accounts/{account_id}/execution-mode` | `{execution_mode: "local" \| "byok" \| "managed"}` | 200 アカウントの本文。今と同じ値なら履歴の行を足さずに 200 | 400 `invalid_request`・403 `forbidden`(I22・I18)・409 `managed_not_allowed_on_free`(`message` は「Free のプランでは managed(サービスの Bedrock)を選べません」)・503 `account_unavailable` | +| `PUT /accounts/{account_id}/execution-mode` | `{execution_mode: "local" \| "byok" \| "managed"}` | 200 アカウントの本文。今と同じ値なら履歴の行を足さずに 200 | 400 `invalid_request`・403 `forbidden`(I22・I18)・409 `managed_not_allowed_on_free`(`message` は「Free の料金プランでは managed(サービスの Bedrock)を選べません」)・503 `account_unavailable` | | `POST /accounts/{account_id}/seats` | `{user_id: 1〜128 字, seat_type}` | 201 席 `{user_id, idp, seat_type, added_at}` | 400・403・409 `not_a_team`・422 `user_not_found`・409 `seat_exists` | | `PATCH /accounts/{account_id}/seats/{user_id}` | `{seat_type}` | 200 席 | 400・403・409 `not_a_team`・404 `seat_not_found`・503 | | `DELETE /accounts/{account_id}/seats/{user_id}` | — | 204 | 403・409 `not_a_team`・409 `owner_seat_protected`・404 `seat_not_found`・503 | -- **持ち主の確かめは、席の有無とプランの照合より先に行う。** 404 `seat_not_found` と 409 `managed_not_allowed_on_free` は、要求した者が持ち主と確かめた後だけ返す +- **持ち主の確かめは、席の有無と料金プランの照合より先に行う。** 404 `seat_not_found` と 409 `managed_not_allowed_on_free` は、要求した者が持ち主と確かめた後だけ返す - **`byok` を選んでもキーの登録はこの経路で求めない。** キーの保存は #2186 が持つ -- **`GET /me` のチームの一覧は GSI を候補として引き、元の席を強い整合で確かめる。** 外した席が GSI に残る間に、そのチームの名前やプランを返さないためである +- **`GET /me` のチームの一覧は GSI を候補として引き、元の席を強い整合で確かめる。** 外した席が GSI に残る間に、そのチームの名前や料金プランを返さないためである - 本文が JSON のオブジェクトでなければ 400 `invalid_request`。経路の中で起きた想定外の失敗は 503 `account_unavailable` に揃える ### GitHub の仲立ち @@ -255,9 +255,9 @@ sequenceDiagram | --- | --- | --- | | ログイン | `/login` | Google と GitHub の 2 つのボタン。`?error=` があれば「ログインできませんでした」を出す | | ログインの戻り | `/callback` | code を token に換え、`/account` へ移る。`error` つきの戻りは token を保存せず `/login?error=…` へ移る | -| 自分のアカウント | `/account` | 利用者の ID(写すボタン)・IdP・個人のプランと実行の形態と 2 つの上限(`managed` でなければ月の token は「掛けない」)・形態を変える操作・席を持つチームの一覧・チームを作る・ログアウト | +| 自分のアカウント | `/account` | 利用者の ID(写すボタン)・IdP・個人の料金プランと実行の形態と 2 つの上限(`managed` でなければ月の token は「掛けない」)・形態を変える操作・席を持つチームの一覧・チームを作る・ログアウト | | チームを作る | `/teams/new` | 名前(1〜64 字)と自分の席の種類(既定は standard)を入れて作る | -| チームのアカウント | `/accounts/:accountId` | プラン・実行の形態・自分の席の種類と 2 つの上限・席の一覧。持ち主には席の追加(利用者の ID は UUID の形で 36 字まで)・種類の変更・削除と形態を変える操作が出る | +| チームのアカウント | `/accounts/:accountId` | 料金プラン・実行の形態・自分の席の種類と 2 つの上限・席の一覧。持ち主には席の追加(利用者の ID は UUID の形で 36 字まで)・種類の変更・削除と形態を変える操作が出る | それ以外の経路は `/account` へ移る。Free では形態の選択肢の `managed` を選べない。 @@ -305,7 +305,7 @@ GSI `gsi1` は利用者が席を持つチームの一覧を引くためにあり | アカウント | `kind` | 文字列 | 許さない | `personal` / `team` | | アカウント | `plan` | 文字列 | 許さない | `free` / `pro` / `max` / `team`(I13) | | アカウント | `execution_mode` | 文字列 | 許さない | `local` / `byok` / `managed`(I21・I24) | -| アカウント | `version` | 数 | 許さない | 作成のときは 1。プランと形態を変えるたびに 1 増やす | +| アカウント | `version` | 数 | 許さない | 作成のときは 1。料金プランと形態を変えるたびに 1 増やす | | アカウント | `owner_user_id` | 文字列 | 許さない | 個人は本人、チームはチームの持ち主 | | アカウント | `name` | 文字列 | 許す | チームの名前。空は「個人のアカウント」 | | アカウント | `created_at`・`updated_at` | 文字列(ISO 8601、UTC) | 許さない | 作成と最後の変更の時刻 | @@ -326,7 +326,7 @@ GSI `gsi1` は利用者が席を持つチームの一覧を引くためにあり | 席を操作する | R(相手) | R | C・U・D | C | | 解決する | C(初回)・R | C(初回)・R | R | C(初回) | | 使える量 | — | R | R | — | -| プランを変える(`setPlan`) | — | U(`free` へ下げるときは形態も) | — | C | +| 料金プランを変える(`setPlan`) | — | U(`free` へ下げるときは形態も) | — | C | | 実行の形態を変える | — | R・U | R | C | ### 環境変数 @@ -338,12 +338,13 @@ GSI `gsi1` は利用者が席を持つチームの一覧を引くためにあり | `COGNITO_DOMAIN` | 画面・GitHub の仲立ち | Cognito の管理されたログインのドメイン。仲立ちは `/authorize` で受ける `redirect_uri` をこのドメインの `/oauth2/idpresponse` と照らす | | `FACTORY_WEB_URL` | 画面・API | 画面の URL。画面はログインとログアウトの戻りに、API は CORS で許すオリジンに使う | | `FACTORY_API_URL` | 画面 | API の入口の URL | +| `FACTORY_POLL_INTERVAL_MS` | 画面 | スプリントの一覧と詳細を読み直す間隔(ミリ秒)。正の整数でなければ既定の 30,000 | | `ACCOUNTS_TABLE_NAME` | API | アカウントの表の名前 | | `GITHUB_OIDC_ISSUER` | GitHub の仲立ち | 仲立ちの発行元の URL | | `GITHUB_OAUTH_CLIENT_ID` | GitHub の仲立ち | GitHub の OAuth App のクライアントの ID(秘密でない)。`/authorize` で受ける `client_id` と照らす | | `GITHUB_OIDC_SIGNING_KEY_SECRET_ARN` | GitHub の仲立ち | ID token を署名する RSA の秘密鍵の置き場 | -画面へは `vite.config.ts` が `COGNITO_DOMAIN`・`COGNITO_CLIENT_ID`・`FACTORY_WEB_URL`・`FACTORY_API_URL` の 4 つだけを名前で選んで埋め込む。ほかの変数を画面へ入れない。API と仲立ちは、要る変数が無ければ最初の要求の処理で失敗する。 +画面へは `vite.config.ts` が `COGNITO_DOMAIN`・`COGNITO_CLIENT_ID`・`FACTORY_WEB_URL`・`FACTORY_API_URL`・`FACTORY_POLL_INTERVAL_MS` の 5 つだけを名前で選んで埋め込む。ほかの変数を画面へ入れない。API と仲立ちは、要る変数が無ければ最初の要求の処理で失敗する。 ### パッケージの管理 @@ -417,7 +418,7 @@ graph TD | 観点 | 縛る振る舞い | | --- | --- | -| ログイン | 2 つのボタンがそれぞれ `identity_provider=Google` / `GitHub` を付けて Cognito へ送り、戻りの後に `/account` がプランを出すこと | +| ログイン | 2 つのボタンがそれぞれ `identity_provider=Google` / `GitHub` を付けて Cognito へ送り、戻りの後に `/account` が料金プランを出すこと | | ログインの失敗 | `error` つきの戻りで `/login` に「ログインできませんでした」が出て、保存先に token が無いこと | | ログアウト | 保存先の token が消え、`/account` を開くと `/login` へ移ること。API の 401 でも同じであること | | token の検証 | token が無い・署名が違う・期限切れ・別の利用者プール・別のクライアント・ID token のそれぞれで 401 になり、本文にアカウントの中身が無いこと | @@ -431,17 +432,17 @@ graph TD | 席の条件(I14・I15・I16) | 未ログインの利用者 → 422、既に席がある → 409、持ち主の席を外す → 409 で、どれも席の一覧と履歴が変わらないこと | | 個人のアカウントは 1 つ(I12) | 同じ利用者で `GET /me` を並べて 2 回呼んでも個人のアカウントは 1 つで、2 回とも同じ ID を返すこと | | 他人のアカウント(I18) | 利用者 A の token で、他人の個人のアカウント・入っていないチーム・存在しない ID の 3 つに読む・書くを送ると、すべて 403 で本文が同じであること | -| プランの組(I13) | 個人のアカウントへの席の操作は 409、`setPlan` で個人に `team`・チームに `pro` は失敗で、行が変わらないこと | -| 履歴(I20) | 席・プラン・実行の形態の変更のたびに履歴の行が 1 つ増え、変更が失敗したときは増えないこと | +| 料金プランの組(I13) | 個人のアカウントへの席の操作は 409、`setPlan` で個人に `team`・チームに `pro` は失敗で、行が変わらないこと | +| 履歴(I20) | 席・料金プラン・実行の形態の変更のたびに履歴の行が 1 つ増え、変更が失敗したときは増えないこと | | 実行の形態の読み出し | `GET /me`・`GET /accounts/{account_id}` の応答と `resolveAccount` の文脈に保存した形態が出ること | | 実行の形態の変更(I21・I22) | 持ち主の変更は 200 で形態が変わり、持ち主でない者は 403、Free で `managed` は 409 `managed_not_allowed_on_free` で、どちらも形態と履歴が変わらないこと | -| Free へ下げる(I21) | `managed` のプロのアカウントへ `setPlan(…, "free")` を呼ぶと、プランが `free`・形態が `local` になり、2 つの履歴の行が同じトランザクションで増えること。形態の書き込みが失敗すればプランも変わらないこと | -| 版の競合(I21) | `setPlan` の書き込みの前に別の要求が `managed` を保存して版を進めても、`setPlan` が読み直して、終わったときプランが `free`・形態が `local` であること | +| Free へ下げる(I21) | `managed` のプロのアカウントへ `setPlan(…, "free")` を呼ぶと、料金プランが `free`・形態が `local` になり、2 つの履歴の行が同じトランザクションで増えること。形態の書き込みが失敗すれば料金プランも変わらないこと | +| 版の競合(I21) | `setPlan` の書き込みの前に別の要求が `managed` を保存して版を進めても、`setPlan` が読み直して、終わったとき料金プランが `free`・形態が `local` であること | | GitHub の仲立ち | ID token の `sub` が GitHub の数の利用者 ID、`aud` が `client_id` で、公開した鍵で検証できること。検証済みでないメールを返さないこと。`/authorize` の `nonce` が ID token に同じ値で出ること。署名の壊れた・期限の切れた・`client_id` か `redirect_uri` の違う `state` と `code` を断ること。`/authorize` に違う `redirect_uri` か `client_id` が来たら GitHub へ送らずに 400 を返すこと | | 秘密を出さない | ログの出力に token・`client_secret`・鍵の形が無いこと。`python3 scripts/check-service-boundary.py` が 0 で終わること | | パッケージの管理と CI | `npm ci` がロックファイルどおりに入り、`npm run check -w web`・`npm run check -w api` が手元と CI の両方で走り、型の誤りかテストの失敗で 1 になること。`factory/` を変えない Pull Request ではジョブが成功で終わること | | 既存への影響 | `uv run --frozen --project . --all-extras pytest . -q -n 4`・`claude plugin validate .`・既存の必須のチェックが変わらず通り、プラグインの配布物と CLI だけで使う人の振る舞いが変わらないこと | -| 実環境のログイン | private リポジトリのインフラが利用者プールを作った後、実際の Google と GitHub のアカウントでログインし、プラン Free・形態 `local` の個人のアカウントができること | +| 実環境のログイン | private リポジトリのインフラが利用者プールを作った後、実際の Google と GitHub のアカウントでログインし、料金プラン Free・形態 `local` の個人のアカウントができること | ## 関連リンク diff --git a/docs/specifications/ndf-factory-contracts-and-progress.md b/docs/specifications/ndf-factory-contracts-and-progress.md index c891b6116..7f31250ad 100644 --- a/docs/specifications/ndf-factory-contracts-and-progress.md +++ b/docs/specifications/ndf-factory-contracts-and-progress.md @@ -147,8 +147,8 @@ graph TD | 種類 | 鍵(型。任意のものは「任意」) | | --- | --- | | `job.started` | `ndf_version`(文字列)、`start`(`new` / `resume` / `loop`) | -| `phase.changed` | `phase`(文字列。フェーズの名前の集合は #2172 が決めるため列挙にしない)、`issues`(1 以上の整数の並び) | -| `step.recorded` | `step`(文字列)、`exit_code`(整数)、`seconds`(0 以上の数)、`summary`(文字列)、`pull_request`(任意。`repo`(`owner/name`)・`number`(1 以上の整数)) | +| `phase.changed` | `phase`(1 字以上の文字列。5 つの名前は `src/phases.ts` の列挙が正で、定義では列挙で縛らない)、`issues`(1 以上の整数の並び) | +| `step.recorded` | `step`(文字列)、`exit_code`(整数)、`seconds`(0 以上の数)、`summary`(文字列)、`pull_request`(任意。`repo`(`owner/name`)・`number`(1 以上の整数))。工程の中身はどれも任意: `phase`(1 字以上の文字列)、`issues`(1 以上の整数の並び、最大 50)、`files`(`path`(1〜512 字)・`added`・`deleted`(0 以上の整数か、バイナリなら `null`)の並び、最大 100)、`files_omitted`(0 以上の整数)、`commit_range`(`from`・`to`。どちらも 40 桁の 16 進)、`decisions`(`question`・`answer`(1〜1,000 字)・`by`(`ai` / `user`)の並び、最大 20)、`output`(`kind`(`report` / `log`)・`text`(32,768 字以下)・`truncated`(真偽)) | | `attention` | `reason`(文字列)、`summary`(文字列) | | `gate.reached` | `kind`(`gate1` / `gate2` / `decision`)、`pr_url`(URI か `null`)、`material`(文字列。承認資料の本文)、`loop`(任意。開発ループの実行のとき `name`(ループの名前)・`lap`(1 以上の整数)) | | `gate.answered` | `approval_request_id`(文字列)、`kind`(`gate1` / `gate2` / `decision`)、`decision`(`approved` / `rejected`。`kind` が `decision` のときは持たず、ほかでは必須)、`answered_by_account_id`(文字列。#2170 の解決の結果の `accountId`)。本文は持たない | @@ -163,6 +163,34 @@ graph TD **大きさの上限は 256 KiB(262,144 バイト、`MAX_EVENT_BYTES`)で、定義の外で見る。** `runner`・`api` の経路は要求の本文の UTF-8 のバイト数、`github` の経路と `appendControlEvent` は作った事象を JSON にしたバイト数で見る。JSON Schema の `maxLength` は字の数でバイトを数えられないためである。 +### スプリントの一覧と詳細の読み出し + +一覧・詳細・工程の中身・バックログは、進捗の事象の列とスプリントの実行の行から読み出しのたびに導く派生で、表に保存しない。派生は課題の本文・ボード・GitHub の API を読まない(バックログの順位の表だけは GitHub の口の `findRankingTable` で読む)。派生の関数は `factory/api/src/views/derive.ts`、経路は `factory/api/src/routes/sprint-views.ts`、形は `factory/contracts/openapi/runs.yaml` にある。 + +**フェーズの名前は `設計`・`実装`・`検査`・`取り込み`・`仕上げ` の 5 つで、`factory/contracts/src/phases.ts` の列挙が正である。** 定義(`progress-event.schema.json`)では `phase` を文字列のまま受け、列挙で縛らない。NDF の版がフェーズを足しても事象を 400 にせず残すためで、画面は 5 つに無い名前を「その他(<名前>)」と出す。画面も同じ列挙を `@ndf-factory/contracts/phases`(依存を持たない入口)から読む。ランナーの翻訳は NDF のプランの JSON の `"フェーズ"` の値を次のとおり写す。プランの JSON が読めない・表に無い値は計画の名前の断片(`5-impl-2172` なら `impl`)を送る。 + +| プランの `"フェーズ"` | 写すフェーズの名前 | +| --- | --- | +| `設計`・`実装`・`検査` | 同じ名前 | +| `配布(開発版)` | `取り込み` | +| `配布(本番)`・`まとめ` | `仕上げ` | + +**ランナーは `step.recorded` に工程の中身を任意の項目として添える(`schema_version` は 1 のまま)。** 項目は `phase`(プランの `"フェーズ"` を写した名前)・`issues`(計画の名前の課題の番号。スプリントで 1 つの計画は持たない)・`files`(その工程の時間の窓 `at − seconds` 〜 `at` に作業場所の git で作られたコミットが触ったファイルと増減の行数。最大 100 件で、外した数を `files_omitted`)・`commit_range`(窓の最初のコミットの親と最後のコミット)・`decisions`(判断の台帳の `decision` の行のうち、`cwd` が作業場所の下で `at` が窓の中のもの。最大 20 件)・`output`(ステップの出力。`work` のステップは作業の報告、`run` のステップはログ。UTF-8 で 32 KiB まで)である。集められない項目は省き、工程の送信は止めない。`output.text`・`decisions` の文・`files` のパスは、送る前に NDF の `secret_redact.py` の 4 つの形の規則と、ランナーが持つ秘密の値(実行ジョブの token・導入の token・持ち込んだキー・引き受けたロールの資格)の置き換えで伏せる。事象 1 件が 256 KiB を超えるときは `output.text` → `files` → `decisions` の順に削り、それでも超えれば工程の中身をすべて外す。同じステップの行から作る `step.recorded` と `usage.recorded` は同じ `at` で送る。 + +| 派生の値 | 導き方 | +| --- | --- | +| 一覧の今のフェーズ | その実行の最後の `phase.changed`(`order_key` の順)の `phase`。0 件なら `null`(画面は「始まる前」) | +| 工程のフェーズ | `step.recorded` の `phase`。無ければ同じ実行ジョブの直前の `phase.changed` の `phase` | +| 課題の欄 | `step.recorded` の `issues` の課題ごとに 1 回ずつ。`issues` が無いか空なら「スプリント全体」の欄 | +| 課題の欄の Pull Request | 欄の工程の `pull_request` を重複なく。状態は最後の `pr.updated`(無ければ `unknown`)、チェックは名前ごとの最後の `ci.updated` の結論(`null` は実行中) | +| 工程の費用 | 同じ `job_id`・`payload.step`・`at` の `usage.recorded` の `cost_usd` の和。無ければ `null` | +| 承認の欄 | ゲート 1・ゲート 2・判断の 3 つ。その種類の最後の `gate.reached` で待ちになり、その後の同じ種類の `gate.answered` で承認・差し戻し(判断は答えた)になる。`gate.reached` の前の答えは状態を変えない | +| 仕上げの欄 | 振り返り(`step` が `retro`)と棚卸し(`refine`)の行を常に持ち、記録が無ければ「まだ」 | +| 詳細の件数の上限 | `step.recorded` は新しい方から 2,000 件までで、省いた数を `omitted_steps` に返す。ほかの種類はすべて読み、承認の欄と使用量の和から欠かさない | +| バックログの棚卸しの記録 | そのリポジトリの実行(新しい順に 50 件まで)の、`step` が `refine` の `step.recorded` を新しい順に | + +詳細の応答は工程の中身の本文を持たず(`has_details` で有無だけを示す)、画面は工程を押したときに工程の中身の経路で 1 件ずつ読む。一覧・詳細・工程の中身・バックログは、他人のアカウント・無いアカウント・アカウントに属さない実行・無い実行・無い工程・登録の無いリポジトリのどれでも、同じ本文の 403 を返す。読み出しは事象の表・実行の表・GitHub へ書かない。画面は一覧と詳細を開いている間、画面の設定の間隔(環境変数 `FACTORY_POLL_INTERVAL_MS`、既定 30 秒)で読み直し、押し出しの通知は使わない。 + ### 実行ジョブの指示の定義 `factory/contracts/schemas/run-job-instruction.schema.json`。全体の契約の「実行ジョブの指示」の 11 の入力と、#2347 が足した開発ループの 5 つの入力(下の表の後ろの 5 行)を、環境変数の名前のまま `properties` に置く。値はどれも文字列で(環境変数と、#2189 の受け取りの応答で同じ形にするため)、`additionalProperties: false`。生成した型は `RunJobInstruction`。 @@ -443,7 +471,7 @@ graph TD - 課題: [#2171](https://github.com/devbasex/ai-plugins/issues/2171)(要求と受け入れ条件の正本)、親の [#2168](https://github.com/devbasex/ai-plugins/issues/2168)、照合と起票の依頼の [#2216](https://github.com/devbasex/ai-plugins/issues/2216)(設計 Pull Request [#2233](https://github.com/devbasex/ai-plugins/pull/2233))、相手先の流儀の判定の [#2239](https://github.com/devbasex/ai-plugins/issues/2239) - 設計 Pull Request: [#2201](https://github.com/devbasex/ai-plugins/pull/2201)、実装: [#2207](https://github.com/devbasex/ai-plugins/pull/2207)・[#2209](https://github.com/devbasex/ai-plugins/pull/2209) - サービス全体の契約: [ndf-factory-service-boundary.md](ndf-factory-service-boundary.md) -- 認証とアカウント・席・プランの使える量(#2170): [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) +- 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) - サービスのディレクトリとパッケージの管理: [factory/README.md](../../factory/README.md) - 受け口の契約: [factory/contracts/openapi/ingest.yaml](../../factory/contracts/openapi/ingest.yaml)、定義: [factory/contracts/schemas/](../../factory/contracts/schemas/) - 用語: [用語集](../glossary.md) diff --git a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md index de53d6c3d..41e3f8e0b 100644 --- a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md +++ b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md @@ -1,6 +1,6 @@ # Neural Development Factory: 開発ループのデータと契約 -[開発ループと並行する実行の調整](ndf-factory-dev-loops.md) の実行の表の行・移行の埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・NDF の受け渡しの形・手元のランナーの申告・画面向けの経路の契約を定める(ndf-factory-dev-loops.md から分けた)。機械可読な正は `factory/contracts/` にある。 +[開発ループと並行する実行の調整](ndf-factory-dev-loops.md) の実行の表の行・移行の埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・NDF の受け渡しの形・手元のマシンの申告・画面向けの経路の契約を定める(ndf-factory-dev-loops.md から分けた)。機械可読な正は `factory/contracts/` にある。 ## データ・設定 @@ -14,7 +14,7 @@ erDiagram "開発ループ" ||--o{ "スプリントの実行" : "周回ごとに起こす" "開発ループ" ||--|{ "マイルストーンの持ち分" : "占める" "スプリントの実行" ||--o{ "課題の持ち分" : "占める" - "手元のランナーの枠" ||--o{ "実行ジョブ" : "数える" + "手元のマシンの枠" ||--o{ "実行ジョブ" : "数える" ``` | 行 | `pk` | `sk` | `gsi1pk` / `gsi1sk` | @@ -23,7 +23,7 @@ erDiagram | ループの実行(周回の開始の書き込みで足す。列は `sprintRunId`) | `ACCOUNT#` | `LOOPRUN##` | — | | マイルストーンの持ち分 | `ACCOUNT#` | `MILESTONE##<題の sha256 の先頭 32 字>` | — | | 課題の持ち分 | `ACCOUNT#` | `ISSUE##<番号>` | — | -| 手元のランナーの枠 | `ACCOUNT#` | `RUNNER#` | — | +| 手元のマシンの枠 | `ACCOUNT#` | `RUNNER#` | — | | 移行の済み(表に 1 つ) | `MIGRATION` | `MIGRATED#2347` | — | | 行 | 列 | 型 | 空 | 意味 | @@ -42,18 +42,18 @@ erDiagram | ループ | `answers` | `{number, decision, pr}` の並び | 不可 | 画面の答え。次のジョブの `LOOP_ANSWERS` に載り、`pending` から消えた番号は外す | | ループ | `cancelRequested`・`createdBy`・`createdAt`・`updatedAt`・`lastRunAt` | — | `lastRunAt` だけ可 | 取り消しの要求と時刻(`lastRunAt` は最後の実行の `updatedAt`) | | 持ち分 | `loopId`・`title` / `sprintRunId` | 文字列 | 不可 | 占めるループと題 / 占める実行 | -| ランナーの枠 | `count` | 数 | 不可 | `running` の `local` のジョブの数(I50) | +| 手元のマシンの枠 | `count` | 数 | 不可 | `running` の `local` のジョブの数(I50) | | 実行 | `loopId`・`lap` | 文字列・数 | 可 | 空は手動の実行 | | 実行 | `legacy` | 真偽 | 可 | 古い版として `legacyCount` に数えた実行。作成と移行の埋め戻しで書き、終わりへの移りで真のときだけ `legacyCount` を減らす。空は「埋め戻していない既存の実行」 | | 実行 | `finishReason` | 文字列 | 可 | `job.finished` の `reason`(`section_limit` / `usage_limit`) | | 実行 | `resumeAt` | 文字列 | 可 | `awaiting_reset` の間だけ。リセット時刻(読めなければ `USAGE_LIMIT_RETRY_SECONDS` 後) | -| ジョブ | `runnerId` | 文字列 | 可 | `local` で受け取ったランナー。手元のランナーの枠を減らす先 | +| ジョブ | `runnerId` | 文字列 | 可 | `local` で受け取った手元のマシン。手元のマシンの枠を減らす先 | | リポジトリの枠 | `legacyCount` | 数 | 不可 | 並行を許す版の下限より古い版の、終わりの状態でない実行の数(I48)。無いのは 0 | -| 登録 | `maxConcurrentRuns` | 数 | 可 | 空は「宣言しない(プランの値)」 | +| 登録 | `maxConcurrentRuns` | 数 | 可 | 空は「宣言しない(料金プランの値)」 | 実行の状態には `awaiting_reset` がある(終わりの状態でない)。`maxConcurrentRuns`・`loopId` などの無い行は空として読む。 -| 機能 | ループ | 実行 | ジョブ | 枠・持ち分・ランナーの枠 | 登録 | 進捗の記録 | +| 機能 | ループ | 実行 | ジョブ | 枠・持ち分・手元のマシンの枠 | 登録 | 進捗の記録 | | --- | --- | --- | --- | --- | --- | --- | | 始める | C | C | C | C・U | R | — | | 止める | U | U | — | U・D | — | R | @@ -138,7 +138,7 @@ erDiagram - 規則の持ち主は NDF で、NDF の変更が例を直す。ランナーは実行時に NDF をマーケットプレイスから入れ、`plugins/` を参照しないため、規則を写す。写した規則が古くなるのを、NDF の pytest(`next_blocks`・`_mark_action`)と Factory の vitest(`next-block.ts`)が同じ例の組を読むことで防ぐ(I55) - `scripts/contracts_lib/model.py` は `forms/*.json` を読み、上の鍵の型を確かめる(知らない鍵は断る)。Factory はテストだけが `../plugins/ndf/contracts/forms/ndf-next-block.json` を読む。区分 `forms/` は [NDF の入口の契約](ndf-entrypoint-contracts.md) の一覧にある -### 手元のランナーの申告(`local-runner-declaration.schema.json`) +### 手元のマシンの申告(`local-runner-declaration.schema.json`) | 鍵 | 型・必須 | 意味 | | --- | --- | --- | @@ -146,7 +146,7 @@ erDiagram | `max_jobs` | 1〜16 の整数・任意(無ければ 1) | 同時のジョブの数 | | `capacity_hint` | 1 以上の整数・任意 | 目安。表示だけに使う | -手元のランナーの登録と心拍の本文はこの形を含む。 +手元のマシンの登録と心拍の本文はこの形を含む。 ### 画面向けの経路(`openapi/loops.yaml`・`openapi/runs.yaml`) diff --git a/docs/specifications/ndf-factory-dev-loops.md b/docs/specifications/ndf-factory-dev-loops.md index 3dc2bb1c3..aee611953 100644 --- a/docs/specifications/ndf-factory-dev-loops.md +++ b/docs/specifications/ndf-factory-dev-loops.md @@ -1,6 +1,6 @@ -# Neural Development Factory: 開発ループと並行する実行の調整・ジョブの中の区間・利用上限・手元のランナーの同時のジョブの数 +# Neural Development Factory: 開発ループと並行する実行の調整・ジョブの中の区間・利用上限・手元のマシンの同時のジョブの数 -Neural Development Factory の利用者は、リポジトリごとに方針の違う開発ループを Web から始め・止め・予算を変え、一覧と詳細で周回と止まった理由を見る。同じリポジトリで並行する実行どうしの取り合い(課題・マイルストーン・NDF の版・同時の数)と、手元のランナーの同時のジョブの数は機械が守る。ランナーは 1 つの実行ジョブの中で `ndf-next` の区間をつなぎ、手元の relay.py と同じく続ける。 +Neural Development Factory の利用者は、リポジトリごとに方針の違う開発ループを Web から始め・止め・予算を変え、一覧と詳細で周回と止まった理由を見る。同じリポジトリで並行する実行どうしの取り合い(課題・マイルストーン・NDF の版・同時の数)と、手元のマシンの同時のジョブの数は機械が守る。ランナーは 1 つの実行ジョブの中で `ndf-next` の区間をつなぎ、手元の relay.py と同じく続ける。 ## 概要 @@ -11,13 +11,13 @@ Neural Development Factory の利用者は、リポジトリごとに方針の ## 用語 -定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`・`ndf-workflow`)。この文書で使う語は「開発ループ」「周回」「マイルストーンの持ち分」「課題の持ち分」「手元のランナーの枠」「区間」「区間の上限」「同時のジョブの数」「人の判断待ち」「リセット時刻」である。 +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`・`ndf-workflow`)。この文書で使う語は「開発ループ」「周回」「マイルストーンの持ち分」「課題の持ち分」「手元のマシンの枠」「区間」「区間の上限」「同時のジョブの数」「人の判断待ち」「リセット時刻」である。 ## コンテキスト | コンテキスト | 何の語が 1 つの意味に決まるか | | --- | --- | -| Neural Development Factory(`ndf-factory`) | 開発ループ(集約)・スプリントの実行・実行の枠・マイルストーンの持ち分・課題の持ち分・手元のランナーの枠・区間・区間の上限・同時のジョブの数 | +| Neural Development Factory(`ndf-factory`) | 開発ループ(集約)・スプリントの実行・実行の枠・マイルストーンの持ち分・課題の持ち分・手元のマシンの枠・区間・区間の上限・同時のジョブの数 | | NDF の開発ワークフロー(`ndf-workflow`) | 開発ループ(`loop.json`)・周回・ループの予算・止まる条件・人の判断待ち・`ndf-next`・リセット時刻 | `ndf-workflow` が上流、`ndf-factory` が下流の腐敗防止層である。Factory の開発ループは NDF のループの記録(`.ndf/loop/<名>/loop.json`)を読まず、ランナーが翻訳した進捗の事象(`loop.lap_closed`・`loop.stopped`)だけを読む。翻訳はランナーの `factory/runner/src/loop-file.ts` と `translate.ts` だけが持つ。`ndf-next` のブロックの読み方は NDF の契約の一覧が公開された言語として宣言し、ランナーは形を写す。 @@ -27,8 +27,8 @@ Neural Development Factory の利用者は、リポジトリごとに方針の | 要素 | 場所 | 責務 | | --- | --- | --- | | ループの制御 | `factory/api/src/runs/loops.ts`(`LoopControl`) | 始める・止める・予算・答え・次の周回の操作、`startNextLap`(周回の開始の判定とトランザクション)、`wakeWaitingLoops`(待っているループを古い順に起こす)、停止の後の処理が使うループの状態の写し | -| 実行の制御 | `factory/api/src/runs/control.ts`(`RunControl`) | 手動の開始の課題の持ち分・マイルストーンの照合・版の照合・リポジトリの同時の数。停止の後の処理で `loop.*` と `job.finished` の理由を読み、ループの写しと `awaiting_reset` を同じトランザクションへ入れる。見回りの再開の時刻と待っているループ。`claimLocalRunJob` の手元のランナーの枠 | -| 実行の表 | `factory/api/src/runs/table.ts`・`dynamo-table.ts`・`memory-table.ts` | ループの行・持ち分の行・手元のランナーの枠の行と、3 集約をまたぐ条件つきの書き込み | +| 実行の制御 | `factory/api/src/runs/control.ts`(`RunControl`) | 手動の開始の課題の持ち分・マイルストーンの照合・版の照合・リポジトリの同時の数。停止の後の処理で `loop.*` と `job.finished` の理由を読み、ループの写しと `awaiting_reset` を同じトランザクションへ入れる。見回りの再開の時刻と待っているループ。`claimLocalRunJob` の手元のマシンの枠 | +| 実行の表 | `factory/api/src/runs/table.ts`・`dynamo-table.ts`・`memory-table.ts` | ループの行・持ち分の行・手元のマシンの枠の行と、3 集約をまたぐ条件つきの書き込み | | 移行の埋め戻し | `factory/api/src/runs/backfill.ts` | 既存の終わりの状態でない実行を古い版として `legacyCount` と課題の持ち分へ写し、移行の済みの行を書く | | 上限の判定 | `factory/api/src/accounts/limits.ts`(`judgeLimits`) | 理由 `concurrent_repo_runs`(リポジトリの枠の数と登録の宣言) | | 取り込み | `factory/api/src/repositories/service.ts` | 登録の `maxConcurrentRuns` の宣言 | @@ -93,11 +93,11 @@ graph TD LF --> NDF MAIN --> RAPI MAIN --> ING - CAP -.->|手元のランナーの登録と心拍| RC + CAP -.->|手元のマシンの登録と心拍| RC READ --> ING ``` -ループの制御は既存の API の handler と、停止の知らせと見回りの Lambda の中で動く。手元のランナーは利用者の環境で、`max_jobs` の数までジョブを別の作業場所で並べて動かす。 +ループの制御は既存の API の handler と、停止の知らせと見回りの Lambda の中で動く。手元のマシンは利用者の環境で、`max_jobs` の数までジョブを別の作業場所で並べて動かす。 ## 仕様 @@ -107,12 +107,12 @@ graph TD | --- | --- | --- | --- | | 開発ループ | ループの制御(`LoopControl`) | ループ | 名前・選定の対象のマイルストーン・予算・状態・待ちの理由・止まった理由・人の判断待ちの写し・答え・締めた周回の和 | | スプリントの実行 | 実行の制御 | スプリントの実行(エンティティ: 実行ジョブ) | 属するループ・周回の番号・終わりの理由・再開の時刻 | -| 実行の枠 | 実行の制御 | アカウントの枠 | リポジトリの枠の古い版の数・マイルストーンの持ち分・課題の持ち分・手元のランナーの枠 | +| 実行の枠 | 実行の制御 | アカウントの枠 | リポジトリの枠の古い版の数・マイルストーンの持ち分・課題の持ち分・手元のマシンの枠 | | リポジトリの登録 | 取り込み | 登録 | 同時の実行の数の宣言 | - ほかの集約は識別子で参照する。ループは登録を `repositoryId`、今の実行を `currentRunId` で指し、実行はループを `loopId` で指す - 1 つの処理が書き換える集約は 1 つで、例外は「開発ループ・スプリントの実行・実行の枠」の 3 つを 1 つのトランザクションで書く周回の開始・実行の終わりへの移り・ループの取り消しである。ループの「今の実行」と実行の作成を別のトランザクションにすると、間で止まったときに見回りがもう 1 本を起こすためである。実行の制御は停止の後の処理でループの写しを同じトランザクションへ入れるが、書く中身はループの制御の関数が組む -- 持ち分と手元のランナーの枠は実行の枠の行である。取り合いの条件を、実行と同じトランザクションの条件つきの書き込みで守るためである(読んでから書くと同時の 2 本がどちらも通る) +- 持ち分と手元のマシンの枠は実行の枠の行である。取り合いの条件を、実行と同じトランザクションの条件つきの書き込みで守るためである(読んでから書くと同時の 2 本がどちらも通る) - DynamoDB のトランザクションは 100 件まで書け、最も多い手動の開始(実行 1・ジョブ 1・枠 2・課題の持ち分 20・マイルストーンの持ち分の照合が題ごとに 1 件で多くて 20 の 44 件)でも足りる ### 常に成り立つ条件 @@ -124,8 +124,8 @@ graph TD | I46 | 実行の枠 | 枠の数は終わりの状態でない実行の数に等しい。`between_laps` のループは枠の行を持たない | ループは枠の行を書かない。枠は実行の作成と終わりへの移りでだけ増減する | | I47 | 実行の枠 | 同じリポジトリで、1 つの課題を持つ終わりの状態でない実行は 1 つまで | 手動の開始が課題の持ち分の `attribute_not_exists` の書き込みと、課題のマイルストーンの持ち分の `attribute_not_exists` の照合を同じトランザクションで行う。前者で落ちれば 409 `issue_in_use`、後者で落ちれば 409 `issue_in_loop`。ループの周回へは、強い整合で読んだ持ち分の課題を `EXCLUDE_ISSUES` で渡す | | I48 | 実行の枠 | 並行を許す版の下限より古い NDF の版の実行がリポジトリにあれば、その実行は唯一である | リポジトリの枠の `legacyCount` と `count` を条件にし、落ちれば 409 `parallel_unsupported_version`(ループは待つ)。移行の済みの行が無い間は、すべての版を古い版として扱う | -| I49 | 実行の枠 | リポジトリの終わりの状態でない実行の数は、プランの同時の数と登録の宣言の小さい方以下 | `judgeLimits` が `concurrent_repo_runs` を理由に断る(ループは待つ) | -| I50 | 実行の枠 | 1 つの手元のランナーが `running` で持つ `local` のジョブの数は、そのランナーが申告した `max_jobs` 以下 | 受け取りが手元のランナーの枠を条件に書き、落ちれば `null`(渡さない) | +| I49 | 実行の枠 | リポジトリの終わりの状態でない実行の数は、料金プランの同時の数と登録の宣言の小さい方以下 | `judgeLimits` が `concurrent_repo_runs` を理由に断る(ループは待つ) | +| I50 | 実行の枠 | 1 つの手元のマシンが `running` で持つ `local` のジョブの数は、その手元のマシンが申告した `max_jobs` 以下 | 受け取りが手元のマシンの枠を条件に書き、落ちれば `null`(渡さない) | | I51 | スプリントの実行 | 1 つの実行ジョブが起動する区間は区間の上限以下 | ランナーが次の区間を起動せず `job.finished`(`stopped`・`section_limit`)で終える | | I52 | 開発ループ | `local` のループの次の周回は、利用者の「次の周回を始める」の操作か、設定 `LOCAL_LOOP_AUTOSTART=true` のときだけ起こる | 自動の起動は `waitReason` が `user_start` のループを飛ばす | | I53 | 開発ループ | ループの状態は、属する実行の終わり方と `loop.stopped` の条件から、下の「ループの状態の写し方」の表のとおりにだけ決まる | 表に無い組は `stopped`(`run_failed`)にする | @@ -151,7 +151,7 @@ I8・I9・I32〜I43 は [スプリントの実行と実行ジョブの状態](nd | E11 | 利用上限でジョブを終えた(`usage_limit`) | ランナー | 実行の制御(実行を `awaiting_reset`) | | E12 | リセット時刻の後に再開を起こした | 実行の制御(見回り) | ランナー(`START=resume`) | | E13 | 利用者がループを止めた(`cancelled`) | 実行の制御(画面の操作) | 実行の制御(`cancelSprintRun` と同じ形で今の実行を止める) | -| E14 | 手元のランナーが同時のジョブの数を申告した | 手元のランナー(登録と心拍) | 実行の制御(`claimLocalRunJob` の条件) | +| E14 | 手元のマシンが同時のジョブの数を申告した | 手元のマシン(登録と心拍) | 実行の制御(`claimLocalRunJob` の条件) | ### ループの状態 @@ -251,7 +251,7 @@ sequenceDiagram | 実行を `awaiting_reset`(`resumeAt`)にする | `job.finished` が `stopped`・`usage_limit` で、形態が `local`(I54。ほかの形態では `failed`) | | 実行の `finishReason` | `job.finished` に `reason` がある | | 課題の持ち分を消す(`sprintRunId` がその実行の行だけ)・`legacyCount` を減らす(実行の `legacy` が真のときだけ) | 実行が終わりの状態へ移る。実行を移す書き込みに読んだ時の `legacy` の値を条件として入れ(真・偽・`attribute_not_exists(legacy)`)、落ちたら実行と持ち分を読み直してやり直す。読んだ後に埋め戻しが `legacy` を真にしたとき、減算と削除を漏らさないためである | -| 手元のランナーの枠を減らす | ジョブを終わりへ移す最後の書き込み(`runnerId` がある) | +| 手元のマシンの枠を減らす | ジョブを終わりへ移す最後の書き込み(`runnerId` がある) | | ループの写し(上の表)・`lapsClosed` などの和・`pending`・持ち分の削除 | 実行が `loopId` を持つ。`loop.lap_closed` と `loop.stopped` をジョブの冪等の鍵の範囲で読む | トランザクションが通った後に、ループが `between_laps` なら `startNextLap`、枠が減ったなら `wakeWaitingLoops` を呼ぶ。どちらも落ちてよく、次の見回りが同じ結果で起こし直す。 @@ -316,11 +316,11 @@ sequenceDiagram | 6. 答えの書き込み | `LOOP_ANSWERS` の各組のうち、`loop.json` の人の判断待ちに答えの無い番号だけ `sprint-state.py loop answer <名> <番号> --approved \| --rejected [--pr <番号>]` を打つ | | 7. conductor | `/ndf:development-workflow --loop --milestones [--budget-laps …]`。子の環境に `NDF_LOOP_EXCLUDE=` を置く | -### 手元のランナーの同時のジョブの数 +### 手元のマシンの同時のジョブの数 -- `claimLocalRunJob(accountId, {runnerId, maxJobs})` は、そのランナーの手元のランナーの枠が `maxJobs` 未満のときだけ、ジョブを `running` にする書き込みと枠を 1 増やす書き込みを 1 回のトランザクションで行う(I50)。引数の 2 つ目が無い呼び出しは `max_jobs` 1・ランナーの識別子なしとして扱う -- 申告の値の保存は手元のランナーの登録の集約に属し、この仕様は受け取りの条件と申告の形と目安の関数を持つ -- 目安は `max(1, min(floor(CPU の数 × 3 / 4), floor(メモリの GiB / 5)))` である(2026-09-25 の実測、8 コア・30 GB で全体テスト 6 本から 1 本あたり 4/3 コア・5 GiB)。申告が目安を超えれば手元のランナーが標準エラーへ 1 行の警告を出し、申告はそのまま送る。手元の機械の資源を知るのは手元だけで、サービスは申告を切り詰めない +- `claimLocalRunJob(accountId, {runnerId, maxJobs})` は、その手元のマシンの枠が `maxJobs` 未満のときだけ、ジョブを `running` にする書き込みと枠を 1 増やす書き込みを 1 回のトランザクションで行う(I50)。引数の 2 つ目が無い呼び出しは `max_jobs` 1・ランナーの識別子なしとして扱う +- 申告の値の保存は手元のマシンの登録の集約に属し、この仕様は受け取りの条件と申告の形と目安の関数を持つ +- 目安は `max(1, min(floor(CPU の数 × 3 / 4), floor(メモリの GiB / 5)))` である(2026-09-25 の実測、8 コア・30 GB で全体テスト 6 本から 1 本あたり 4/3 コア・5 GiB)。申告が目安を超えれば手元のマシンが標準エラーへ 1 行の警告を出し、申告はそのまま送る。手元の機械の資源を知るのは手元だけで、サービスは申告を切り詰めない ### 予算の残りと費用 @@ -358,7 +358,7 @@ sequenceDiagram ## データ・設定 -実行の表の行・移行の埋め戻し・環境変数と、外部との契約(実行ジョブの指示・進捗の事象・NDF の受け渡しの形・手元のランナーの申告・画面向けの経路)は [開発ループのデータと契約](ndf-factory-dev-loops-data-and-contracts.md) にある。 +実行の表の行・移行の埋め戻し・環境変数と、外部との契約(実行ジョブの指示・進捗の事象・NDF の受け渡しの形・手元のマシンの申告・画面向けの経路)は [開発ループのデータと契約](ndf-factory-dev-loops-data-and-contracts.md) にある。 ## エラー処理 @@ -383,7 +383,7 @@ sequenceDiagram - 下限より古い版の実行があるリポジトリで 2 本目が 409 `parallel_unsupported_version` になり、下限が設定に無ければ 2 本目が断られ、下限以上の版どうしは並ぶこと - 移行の済みの行が無い間は下限以上の版でも 2 本目が断られ、埋め戻しの後に既存の実行の課題を指す手動の開始が 409 `issue_in_use` になること。埋め戻しを 2 回流しても `legacyCount` が増えず、埋め戻す前に終わった実行で `legacyCount` が負にならないこと - `step` が `base-sync` の `attention` の行だけが `reason: base_conflict` の事象になること -- プランの値 3 で宣言 1 のリポジトリで 2 本目が `concurrent_repo_runs` で断られ、宣言が無ければプランの値で断られること +- 料金プランの値 3 で宣言 1 のリポジトリで 2 本目が `concurrent_repo_runs` で断られ、宣言が無ければ料金プランの値で断られること - `max_jobs` 1 のランナーへ 2 本目を渡さず、1 本目が終わると渡し、申告が無ければ 1 として扱うこと。目安が 8 コア・30 GiB で 6、2 コア・4 GiB で 1 になり、目安を超える申告で警告の 1 行が出て申告は変わらないこと - 答えると次のジョブの `LOOP_ANSWERS` に載り、ランナーが `loop answer` を打ち、答えの済んだ番号は打たないこと。ループの実行の `gate.reached` が `loop` を持つこと - `local` の周回の後にループが `user_start` で待ち、`next-lap` で起き、`LOCAL_LOOP_AUTOSTART=true` なら待たず、`managed`・`byok` は待たないこと @@ -400,7 +400,7 @@ sequenceDiagram - [リポジトリの登録・実行の制御・ランナー](ndf-factory-runs-and-runner.md) - [スプリントの実行と実行ジョブの状態・実行の表](ndf-factory-runs-state-and-data.md) - [契約の定義と進捗の受け口](ndf-factory-contracts-and-progress.md) -- [認証とアカウント・席・プラン](ndf-factory-accounts-and-plans.md) +- [認証とアカウント・席・料金プラン](ndf-factory-accounts-and-plans.md) - [画面の地図](ndf-factory-screen-map.md) - [開発ループのデータと契約](ndf-factory-dev-loops-data-and-contracts.md) - [NDF の入口の契約](ndf-entrypoint-contracts.md) diff --git a/docs/specifications/ndf-factory-runs-and-runner.md b/docs/specifications/ndf-factory-runs-and-runner.md index 1aab744d4..621e8bbf6 100644 --- a/docs/specifications/ndf-factory-runs-and-runner.md +++ b/docs/specifications/ndf-factory-runs-and-runner.md @@ -44,7 +44,7 @@ Neural Development Factory の利用者が自分の GitHub のリポジトリを ## 対象範囲 - **含む**: `factory/api/src/` の取り込み(`repositories/`)・キーの持ち込み(`credentials/`)・GitHub の口(`github/`)・実行の制御(`runs/`)・進捗の記録の読み出し(`progress/read.ts`)・画面向けの経路(`routes/factory.ts`)・ランナー向けの経路と停止の知らせと見回りの handler(`handlers/run-jobs.ts`・`handlers/run-control.ts`)・`index.ts` への export、`factory/runner/`(ランナーとイメージ)、`factory/web/src/pages/` の 3 画面(リポジトリ・キーの持ち込み・スプリントを始める)、`factory/contracts/openapi/runs.yaml`、`factory/.env.example` の 14 の変数の名前 -- **含まない**: 承認の依頼の保存と答えを返す経路の実装(#2173。経路の形だけを `runs.yaml` に置く)、手元のランナー(形態 ①)の登録・心拍・受け取りの経路(#2189。本書は渡す口の関数だけを持つ)、一覧と詳細の画面(#2172)、料金と台帳(#2174)、承認ゲートの外の問い合わせを止める hook と `ask.jsonl` の書き手(#2206)、ECS・S3・Secrets Manager・DynamoDB・EventBridge・GitHub App の作成などインフラの定義(private リポジトリ) +- **含まない**: 承認の依頼の保存と答えを返す経路の実装(経路の形だけを `runs.yaml` に置く)、手元のマシン(形態 ①)の登録・心拍・受け取りの経路(本書は渡す口の関数だけを持つ)、一覧と詳細の画面、料金と台帳、承認ゲートの外の問い合わせを止める hook と `ask.jsonl` の書き手、ECS・S3・Secrets Manager・DynamoDB・EventBridge・GitHub App の作成などインフラの定義(private リポジトリ) - **取り込むリポジトリの起点・テスト・配布・進め方の入力欄は持たない。** ランナーの中の NDF がそのリポジトリの `.ndf/` から読む(全体の契約の決定 8) - **止まった・失敗したスプリントの実行を同じ実行のまま続け直さない。** 新しいスプリントの実行として始める - プラグインの配布物・Skill・hook は変わらない。`plugins/` は `factory/` を参照しない(全体の契約の I1) @@ -76,7 +76,7 @@ Neural Development Factory の利用者が自分の GitHub のリポジトリを | 実行の表 | `factory/api/src/runs/table.ts`(`RunsTable`)・`dynamo-table.ts`・`memory-table.ts` | 条件つきの書き込みを操作の単位で持つ。`memory-table.ts` はテスト用の偽物 | | 実行基盤の口 | `factory/api/src/runs/platform.ts`(`RunnerPlatform`)・`ecs.ts`(`EcsRunnerPlatform`) | `RunTask`・`StopTask`・`DescribeTasks`、token の置き場の作成と削除、状態の置き場の署名つき URL。テストは偽物 | | 依存の組み立て | `factory/api/src/runs/deps.ts`(`factoryDepsFromEnv`・`ndfVersionFromMarketplace`) | 環境変数から依存を 1 回だけ作り、秘密を ARN から読む | -| 進捗の記録の読み出し | `factory/api/src/progress/read.ts`(`ProgressReader`) | `job.finished` を冪等の鍵で読む・月の `usage.recorded` の token を足す。読むだけで、保存の型に書き換えを足さない | +| 進捗の記録の読み出し | `factory/api/src/progress/read.ts`(`ProgressReader`) | `job.finished` を冪等の鍵で読む・月の `usage.recorded` の token を足す・スプリントの一覧と詳細の事象を読む([スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し))。読むだけで、保存の型に書き換えを足さない | | 画面向けの経路 | `factory/api/src/routes/factory.ts`(`registerFactory`) | 既存の API の handler(`handlers/api.ts`)に足す。どの経路も `resolveFromRequest` でアカウントを解決する | | ランナー向けの経路 | `factory/api/src/runs/runner-api.ts`(`createRunnerApiApp`)・`handlers/run-jobs.ts` | 実行ジョブの token で認証し、導入の token の渡し直しと状態の置き場の署名つき URL を返す | | 停止の知らせと見回りの handler | `factory/api/src/handlers/run-control.ts`(`handleRunControlEvent`) | HTTP を持たない。EventBridge の「ECS Task State Change」と 5 分ごとの起動を受ける | @@ -108,7 +108,7 @@ graph TD end ING[進捗の受け口 #2171] C2173["#2173 承認と通知"] - C2189["#2189 手元のランナー"] + C2189["#2189 手元のマシン"] PR --> REPO PK --> CRED PS --> CAND @@ -138,18 +138,18 @@ graph TD | 名前 | 使う側 | 入力 | 出力 | 失敗 | | --- | --- | --- | --- | --- | | `requestResume({sprintRunId, approvalRequestId})` | #2173(答えを保存した後) | 実行と依頼の識別子 | `Promise<{jobId}>`。`START=resume` のジョブを作り起動する | `ResumeRejectedError`(`code` が `not_found` / `not_awaiting_approval` / `sprint_run_busy`)。`sprint_run_busy` は前のジョブの停止を待っている間か、別の再開が先に通ったときで、呼ぶ側は時間をおいて呼び直す | -| `claimLocalRunJob(accountId)` | #2189(手元のランナーの受け取り) | アカウント | `Promise<{instruction: RunJobInstruction, token} \| null>`。そのアカウントの最も古い `pending` の `local` のジョブ(取り消しの要求の無い実行のもの)を `running` にし、指示と token を 1 回だけ返す | 保存の失敗は投げる | +| `claimLocalRunJob(accountId)` | #2189(手元のマシンの受け取り) | アカウント | `Promise<{instruction: RunJobInstruction, token} \| null>`。そのアカウントの最も古い `pending` の `local` のジョブ(取り消しの要求の無い実行のもの)を `running` にし、指示と token を 1 回だけ返す | 保存の失敗は投げる | | `reportLocalRunJobEnded(jobId)` | #2189(終わりか心拍の切れ) | ジョブ | `Promise`。停止の知らせと同じ処理 | 知らないジョブ・`local` でないジョブ・終わったジョブは何もしない | -| `cancelSprintRun(ctx, sprintRunId)` | 画面の経路と同じ処理 | アカウントの文脈と実行 | `Promise<{state: "cancelled" \| "running"}>` | `NotFoundError`(`sprint_run_not_found`。別のアカウントの実行を含む)・`ConflictError`(`already_finished`) | +| `cancelSprintRun(ctx, sprintRunId)` | 画面の経路と同じ処理 | アカウントの文脈と実行 | `Promise<{state: "cancelled" \| "running"}>` | `NotFoundError`(`sprint_run_not_found`。別のアカウントの実行を含む)・`ConflictError`(`already_finished`。main へマージした後の配布の実行をジョブの無いときに取り消すと `release_after_main`) | | `getSprintRun(sprintRunId)`・`getRegistration(accountId, repositoryId)` | 実行と登録を読む側 | 識別子 | 行か `null` | — | あわせて `RunControl`・`ResumeRejectedError`・`repoKey`(`owner/name` を小文字にした比較の鍵)と、`ResumeRejectedCode`・`RunControlConfig`・`RunControlDeps`・`RunJob`・`SprintRun`・`RunState`・`JobState` の型を export する。 -**`local` のジョブの token は置き場に書かず、`claimLocalRunJob` の応答で 1 回だけ返す。** 指示の形(`INGEST_TOKEN_SECRET_ARN` は必須)を保つため、その項目には `arn:aws:secretsmanager:local:000000000000:secret:ndf-factory/run-jobs/` の形の、実在しない ARN を置く。手元のランナーは環境変数 `INGEST_TOKEN` で token を直に渡し、ランナーはそれがあれば置き場を読まない。 +**`local` のジョブの token は置き場に書かず、`claimLocalRunJob` の応答で 1 回だけ返す。** 指示の形(`INGEST_TOKEN_SECRET_ARN` は必須)を保つため、その項目には `arn:aws:secretsmanager:local:000000000000:secret:ndf-factory/run-jobs/` の形の、実在しない ARN を置く。手元のマシンは環境変数 `INGEST_TOKEN` で token を直に渡し、ランナーはそれがあれば置き場を読まない。 ### 画面向けの経路 -機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。 +機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。 | 経路 | 誰が | 入力 | 成功 | 失敗 | | --- | --- | --- | --- | --- | @@ -164,8 +164,8 @@ graph TD | `DELETE /accounts/{a}/model-credential` | 持ち主 | — | 204(無くても 204) | 403・503 `credential_unavailable` | | `POST /accounts/{a}/repositories/{r}/issue-candidates` | 席を持つ人 | `{exclude?: [番号]}`(選び直しで外す番号) | 200 `{source: "ranking" \| "recent", milestone, candidates: [{number, title, selected, reason}], note}` | 400(ID の形)・403・404 `repository_not_found` | | `POST /accounts/{a}/sprint-runs` | 席を持つ人 | `{repository_id, issues}`(1〜20 件の重なりの無い番号) | 201 `{sprint_run_id, state: "starting"}` | 400・403・404・409・503(下の表) | -| `GET /accounts/{a}/sprint-runs?repository_id=` | 席を持つ人 | — | 200 `{sprint_runs: [{sprint_run_id, repository_id, issues, state, failure_reason, created_at}]}`(新しい順 50 件) | 400(ID の形)・403 | -| `POST /accounts/{a}/sprint-runs/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished` | +| `GET /accounts/{a}/sprint-runs?repository_id=` | 席を持つ人 | — | 200 `{sprint_runs: [{sprint_run_id, repository_id, issues, state, failure_reason, created_at, repo, current_phase, current_step, last_event_at}]}`(新しい順 50 件。後ろの 4 項目は進捗の事象から導く。規則は [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | 400(ID の形)・403・503 `progress_unavailable` | +| `POST /accounts/{a}/sprint-runs/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished`・`release_after_main` | **持ち主だけの経路は、席を持つだけの利用者に他人のアカウントと同じ 403 `forbidden` を返す**(`requireOwner`)。チームのアカウントでは、リポジトリの登録と外し・認可・候補・キーの 3 つの操作は持ち主(`role` が `owner`)だけが、スプリントの開始・一覧・取り消しと課題の候補は席を持つ人が行う。 @@ -413,7 +413,7 @@ Workflows・Administration・Projects は持たない。Workflows は CI を書 - **ワークフローを変える課題は Factory では進められない。** App が Workflows の権限を持たないため push が断られ、ジョブは `failed` で終わる。手元の NDF で進める - **ランナーの中の NDF のボードの記録は失敗する。** App が Projects の権限を持たないためで、NDF はボードの失敗で工程を止めない - **持ち込んだキーを消すと、その `byok` の実行の次のジョブは起動の時点で置き場を読めずに `failed` になる。** 実行は開始の時点のキーの ARN を持ち(I39)、消した後に保存し直したキーは新しい ARN になるためである。動いているジョブは起動の時点で読んだ値で続ける -- **`local` の `pending` のジョブには 15 分の上限を掛けない。** 手元のランナーが受け取るまで枠を使い続ける。止めるには取り消す +- **`local` の `pending` のジョブには 15 分の上限を掛けない。** 手元のマシンが受け取るまで枠を使い続ける。止めるには取り消す - **承認の依頼の答えの経路(`GET /run-jobs/approval-requests/{id}/answer`)は #2173 が実装するまで無い。** それまで `resume` のジョブは答えを読めず `failed` で終わる - **実環境の形は、インフラが入った後のリリース後テストで確かめる。** EventBridge の ECS の知らせの形・GitHub App の利用者の認可の応答・2 本の同時の実行の登録から完了まで @@ -463,7 +463,7 @@ Workflows・Administration・Projects は持たない。Workflows は CI を書 - 実装: [#2248](https://github.com/devbasex/ai-plugins/pull/2248)・[#2250](https://github.com/devbasex/ai-plugins/pull/2250) - サービス全体の契約: [ndf-factory-service-boundary.md](ndf-factory-service-boundary.md) - 受け口と実行ジョブの token(#2171): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) -- 認証とアカウント・席・プランの使える量(#2170): [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) +- 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) - 経路の契約: [factory/contracts/openapi/runs.yaml](../../factory/contracts/openapi/runs.yaml) - サービスのディレクトリと API の入口: [factory/README.md](../../factory/README.md) - 代わりの判断を残す問い合わせの guard(#2206): [ndf-ask-guard.md](ndf-ask-guard.md)、記録の形: [plugins/ndf/scripts/lib/ask_ledger.py](../../plugins/ndf/scripts/lib/ask_ledger.py) diff --git a/docs/specifications/ndf-factory-runs-state-and-data.md b/docs/specifications/ndf-factory-runs-state-and-data.md index ddcc163aa..4cefc4fc5 100644 --- a/docs/specifications/ndf-factory-runs-state-and-data.md +++ b/docs/specifications/ndf-factory-runs-state-and-data.md @@ -32,13 +32,15 @@ stateDiagram-v2 `RUNNING` の知らせを取りこぼしたときは `starting` から終わりの状態や `awaiting_approval` へ直に移る。`running` への移りは `starting` のときだけ書く。 +Factory のオーガナイザーの配布の依頼と配布の実行では、`awaiting_approval` に理由(`awaitingReason`)が付く。手動の実行が配布の依頼を出して終わると、理由 `release_requested` の `awaiting_approval` で枠を持ったまま依頼の答えを待つ(ループの実行は `done` で終わる)。配布の実行(目的 `release`)は枠を持たず、配布済みなら `done`、承認ゲートか判断なら理由の無い `awaiting_approval`、それ以外の終わり方(失敗・打ち切り)でも終わりの状態にせず理由 `release_stalled` の `awaiting_approval` へ移る。配布の実行が終わりの状態の `stopped` になるのは、まとめた配布が差し戻されたときである。取り消しも、まとめた配布が main へマージした後なら終わりの状態にせず、取り消しの印を外して理由 `release_stalled` の `awaiting_approval` へ移す(続きから流して配布を仕上げる)。ジョブの無いときの取り消しは `release_after_main` で断る。 + **失敗の理由**(実行の `failureReason`): | 値 | いつ | | --- | --- | | `launch_failed` | token の置き場の作成か `RunTask` が失敗した。`pending` のまま 15 分を過ぎた | | `timeout` | 見回りが打ち切りの時間で止めた | -| `task_stopped` | ランナーの `job.finished` が無いままタスクが止まった(手元のランナーの終わりの知らせを含む) | +| `task_stopped` | ランナーの `job.finished` が無いままタスクが止まった(手元のマシンの終わりの知らせを含む) | | `job_failed` | ランナーが `job.finished`(`failed`)を送った。`local` 以外の形態の利用上限(`stopped`・`usage_limit`)も含む | | `seat_missing` | リセット時刻の後の再開で、始めた利用者が席を持たないか、形態が `local` でなくなっていた | @@ -108,7 +110,7 @@ erDiagram | スプリントの実行 | `RUN#` | `RUN` | `ACCOUNT#` / `RUN#` | | 実行ジョブ | `JOB#` | `JOB` | `ACTIVE_JOBS` / `#`(終わりの状態でない間だけ。終わりへ移すときに外す) | -列の名前は `factory/api/src/runs/model.ts` の型の名前のまま(camelCase)である。#2347 が足した行(開発ループ・課題とマイルストーンの持ち分・手元のランナーの枠・移行の済みの行)と列(実行の枠の `legacyCount`、実行の `legacy`・`resumeAt`・`loopId` など)は [開発ループの確定仕様の「実行の表の行」](ndf-factory-dev-loops-data-and-contracts.md#実行の表の行) にある。 +列の名前は `factory/api/src/runs/model.ts` の型の名前のまま(camelCase)である。#2347 が足した行(開発ループ・課題とマイルストーンの持ち分・手元のマシンの枠・移行の済みの行)と列(実行の枠の `legacyCount`、実行の `legacy`・`resumeAt`・`loopId` など)は [開発ループの確定仕様の「実行の表の行」](ndf-factory-dev-loops-data-and-contracts.md#実行の表の行) にある。 | 行 | 列 | 型 | 空 | 意味 | | --- | --- | --- | --- | --- | @@ -173,7 +175,7 @@ erDiagram | `USAGE_LIMIT_RETRY_SECONDS` | 制御 | リセット時刻を読めないときの待ち。既定 900 | | `RUNNER_SECTION_LIMIT` | ランナー | 1 つのジョブで起動する区間の上限。既定 20 | -API は要る変数が無ければ最初の要求の処理で失敗する。ランナーは指示の 16 項目と `RUNNER_SECTION_LIMIT` のほかに `NDF_MARKETPLACE`・`BEDROCK_REGION`・`RUNNER_WORK_DIR`・`NDF_MVV_STATE_DIR`(`ask.jsonl` の置き場)と、手元のランナーの `INGEST_TOKEN` を読む。 +API は要る変数が無ければ最初の要求の処理で失敗する。ランナーは指示の 16 項目と `RUNNER_SECTION_LIMIT` のほかに `NDF_MARKETPLACE`・`BEDROCK_REGION`・`RUNNER_WORK_DIR`・`NDF_MVV_STATE_DIR`(`ask.jsonl` の置き場)と、手元のマシンの `INGEST_TOKEN` を読む。 ## 関連リンク diff --git a/docs/specifications/ndf-factory-screen-map.md b/docs/specifications/ndf-factory-screen-map.md index 039901c3e..67817d9cc 100644 --- a/docs/specifications/ndf-factory-screen-map.md +++ b/docs/specifications/ndf-factory-screen-map.md @@ -29,20 +29,20 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ | --- | --- | --- | --- | --- | | `/login` | ログイン | ログイン前 | あり | Google・GitHub でログイン([accounts-and-plans](ndf-factory-accounts-and-plans.md)「ログインの流れ」) | | `/callback` | ログインの戻り | ログイン前 | あり | code を token に換える(accounts-and-plans「ログインの流れ」) | -| `/account` | 自分のアカウント | 個人・管理者・席 | あり | 利用者の ID・個人のプランと実行の形態・使える量・チームの一覧・ログアウト(accounts-and-plans) | +| `/account` | 自分のアカウント | 個人・管理者・席 | あり | 利用者の ID・個人の料金プランと実行の形態・使える量・チームの一覧・ログアウト(accounts-and-plans) | | `/teams/new` | チームを作る | 個人 | あり | Team を作る(accounts-and-plans) | | `/accounts/:accountId` | チームのアカウント | 管理者・席 | あり | 席の追加・種類の変更・削除(管理者)・実行の形態(管理者)・使える量(accounts-and-plans) | | `/accounts/:accountId/repositories` | リポジトリ | 個人・管理者・席 | あり | 導入の案内・登録と外す(個人・管理者)・同時の実行の数の宣言(個人・管理者)・実行の状態([runs-and-runner](ndf-factory-runs-and-runner.md)「画面」) | | `/accounts/:accountId/model-credential` | キーの持ち込み | 個人・管理者 | あり | キーの保存と消去・外部 ID(runs-and-runner) | | `/accounts/:accountId/repositories/:repositoryId/sprints/new` | スプリントを始める | 個人・管理者・席 | あり | 課題の候補・選び直し・開始(runs-and-runner) | -| `/accounts/:accountId/sprints` | スプリントの一覧 | 個人・管理者・席 | あり(見本) | フェーズと状態・取り消し | -| `/accounts/:accountId/sprints/:sprintRunId` | スプリントの詳細 | 個人・管理者・席 | あり(見本) | 工程・PR・CI・承認ゲート・起票の依頼([issue-requests](ndf-factory-issue-requests.md)) | +| `/accounts/:accountId/sprints` | スプリントの一覧 | 個人・管理者・席 | あり | 今のフェーズ(5 つの棒)と今の工程・状態・状態の絞り込み・最後の動き([contracts-and-progress](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | +| `/accounts/:accountId/sprints/:sprintRunId` | スプリントの詳細 | 個人・管理者・席 | あり | フェーズの欄(仕上げに振り返りと棚卸し)・課題ごとの工程の記録・工程の詳細パネル・Pull Request と CI・承認ゲート・使用量(contracts-and-progress「スプリントの一覧と詳細の読み出し」)・起票の依頼([issue-requests](ndf-factory-issue-requests.md)) | | `/accounts/:accountId/repositories/:repositoryId/loops` | ループ | 個人・管理者・席 | あり | 開発ループの一覧(名前・選定の対象・状態・周回の数・費用・予算の残り・最後に動いた時刻・止まった理由)・始める | | `/accounts/:accountId/loops/:loopId` | ループの詳細 | 個人・管理者・席 | あり | 周回・周回の実行と区間の数・利用上限の再開の時刻・人の判断待ちと答え・予算の変更・止める・次の周回を始める | -| `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | 予定 | 順位の表と棚卸しの記録 | +| `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | あり | 順位の表と棚卸しの記録(読むだけ) | | 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知 | -| 未定 | 請求と使用量 | 個人・管理者 | 予定 | プランの料金・使用量の台帳・決済 | -| 未定 | 手元のランナー | 個人・管理者 | 予定 | ランナーの登録・心拍 | +| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済 | +| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍 | ## ナビゲーション @@ -59,18 +59,19 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ ## 見本の画面 -スプリントの一覧と詳細は、部品だけで組み、ダミーデータで動く見本である。並べる項目・フェーズの集合・工程の詳細パネルは #2172 が置き換え、経路は変えない。 +スプリントの一覧と詳細とバックログは、部品だけで組み、ダミーデータでも動く。一覧と詳細は開いている間、画面の設定の間隔(環境変数 `FACTORY_POLL_INTERVAL_MS`、既定 30 秒)で読み直し、読み直しが失敗しても前の表示を残して失敗の時刻を出す。 | 状態 | 一覧 | 詳細 | | --- | --- | --- | | 読み込み中 | `LoadingState` | 同じ | -| 0 件・見つからない | `EmptyState`「スプリントの実行はまだありません」 | `EmptyState`「このスプリントの実行は見つかりません」 | +| 0 件 | `EmptyState`「スプリントの実行はまだありません」 | 工程が 0 件なら `EmptyState`「工程の記録はまだありません」 | | 失敗(5xx など) | `ErrorState` と「もう一度読む」 | 同じ | | 401 | token を消してログインへ | 同じ | -| 403 | 「このアカウントを見る権限がありません」 | 同じ | +| 403(無い実行も同じ) | 「このアカウントを見る権限がありません」 | 同じ | +| 読み直しの失敗 | 前の表示を残し、上に「読み直せませんでした(<時刻>)」 | 同じ | | オフライン | 枠の `ConnectionStatus` が出し、画面は最後の表示を残す | 同じ | -詳細は `Api` の `sprintRunDetails`(実行の ID の配列を受け、要素の配列を返す)で読む。本物の実装は詳細の経路がまだ無いため、アカウントの新しい順の 50 件から選ぶ。 +詳細は `Api` の `sprintRunDetails`、工程の中身は `stepDetails`、バックログは `backlogs` で読む(どれも ID の配列を受け、要素の配列を返す)。本物の実装は経路を 1 件ずつ読む。 ## ダミーで起動する @@ -86,6 +87,6 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ ## 関連リンク - [画面の部品](ndf-factory-ui-components.md) -- [認証とアカウント・席・プラン](ndf-factory-accounts-and-plans.md) +- [認証とアカウント・席・料金プラン](ndf-factory-accounts-and-plans.md) - [リポジトリの登録・スプリントの実行の制御・ランナー](ndf-factory-runs-and-runner.md) - [起票の依頼と起票の代行](ndf-factory-issue-requests.md) diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md index 4724a1056..05d31d582 100644 --- a/docs/specifications/ndf-factory-service-boundary.md +++ b/docs/specifications/ndf-factory-service-boundary.md @@ -137,15 +137,15 @@ graph TD | 課題 | 受け持つ範囲 | 提供する契約 | 使う契約 | | --- | --- | --- | --- | -| #2170 | Cognito の利用者プール(Google・GitHub)・アカウント・席・プランと使える量・実行の形態の値の画面と API | `resolveAccount(caller, accountId?)` が返す `account_id`(token には載せず、検証した token の利用者と経路 `/accounts/{account_id}/…` から解決する。経路に無ければ個人のアカウント)、使える量の問い合わせ(同時のスプリントの数・月の token の上限)、アカウントの実行の形態 | 無し | +| #2170 | Cognito の利用者プール(Google・GitHub)・アカウント・席・料金プランと使える量・実行の形態の値の画面と API | `resolveAccount(caller, accountId?)` が返す `account_id`(token には載せず、検証した token の利用者と経路 `/accounts/{account_id}/…` から解決する。経路に無ければ個人のアカウント)、使える量の問い合わせ(同時のスプリントの数・月の token の上限)、アカウントの実行の形態 | 無し | | #2171 | `factory/contracts/`・進捗の受け口・実行ジョブの token の検証 | 実行ジョブの指示・進捗の事象の種類と形・受け口の認証と応答・実行ジョブの token の検証(token から実行ジョブと `sprint_run_id`・`repo` を返し、無効なら拒む) | #2170 の `resolveAccount` が返す `account_id`、#2173 の保存済みの答え(`gate.answered` の照合) | | #2186 | GitHub App・リポジトリの登録・スプリントの実行と実行ジョブ・クラウドのランナー(形態 ② と ③)・持ち込んだキーの保存・実行ジョブの token の発行 | 実行の制御への再開の要求の受け口、実行ジョブを渡す口(#2189 が使う) | #2171 の `factory/contracts/`・進捗の受け口・token の検証、#2170 の `resolveAccount` が返す `account_id` と使える量と実行の形態、#2173 の承認の依頼の答えを読む経路 | -| #2172 | 一覧と詳細の画面・派生の形・フェーズの名前の集合 | 無し | 進捗の事象(`phase.changed`・`step.recorded`・`pr.updated`・`ci.updated`) | +| #2172 | 一覧・詳細・バックログの画面と読み出しの経路・派生の形・フェーズの名前の集合・ランナーの工程の中身の翻訳 | フェーズの名前の列挙(`factory/contracts/src/phases.ts`)、`step.recorded` の工程の中身の任意の項目 | 進捗の事象(`phase.changed`・`step.recorded`・`gate.reached`・`gate.answered`・`usage.recorded`・`decision.delegated`・`pr.updated`・`ci.updated`)、#2186 のスプリントの実行の行と GitHub の口の順位の表、#2170 の `resolveAccount` が返す `account_id` | | #2173 | 承認の依頼・Web と Slack の通知・Web からの承認 | `gate.answered`、承認の依頼の答えを読む経路、実行の制御への再開の要求 | `gate.reached`・`attention`、#2171 の実行ジョブの token の検証 | -| #2174 | プランの料金・使用量の台帳・決済の手段と提供元・Bedrock の使用量の帰属(形態 ③ だけ) | 請求の画面と API | `usage.recorded`、#2170 のプランと実行の形態 | -| #2189 | 手元のランナー(形態 ①)の登録・心拍・実行ジョブの受け取り | 無し | #2186 の実行ジョブを渡す口と実行ジョブの token、#2171 の `factory/contracts/`・進捗の受け口、#2170 の `resolveAccount` が返す `account_id` と実行の形態 | +| #2174 | 料金プランの料金・使用量の台帳・決済の手段と提供元・Bedrock の使用量の帰属(形態 ③ だけ) | 請求の画面と API | `usage.recorded`、#2170 の料金プランと実行の形態 | +| #2189 | 手元のマシン(形態 ①)の登録・心拍・実行ジョブの受け取り | 無し | #2186 の実行ジョブを渡す口と実行ジョブの token、#2171 の `factory/contracts/`・進捗の受け口、#2170 の `resolveAccount` が返す `account_id` と実行の形態 | -各課題が本節の外で決めること: `factory/` の TypeScript のパッケージの管理の道具は #2170 が npm の workspaces に決めた(規則は [factory/README.md の「パッケージの管理」](../../factory/README.md#パッケージの管理))。ランナーで使える CLI と実行ジョブの打ち切りの時間は #2186 が決めた(「実行の制御」)。`phase.changed` が運ぶフェーズの名前の集合は #2172、決済の提供元と Bedrock の請求との突き合わせは #2174 が決める。 +各課題が本節の外で決めること: `factory/` の TypeScript のパッケージの管理の道具は #2170 が npm の workspaces に決めた(規則は [factory/README.md の「パッケージの管理」](../../factory/README.md#パッケージの管理))。ランナーで使える CLI と実行ジョブの打ち切りの時間は #2186 が決めた(「実行の制御」)。`phase.changed` が運ぶフェーズの名前の集合は #2172 が 5 つに決め、一覧と詳細の派生の規則とともに [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し) にある。決済の提供元と Bedrock の請求との突き合わせは #2174 が決める。 ### 構成と言語 @@ -153,16 +153,16 @@ graph TD | 要素 | 責務 | 課題 | | --- | --- | --- | -| 画面 | ログイン・リポジトリの登録・スプリントの一覧と詳細・承認・プランと請求 | 各課題が各画面 | +| 画面 | ログイン・リポジトリの登録・スプリントの一覧と詳細・承認・料金プランと請求 | 各課題が各画面 | | API | 画面からの要求を受け、アカウントで認可して各集約の持ち主へ渡す | 各課題が自分の経路 | -| 認証とプラン | 利用者プールとアカウント・席・プランの使える量 | #2170 | +| 認証と料金プラン | 利用者プールとアカウント・席・料金プランの使える量 | #2170 | | 取り込み | GitHub App の導入とリポジトリの登録 | #2186 | -| 実行の制御 | スプリントの実行と実行ジョブの状態を持ち、ジョブを起動し終了を受ける。同時の数をプランの使える量で抑える。実行ジョブの token を発行する | #2186 | +| 実行の制御 | スプリントの実行と実行ジョブの状態を持ち、ジョブを起動し終了を受ける。同時の数を料金プランの使える量で抑える。実行ジョブの token を発行する | #2186 | | ランナー | 実行ジョブの中身。取り込み・NDF の導入・conductor の起動・翻訳・送信・状態の保存 | #2186 | | 進捗の受け口 | ランナー・GitHub の通知・API から進捗の事象を受け、出所ごとに認証して冪等に追記する。実行ジョブの token を検証する | #2171 | | 一覧と詳細 | 進捗の事象から一覧と詳細の読み出しの形を導く | #2172 | | 承認と通知 | 承認の依頼を作って知らせ(Web・Slack)、答えを実行の制御へ渡す | #2173 | -| 課金 | 使用量の事象を台帳へ積み、プランの料金に上乗せして請求する | #2174 | +| 課金 | 使用量の事象を台帳へ積み、料金プランの料金に上乗せして請求する | #2174 | ```mermaid graph TD @@ -180,7 +180,7 @@ graph TD APR[承認と通知] BILL[課金] end - AUTH[認証とプラン] + AUTH[認証と料金プラン] REPO[取り込み] WEB --> API API --> AUTH @@ -229,7 +229,7 @@ graph TD | 集約 | 持ち主 | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | -| アカウント | 認証とプラン(#2170) | アカウント | 席 | プラン・使える量 | +| アカウント | 認証と料金プラン | アカウント | 席 | 料金プラン・使える量 | | リポジトリの登録 | 取り込み(#2186) | 登録 | — | GitHub のリポジトリの識別子・GitHub App の導入の識別子 | | スプリントの実行 | 実行の制御(#2186) | スプリントの実行 | 実行ジョブ | NDF の版・スプリントの状態・ジョブの結果 | | 実行の枠 | 実行の制御(#2186) | アカウントの枠 | — | 終わりの状態でない数(アカウント全体・リポジトリごと) | @@ -246,7 +246,7 @@ graph TD | 形態 | 値 | ランナーの場所 | Claude をどう呼ぶか | サービスが持つ Claude の認証情報 | 月の token の上限 | token の課金 | 受け持つ課題 | | --- | --- | --- | --- | --- | --- | --- | --- | -| ① 手元のランナー | `local` | 利用者の環境 | 利用者がログインした `claude` | 無し | 掛けない | しない | #2189 | +| ① 手元のマシン | `local` | 利用者の環境 | 利用者がログインした `claude` | 無し | 掛けない | しない | #2189 | | ② キーの持ち込み | `byok` | ECS Fargate | 利用者の Anthropic API キーか、利用者の AWS アカウントの Bedrock(ロールを引き受ける) | 利用者のキーかロールの ARN(Secrets Manager) | 掛けない | しない | #2186 | | ③ サービスの Bedrock | `managed` | ECS Fargate | サービスの AWS アカウントの Bedrock(タスクの役割の権限) | 無し(役割の権限) | 掛ける | する(#2174) | #2186・#2174 | @@ -317,7 +317,7 @@ erDiagram ### 実行ジョブの指示 -実行の制御からランナーへ、コンテナの環境変数で渡す。形態 ① では、手元のランナーが受け取りの応答で同じ項目を受け取る(#2189)。 +実行の制御からランナーへ、コンテナの環境変数で渡す。形態 ① では、手元のマシンが受け取りの応答で同じ項目を受け取る。 | 入力 | 型・必須 | 意味 | | --- | --- | --- | @@ -345,8 +345,8 @@ erDiagram | 種類 | 出所 | `payload` | 主な受け手 | | --- | --- | --- | --- | | `job.started` | ランナー | NDF の版・`START` | #2172 | -| `phase.changed` | ランナー(`sprint.json` の計画と `progress.jsonl` の `step` の行) | フェーズの名前・課題 | #2172 | -| `step.recorded` | ランナー(`progress.jsonl` の `step`) | ステップ・終了コード・秒・要約・このステップが作った・更新した Pull Request(リポジトリと番号。任意) | #2172 | +| `phase.changed` | ランナー(`sprint.json` の計画と `progress.jsonl` の `step` の行) | フェーズの名前([スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し) の 5 つ)・課題 | #2172 | +| `step.recorded` | ランナー(`progress.jsonl` の `step`) | ステップ・終了コード・秒・要約・このステップが作った・更新した Pull Request(リポジトリと番号。任意)・工程の中身(フェーズ・課題・触ったファイル・コミットの範囲・決めたこと・出力。どれも任意) | #2172 | | `attention` | ランナー(`progress.jsonl` の `attention`) | 理由・要約 | #2172・#2173 | | `gate.reached` | ランナー(作業の報告の `判断が要る` と承認資料) | 種類(ゲート 1・ゲート 2・判断)・Pull Request の URL・承認資料の本文 | #2173 | | `gate.answered` | API(#2173) | 承認の依頼の識別子・種類・承認か差し戻しか(判断では無し)・答えた人のアカウント。本文は事象に載せない | #2171・#2172 | @@ -493,7 +493,7 @@ stateDiagram-v2 - 子課題: [#2170](https://github.com/devbasex/ai-plugins/issues/2170)・[#2171](https://github.com/devbasex/ai-plugins/issues/2171)・[#2172](https://github.com/devbasex/ai-plugins/issues/2172)・[#2173](https://github.com/devbasex/ai-plugins/issues/2173)・[#2174](https://github.com/devbasex/ai-plugins/issues/2174)・[#2186](https://github.com/devbasex/ai-plugins/issues/2186)・[#2189](https://github.com/devbasex/ai-plugins/issues/2189) - 設計 Pull Request: [#2175](https://github.com/devbasex/ai-plugins/pull/2175)、実装: [#2177](https://github.com/devbasex/ai-plugins/pull/2177) - サービスのディレクトリ: [factory/README.md](../../factory/README.md) -- 認証とアカウント・席・プランの使える量(#2170): [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) +- 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) - 契約の定義と型・進捗の受け口・実行ジョブの token(#2171): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) - 境界の検査: [scripts/check-service-boundary.py](../../scripts/check-service-boundary.py)、許可の一覧: [scripts/service-boundary-allow.json](../../scripts/service-boundary-allow.json) - 追加待ちの扱い: [必須のチェックの突き合わせ](ci-required-checks-reconcile.md) diff --git a/docs/specifications/ndf-parallel-organizer.md b/docs/specifications/ndf-parallel-organizer.md new file mode 100644 index 000000000..d3d37b282 --- /dev/null +++ b/docs/specifications/ndf-parallel-organizer.md @@ -0,0 +1,350 @@ +# 並列の計画を 1 度書き、オーガナイザーがレーンを起動して本番の配布を 1 本にまとめる + +1 つのリポジトリで複数の開発ループかスプリントを tmux のレーンとして同時に進めるために、並列の計画(`plan.json`)を 1 度書き、 +`/goal /ndf:development-workflow --parallel <計画名>` の 1 行でオーガナイザーを始める。進行は DBOS の耐久ワークフロー +`parallel-<計画名>` 1 本が持つ。耐久ワークフローはレーンの起動・観測と、配布の依頼のまとめを行う。区分つきの問いもここへ集まる。 +オーガナイザーの conductor は返った 1 手を打つだけである。この文書は、Skill の文書に書いていない部分を残す。 +扱うのは設計判断の結論と理由・常に成り立つ条件・メッセージとイベントの形・置き場・テスト観点である。 + +**手順と利用者向けの形は Skill の文書が正である。ここへは写さない。** + +| 何を読むか | 正本 | +| --- | --- | +| 並列の計画の作り方・`plan.json` の項目・検査の種類(I1〜I4・I19)・`init` が書くもの | `plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md` | +| オーガナイザーの手順・`wait` の `action` ごとの 1 手・問い方・`answer` / `confirm` / `settle` の打ち分け・セッションの切り替え・一時レーンの手順・レーンの側・止める・異常のときの問い・数え方 | `plugins/ndf/skills/development-workflow/references/parallel.md` | +| `--parallel` / `--parallel-helper` の振り分け | `plugins/ndf/skills/development-workflow/SKILL.md` の「並列開発(`--parallel`)」 | +| レーンの問いを問いの hook がオーガナイザーへ送る判定と、送られたときの保留の手順 | `plugins/ndf/skills/development-workflow/references/asking.md` の「並列の計画のレーン」と [ndf-ask-guard.md](ndf-ask-guard.md) の「並列の計画のレーンの問い」 | +| ループのレーンの `配布待ち`・`質問待ち`・`waiting_only`・周回の境で答えを読む順 | `plugins/ndf/skills/development-workflow/references/loop.md` の「並列の計画のレーン」と [ndf-workflow-development-loop.md](ndf-workflow-development-loop.md) | +| レーンの本番のステージを配布の依頼のプラン(`parallel-request`)へ置き換える | [ndf-production-release-record.md](ndf-production-release-record.md) の「並列の計画のレーンの本番のステージ」 | +| 本番チャネルへの Pull Request の規則(head が `release/v<版>`・承認したコミットから切る・`release/v<版>-sync` で版数の衝突を解く・`scripts/check-pr-base.sh`) | [docs/versioning-and-distribution.md](../versioning-and-distribution.md) の「正式版を出す」の前後と、`plugins/ndf/skills/release/references/release-steps.md` の `release` の項 | +| オーガナイザーと一時レーンの数え方(役割 `organizer` / `helper`) | [ndf-relay-multi-session-supervisor.md](ndf-relay-multi-session-supervisor.md) の決定と理由、parallel.md の「数え方」 | +| 用語(並列の計画・レーン・オーガナイザー・配布の依頼・一時レーン・保留の周回・配布待ち・質問待ち) | [docs/glossary.md](../glossary.md)(正本は `docs/glossary/glossary.json`) | +| 配布の依頼の形 | `plugins/ndf/contracts/forms/parallel-request.json` | +| 環境変数の宣言 | `plugins/ndf/contracts/env/NDF_PARALLEL_MAX_PLANS.json`・`NDF_PARALLEL_STALL_MINUTES.json`・`NDF_PARALLEL_POLL.json`・`NDF_PARALLEL_RELAY.json`・`NDF_RELAY_MAX_NAMED.json` | + +## 概要 + +**例: 2026-11-02 の配布の衝突。** 利用者は 3 本の開発ループ(lroot2・lflow・lfactory2)を tmux のセッションとして手で起動した。 +3 本の引継ぎ文書には、共通の決まりを手で書き写した。lflow が ndf v10.27.14 を出したとき、`develop` にあった lfactory2 と +lroot2 の変更もまとめて出た。そのため lroot2 が出していた本番の Pull Request https://github.com/devbasex/ai-plugins/pull/2420 は閉じることになった。 + +並列の計画の下では、同じ状況は次のように進む。 + +| 順 | 誰が | 何をする | 何が起きるか | +| ---: | --- | --- | --- | +| 1 | conductor | `parallel.py init p1` に 3 レーンの下書きを渡す | `.ndf/parallel/p1/plan.json`・`plan.md` と、レーンごとの引継ぎ文書を書く。決まりは `plan.json` の `rules` の 1 か所だけに置く | +| 2 | 利用者 | `/goal /ndf:development-workflow --parallel p1 -- 終わり: …` を打つ | オーガナイザーが `parallel.py start p1` で耐久ワークフローを切り離して起動する。耐久ワークフローが 3 レーンを `relay.py spawn` で起動する | +| 3 | lflow | 本番のステージに来る | 本番の代わりに配布の依頼を送り、周回を `配布待ち` で締めて次の周回へ進む | +| 4 | 耐久ワークフロー | 依頼を受ける。流れている配布は無い | 他のレーンを待たずに、まとめた配布 1(提示 1)を組む。中身は承認資料を作る時点の `origin/develop` のコミットまでの範囲 `main..` で決まる。範囲には lroot2・lfactory2 の Pull Request も入り、内訳に載る | +| 5 | 一時レーン `p1-rel1` | 提示のプランを流す | 承認ゲート 2 で止まり、`kind: gate2` の依頼をオーガナイザーへ送る | +| 6 | オーガナイザー | 承認ゲート 2 を 1 回だけ問い、`confirm` を打つ | `develop` が進んでいなければ提示 1 で確定する。一時レーンが本番のプランを流し、結果を `settle` で返す | +| 7 | 耐久ワークフロー | 範囲の依頼へ答え、`range-1` を立てる | lroot2 は後で本番のステージに来たとき、自分の Pull Request がすでに `main` にあるため依頼を送らずに `released` で進む。2 本目の本番の Pull Request は作られない | + +## 背景 + +- 統括役(#2346)で名前つきのラッパーを並べて起動できるようになったが、並列開発を計画して始める手順と、本番の配布を調整する役が無かった +- 本番の配布は `develop` の先頭を `main` へ出していた。レーンごとに本番を出すと、先に出したレーンが `develop` にある他のレーンの変更も出す +- 人の答え待ちは 10 分〜40 時間あり(`.ndf/loop/*/loop.json` の `waiting`)、本番のプランの所要(855〜1,406 秒)より 1 桁以上長い。 + 答えを待つ間に他のレーンのマージが `develop` へ入る見込みが高い +- 夜の間にすべてのレーンが問いで止まると、利用者はレーンを回って答えることになる + +## 対象範囲 + +- **働く範囲**: NDF を導入したすべてのリポジトリ。起動まで行うのは、Claude Code をラッパー(`relay.py`)の下で動かしていて、tmux と統括役があるときだけである。 + それ以外は、`start` / `run` がレーンごとに手で起動する 1 行を返して終了コード 3 で終わる。 + 手で起動したレーンは耐久ワークフローに属さないため、今どおり自分で本番を出す +- **1 台の中に閉じる**: まとめるのは 1 台の中のレーンだけである。進行は 1 つの SQLite(DBOS)に閉じる。 + Neural Development Factory の実行をまとめるのは Factory のオーガナイザー(https://github.com/devbasex/ai-plugins/issues/2439)で、 + 同じ層の別の実装として互いに排他にする。依頼の形は契約 `parallel-request.json` として公開してある。 + Factory の実行ジョブ(環境変数 `NDF_FACTORY_RUN`)では、`parallel.py` の `init`・`start`・`run` が終了コード 1 で断る。 + 外のオーガナイザーのレーン(環境変数 `NDF_ORGANIZER_DIR`)は、計画名の代わりに `--external` を付けて依頼と答えを置き場のファイルで受け渡す。 + 置き場の形は契約 `parallel-external.json`、手順は parallel.md の「外のオーガナイザーのレーン」にある +- **モード**: オーガナイザーはモードを持たない。レーンの中のスプリントは今どおりモードを判定する +- **扱わないもの**: `main` へのホットフィックス、動いている計画へのレーンの追加(新しい計画名で `init` し直す)、GitHub API の同時本数の枠 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 新しい Skill を作らず、計画は `issue-plan-strategy`、起動は `development-workflow` の `--parallel <計画名>` で受ける | 分割と並行の手順は既存の 2 つの Skill の担当で、同じ役割の手順を 2 か所に分けない。`--loop` と同じ形の引数で、覚えるエントリポイントが増えない | +| オーガナイザーを置き、起動の後も残して本番の配布を 1 本にまとめる | 調整する役が無ければ配布の衝突は再発する。起動のブロックを並べるだけの形では衝突を防げない | +| 並列の計画の正本を `plan.json`(pydantic の `ParallelPlan`)にし、`plan.md` を描画にする | 検査はスクリプトが行い、表の解析より JSON の検証の方が誤りが少ない。`loop.json` と `laps.md` の組と同じ読み方で済む。JSON Schema では名前の重なりと依存先の照合が schema の外に出て、検査が 2 か所に分かれる | +| 進行を DBOS の耐久ワークフロー 1 本とメッセージ・イベントで作り、検査を pydantic と `graphlib.TopologicalSorter` で行う | 状態を残す・別のプロセスから依頼を受けて答える・待つ・落ちた後に続ける、を依存にある部品がすでに持つ。状態ファイル・依頼のファイル・停止の印を自分で書かない。Optuna・Prefect・Airflow・Snakemake などは、長く動いて人の承認を待つ流れに合わないか、サーバーが要る | +| オーガナイザーの conductor は記録から決まる 1 手だけを打つ | 切り替えのたびに状態を読み直して判断する費用と、判断の揺れを無くす。再開のコマンドは起動と同じ 1 行になる | +| `run` は `start` がセッションから切り離して起動する(`start_new_session=True`、出力は `run.log`、pid は `run.pid`) | Bash の背景の子は、セッションの切り替えで消える恐れがある。2 本目の `run` は実行の鍵の排他で動いている `run` を指して 0 で終わる | +| オーガナイザーと一時レーンの印を、作業ディレクトリの役割のファイル(`role.json`)で持つ | 統括役は作業ディレクトリのファイルだけを読んで数える。環境変数は統括役から読めない。登録簿は名前の無い前景のラッパーを表せない | +| レーンかどうかを、ラッパーの名前(`NDF_RELAY_NAME`)と終わっていない計画のレーン名の照合で決める(`parallel_lane.lane_of`) | レーンの最初の入力(`--loop`・`#<課題>`)に引数を足さずに済む。ループの記録・再開のコマンド・スプリントの状態の 3 か所へ引数を引き継がなくてよい | +| 本番のステージを組む 1 つの関数だけが、レーンのときに配布の依頼のプランを返す | `supervise_lib/sprint.py` の 4 か所の組み立てに同時に効き、ループでないスプリントのレーンにも効く | +| 配布の中身を、依頼の集合ではなく承認資料を作る時点の `origin/develop` のコミットの範囲で決める | 依頼を送った時点で、他のレーンの変更もすでに `develop` にある。依頼の集合で中身を決めると、実際に出る中身と一致しない(Pull Request 2420 の件) | +| 依頼が届いた時点で流れている配布が無ければ、他のレーンを待たずに直ちに配布を組む。配布は 1 本ずつ直列にする | まとめるための待ち時間を置かない。範囲に入らない依頼は、今の配布の直後に組む次の配布になる | +| 承認の答えの時点で `develop` が進んでいれば、同じ承認ゲート 2 を 1 回だけ出し直す。出し直しへの答えでは比べない | 出し直さないと、待つ間にマージしたレーンが次の配布で 2 回目の答え待ちを負う。回数の上限なしで出し直すと配布が始まらない。資料の無い範囲を承認させると、承認したものと出すものが食い違う | +| 承認ゲート 2 は提示のプランが持ち、本番のプランは確定の後にだけ流す | 本番のプランは流し始めるときに worktree と `release/v<版>` を作る。答えの前に作ると、提示 2 の本番のプランが同じ名前のブランチを作り直せない | +| 本番チャネルへの Pull Request の head を、承認したコミットから切った `release/v<版>` にする | `develop` を止めずに、承認したコミットと版上げだけを `main` へ出せる。`main` へ出るコミットはすべて `develop` に含まれる。並列の計画の外の配布にも同じ規則が効く | +| 並列の計画の下では、区分つきの問いはオーガナイザーだけが出す。レーンの問いは問いの hook が送る | レーンは問いで止まらず別の作業へ進める。conductor の手順だけで送ると、手順に書いていない場所の問いが漏れる。送れなければ今どおり通すので、DBOS が読めないときも今の振る舞いに戻る | +| オーガナイザーは作業を持たない。答えの後の処理は持ち主のレーンが、持ち主のいない共有の作業は一時レーン(同時に 1 本)が行う | オーガナイザーに作業を積むと、その間は問いと配布の取りまとめが止まる。空いているレーンへ移す形は、空きの判定と他のレーンの周回への割り込みが要る | +| 計画名での停止は `relay.py` ではなく `parallel.py stop` に置く | 統括役はリポジトリをまたぐ層で、並列の計画を知らない。統括役が知る語を役割の印だけに留める | +| レーンの数の上限は `NDF_RELAY_MAX_NAMED` から読み、依存のあるレーンも数える | 統括役が実際に断る値と同じにする。同時に動く本数を依存から求めると、起動の前に確かめられない | +| プランの本数の合計の上限は `NDF_PARALLEL_MAX_PLANS`(既定 6) | 2026-09-25 の実測(8 コア・30 GB で 6 本)。マシンごとに違うため環境変数で変える(2026-11-03 の承認ゲート 1 で利用者が決めた) | +| 異常は、耐久ワークフローの観測と依頼の印で見つけ、人が決めることは問いに、直す作業は持ち主のレーンか一時レーンへ返す | 新しい仕組みを足さず、オーガナイザーが作業を持たないまま扱える | + +## 仕様 + +### 常に成り立つ条件 + +検査の条件(I1〜I4・I19)は `parallel-plan.md` の「検査」が正本である。ここでは進行の条件を挙げる。 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| I5 | 作る前から在る引継ぎ文書と計画を上書きしない | `init` は 1 つも書かずに終了コード 1 で止まり、名前を返す | +| I6 | 検査に誤りがある計画では、レーンを 1 本も起動しない | `start` / `run` は耐久ワークフローを始めずに 1 で終わる | +| I7 | 1 本のレーンは、1 つの計画の進行の中で 1 回だけ起動する。打ち直した `run` も二重に起動しない | 起動は耐久ステップで、成功の記録(`started`)があるレーンは対象から外す。同じ名前の 2 本目の断りも成功として扱う。上限による断り(`refused`)は次の周で起動し直す | +| I8 | 依存のあるレーンは、依存先のレーンがすべて止まってから起動する。問いを保留して止まったレーン(答えていない問いを持つ・答えをまだ当てていない・`waiting_only` で止まったループ)は、止まったと数えない | 依存先が止まるまで起動の対象から外す | +| I9 | 流れている本番の配布は、1 つの計画につき 1 本まで。依頼が届いた時点で流れている配布が無ければ、直ちに配布を組む。中身は提示したコミットの範囲で決まる | 流れている間に届いた依頼は、範囲に入ればその配布の答えを受け、入らなければ `pending` に残って次の配布になる | +| I10 | 依頼 1 件への最終の答え(`released` / `rejected` / `failed`)は、イベント `answer-<依頼>` に 1 回だけ立つ。次の配布へ回した知らせは `defer-<依頼>` で、答えではない | 答えのイベントは耐久ワークフローの中でだけ立てる。`parallel.py answer` もメッセージ(`kind: answer`)を送るだけで、イベントを直接立てない | +| I11 | 耐久ワークフローが終わるのは、全レーンが止まり、流れている配布も、答えていない依頼と問いも無いときか、停止の指示を受けたときだけである | 終わる前に、答えを当てていない持ち主のレーンがあれば起こす 1 行を添え続ける。止まったループのレーンに保留の周回が残れば、一時レーンを起こして流させる | +| I12 | 動いている並列の進行(耐久ワークフローが `PENDING` か `ENQUEUED`)に属さないレーンは、依頼を送らず今の本番のプランを流す | `lane_of` が None を返し、今の本番のプランを組む | +| I13 | 同時に動かせる数から外すのは、オーガナイザーの印を持つ動いているラッパー 1 本だけ。役割 `helper` のラッパーは 1 本だけ、上限を 1 本超えて許す | 2 本目以降のオーガナイザーの印は数に入る。2 本目の `helper` は統括役が断る | +| I14 | 出すのは確定したコミットと版上げのコミットだけ。提示は 1 つのまとめた配布につき 2 つまで | 出し直しの提示への `confirm` は比べずに確定し、提示 3 を組まない | +| I15 | 範囲に入ったレーンは、依頼していなくても配布の結果を受ける。自分の Pull Request のマージのコミットがすべて `origin/<本番チャネル>` にあれば、依頼を送らずに `released` で進む | 出せた配布ごとに範囲のイベント `range-<連番>` を立てる。`request` は送る前に本番チャネルを照らし、版は `range-<連番>` から読む | +| I16 | 本番チャネルへ出すコミットは、すべて `develop` に含まれる | `scripts/check-pr-base.sh`(継続的統合の `pr-base-guard`)が落とす | +| I17 | 並列の計画の下で区分つきの問いを出すのはオーガナイザーだけである | レーンと一時レーンの問いは依頼(`kind: gate1` / `gate2` / `ask`)としてオーガナイザーへ届く | +| I18 | オーガナイザーは作業を持たない。一時レーンは同時に 1 本まで | `answer` は答えのメッセージを送るだけで、ラベル・記録・直しは持ち主のレーンが周回の境で行う | +| I20 | 依頼の `id` は送る側が依頼の中身から決め、受け取った数から採番しない | 打ち直しで同じ `id` になり、冪等の鍵(`:inbox:<依頼>`)が 2 度目を数えない。受け取りの前後で打ち直しても `id` が変わらない | + +### 構成要素 + +| 要素 | 責務 | +| --- | --- | +| `plugins/ndf/scripts/parallel.py` | 並列の計画と進行の CLI。副命令を下の部品へ振り分け、統一 JSON レポートを 1 行で返す | +| `plugins/ndf/scripts/lib/parallel_plan.py` | pydantic のモデル(`ParallelPlan`・`Lane`・`LoopInput`・`SprintInput`)と検査、`plan.md` と引継ぎ文書の描画、起動できるレーンの判定(`ready_lanes`) | +| `plugins/ndf/scripts/lib/parallel_run.py` | 耐久ワークフロー `parallel` と耐久ステップ(起動・観測・配布を組む・停止・要約)。一時レーンが流すもの(`helper_task`) | +| `plugins/ndf/scripts/lib/parallel_release.py` | まとめた配布: 範囲の Pull Request をレーンへ対応づける(`present`・`map_lanes`)、先頭が進んだかを比べる 1 つの関数(`moved`)、範囲で答えられる依頼(`answerable`)、提示のプランと本番のプランの組み立て(`build_plans`・`present_gate_steps`)、配布の依頼のプラン(`plan_release_request`) | +| `plugins/ndf/scripts/lib/parallel_lane.py` | レーンの側: 自分が属する計画の判定(`lane_of`)・送り手の資格(`sender_of`)・本番チャネルにあるかの判定(`merged_on_production`)・依頼を送る(`send_release_request`・`request_gate1`・`request_ask`・`request_gate2`)・答えを読む(`read_answer`・`answers_of`)・プランの本数(`lane_plans`・`queue_max`) | +| `plugins/ndf/scripts/lib/parallel_files.py` | DBOS を読まずにファイルだけで、レーンか一時レーンかを決める(`lane_name_in_plans`。問いの hook の 10 秒の上限の中で使う) | +| `plugins/ndf/scripts/lib/durable.py` | 外のプロセスから記録を開く関数(`open_client`・`send_to`・`event_from`・`status_from`・`output_from`・`wait_from`)と、耐久ワークフローの中の `recv`・`set_event`。DBOS を import するのはこのファイルだけである | +| `plugins/ndf/scripts/relay_lib/hub.py`・`hub_client.py` | 役割のファイルの読み書き(`relay.py role`・`spawn --role helper --plan`)と数え方(I13)。`ls --json` の `metrics.hub`・`metrics.tmux` | +| `plugins/ndf/scripts/lib/sprint_loop_gate.py` | ループのレーンが設計の承認を「人の判断待ち」へ移すときに、`kind: gate1` の依頼を送る。答えを当てる前に設計 PR の先頭を比べる | +| `plugins/ndf/scripts/hook_lib/ask_guard.py` | 判定が通す問いを、レーンか一時レーンなら `parallel.py request-ask` で送って止める | + +```mermaid +graph TD + subgraph 開発の工程 + P[parallel.py] + PL[parallel_plan] + PR[parallel_run] + RL[parallel_release] + LA[parallel_lane] + SV[supervise の
本番のステージ] + HK[問いの hook] + 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 + HK -->|request-ask| P +``` + +**統括役との境界。** 開発の工程は統括役の CLI を子プロセスで打ち、統一 JSON レポートだけを読む。統括役は `plan.json` を読まない。 +役割のファイルの計画名は、問いの hook とレーンの側が読む値で、統括役は解釈しない。 + +### 耐久ワークフローの 1 周 + +耐久ワークフローの中の分岐は、耐久ステップの結果とメッセージだけで決まる。時刻・外のコマンド・ファイルを読むのは、すべて耐久ステップである。 +打ち直しで同じ道を通るためである。 + +1. 最初に計画を読んで検査し、`relay.py role organizer --plan <計画名>` を打つ +2. 停止の指示を受けていて、流れている配布が無いか `--force` なら、起動済みのレーン(`--force` なら一時レーンも)へ `relay.py stop` を打ち、`stopped` で終わる +3. 起動の条件がそろったレーンを `relay.py spawn --name <レーン> --json -- <最初の入力>` で起動する。3 回続けて断られるたびに `kind: spawn` の問いを立てる +4. 流れている配布が無く `pending` があれば、出せた配布の範囲で答えられる依頼に答える。残りがあれば、まとめた配布を組む(下の「まとめた配布」) +5. I11 を満たせば、役割の印を消して `finished` で終わる +6. `inbox` を最長 60 秒(`NDF_PARALLEL_POLL`。配布の途中は 30 秒)待つ。配布の途中なら `resume` も読む +7. 耐久ステップ「観測」で `relay.py ls --json` を読む。状態が `awaiting_approval` へ移ったレーンがあれば `notice` を立てる。動かないレーンと、止まった一時レーンには問いを立てる。2 へ戻る + +### まとめた配布 + +```mermaid +stateDiagram-v2 + [*] --> 提示: 依頼が届き、流れている配布が無い + 提示 --> 配布中: MVV 判定が通した + 提示 --> 承認待ち: 承認ゲート 2 で止まった + 承認待ち --> 配布中: 答えの時点で develop が同じ + 承認待ち --> 出し直し: develop が進んでいた + 出し直し --> 配布中: 新しい範囲か最初の範囲 + 承認待ち --> 差し戻し: 承認されなかった + 出し直し --> 差し戻し: 差し戻す + 配布中 --> 配布済み + 配布中 --> 失敗 + 配布済み --> [*] + 差し戻し --> [*] + 失敗 --> [*] +``` + +- **1 つの提示は 2 つのプランを持つ。** 提示のプラン(`present.json`)は、今の開発版の承認資料のステップ(approval-facts など)を版上げ無しで流す。 + 続く `parallel.py present` が範囲の Pull Request をレーンへ対応づけ、最後に承認ゲート 2(MVV 判定 → `approve`)を置く。 + 本番のプラン(`release.json`)は `plan_mvv_release` で作り、承認ゲート 2 を持たない。承認の記録は提示のプランの状態ディレクトリから読む +- **本番のプランへ範囲を渡す。** 本番のプランは、差し込み `{queue_prs:present}`・`{queue_approved:present}` で範囲を受け取る。 + 中身は提示のプランの状態ディレクトリの `work/prs.txt`・`work/approved.txt` である。worktree と `release/v<版>` の起点は、承認したコミットにする(項目「起点のコミット」)。 + 版の判定が読む「起点」は `origin/<ベースブランチ>` のままにする +- **出す版の決め方。** 配布を組む時点の `origin/develop` の開発版の版数から、接尾辞を外して決める。依頼はレーンの希望の版を持たない +- **範囲をレーンへ対応づける。** 依頼のあるレーンは、依頼の `prs` が範囲にあるかで決める(依頼済みの印をつける)。依頼の無いレーンは、範囲の Pull Request が閉じる課題が、スプリントのレーンの `input.issues` にあるかで決める。 + どちらにも当たらないものは、レーンの無い Pull Request(`orphans`)として内訳に並べる。内訳は承認する人が読むための表示で、配布の中身は範囲である +- **承認の答えを受けたら 1 度だけ比べる。** 提示 1 への `confirm` で、耐久ステップが `origin/develop` の先頭を読む。`moved` が偽なら提示 1 で確定する。 + 真なら提示 2 を組み、加わった範囲を載せた `release`(`represent: true`)を立てる。提示 2 への `--take new` は提示 2、`--take first` は提示 1 で確定する +- **確定の後に一時レーンが打つ 3 手。** 答えを `releases///answer.json` へ書く(`sprint-state.py gate … '関門 2' --by user --outcome approved`)。 + 次に提示のプランを `approve` から続け、最後に本番のプランを初めて流す。確定しなかった提示の本番のプランは流さない。 + 1 つのまとめた配布で `release/v<版>` を作るのは 1 回だけである +- **答えを返す。** `settle` を受けると、前の配布で出た Pull Request と合わせて、`prs` がすべて範囲に入る依頼へ答えを立てる。 + `released` なら `range-` も立てる。範囲に入らない依頼には `defer-<依頼>` を立て、`pending` に残す。差し戻しと失敗は、範囲の依頼へ `rejected` / `failed` を返す + +### 依頼と答え + +| 依頼の `kind` | 送り手 | `id` の形 | 問いの時点の印(`stamp`) | +| --- | --- | --- | --- | +| `request`(配布の依頼) | レーンのプランのステップ `parallel-request`(`parallel.py request`) | `<レーン名>-r<乱数 12 桁>`。最初の実行でプランの状態ディレクトリの `work/request-id.txt` へ書き、打ち直しでは読む | — | +| `gate1`(設計の承認) | ループのレーン(`sprint_loop_gate.py`) | `<レーン名>-g<設計 PR>-<先頭の SHA の 12 桁>` | 設計 PR の先頭の SHA | +| `ask`(承認ゲートの外の問い。1 スプリントのレーンの承認ゲート 1 も含む) | 問いの hook(`parallel.py request-ask`) | `<送り手>-q<判定の記録の id>` | 作業の場所の `HEAD` | +| `gate2`(承認ゲート 2) | 一時レーン(`parallel.py request-gate2`) | `<計画名>-p<連番>-<提示>` | 承認資料の承認したコミット | +| `stall`・`helper`・`spawn` | 耐久ワークフロー自身 | `<レーン>-s<時刻>`・`<一時レーン>-h<起動の連番>`・`<計画名>-spawn-<レーン>-<回数>` | — | + +- レーンは、答えを使う前に今の印と答えの `stamp` を比べる。違えば答えを使わずに問い直す(ループのレーンの設計の承認で実装済み) +- 同じ原因の問いは 1 つにまとめる。対象は `ndf-ask:human-action` の問いで、区分・選択肢・文面が同じものである。文面はレーン名・パス・数字を伏せ、空白をまとめてから比べる。 + 答えは依頼してきた全レーンへ `answer-<依頼>` で配る。取り消せない操作・レッドライン・費用の問いは、番号の違いで別の対象になり得るためまとめない +- 1 スプリントのレーンの持ち主が止まっていれば、`answer` は `metrics.next` に `relay.py restart <レーン>` の 1 行を返す。 + 起こすかは、ラッパーの状態ではなくレーンの側の待ちの記録で決める(`lane_waits`) + +## データ・設定 + +### 置き場 + +どちらもコミットしない。 + +| 置き場 | 中身 | 書き手 | +| --- | --- | --- | +| メインディレクトリの `.ndf/parallel/<計画名>/plan.json` | 並列の計画の正本 | `init`(起動の前は人と AI も直してよい) | +| 同 `plan.md` | 人が読む描画(レーンの表・決まり・配布の内訳) | `init`・耐久ステップ | +| 同 `run.pid`・`run.log` | 切り離した `run` の pid と出力 | `start` | +| 同 `releases/requests.json` | 耐久ワークフローが受けた配布の依頼の写し(`present` が内訳に使う) | 耐久ステップ「配布を組む」 | +| 同 `releases/<連番>/<提示>/present.json`・`release.json`・`answer.json` | 提示のプラン・本番のプラン・提示の答えの記録(スプリントの状態ファイルの形。承認済みのプロジェクト MVV の参照を持つ) | 耐久ステップ「配布を組む」 | +| 同 `releases/<連番>/<提示>/presented.json`・`breakdown.md` | 提示したコミット・範囲の Pull Request・レーンの対応・レーンの無い Pull Request・出し直しで加わった範囲、承認資料へ足す内訳 | `parallel.py present` | +| 同 `releases/<連番>/<提示>/done.json`・`release-done.json` | 提示と本番のプランの queue の done | 一時レーン | +| レーンのプランの状態ディレクトリの `work/request-id.txt`・`work/request.txt` | 依頼の `id`、送った依頼か配布済みの結果 | `parallel.py request` | +| ラッパーの作業ディレクトリの `role.json` | 役割(`organizer` / `helper`)と計画名 | `relay.py role`・`relay.py spawn --role helper` | +| 耐久の記録(種類 `parallel`、識別は計画の置き場の絶対パス) | 耐久ワークフロー `parallel-<計画名>` の入力・耐久ステップの結果・メッセージ・イベント | 耐久ワークフロー(外のプロセスはメッセージを送るだけ) | + +### メッセージとイベント + +| 種類 | 名前 | 送り手・書き手 | 読み手 | 中身 | +| --- | --- | --- | --- | --- | +| メッセージ | 話題 `inbox` | `request`・`request-ask`・`request-gate2`・ループのレーン(`gate1`)・`answer`・`stop` | 耐久ワークフロー | `{kind: request \| gate1 \| gate2 \| ask \| answer \| stop, …}`。冪等の鍵は `:inbox:<依頼>` など | +| メッセージ | 話題 `resume` | `confirm`・`settle` | 配布の途中の耐久ワークフロー | `{n, phase: confirm, take}` / `{n, phase: settle, result, version, reason}` | +| イベント | `pause` | 耐久ワークフロー | `wait` | `{seq, action, …}`。`action` は `running` / `notice` / `release` / `confirmed` / `held`。`running` では `wait` は返らない。起こす 1 行(`wake`)を添える | +| イベント | `questions` | 耐久ワークフロー | `wait` | `{seq, items}`。答えていない問いの依頼の全件 | +| イベント | `answer-<依頼>` | 耐久ワークフロー | `await`・周回の境(`answers_of`) | 配布: `{result, version, release, reason, at}`。問い: `{result, note, choice, stamp, at}` | +| イベント | `defer-<依頼>` | 耐久ワークフロー | `await` | `{seq, reason, at}`。知らせのたびに上書きしてよい | +| イベント | `range-<連番>` | 耐久ワークフロー | `request`(`released_version`) | 出せた配布の `{sha, version, prs, lanes}`。差し戻し・失敗の配布の連番は欠ける | +| イベント | `lane-<レーン名>` | 耐久ワークフロー | 状態の表示 | `{received}`。依頼の `id` の採番には使わない | +| 出力 | 耐久ワークフローの戻り値 | 耐久ワークフロー | `wait`・`run` | `{status: finished \| stopped, lanes, releases, pending}` | + +### 環境変数 + +| 名前 | 既定 | 意味 | +| --- | --- | --- | +| `NDF_RELAY_MAX_NAMED` | 3 | レーンの数の上限(統括役の上限と同じ値)。正の整数でなければ既定を使い、検査の結果に書く | +| `NDF_PARALLEL_MAX_PLANS` | 6 | レーンのプランの本数の合計の上限。扱いは同上 | +| `NDF_PARALLEL_STALL_MINUTES` | 60 | 承認待ちでも止まってもいないレーンを「動かない」として問うまでの分 | +| `NDF_PARALLEL_POLL` | 60 | `inbox` と `resume` を待つ秒(テストで縮める) | +| `NDF_PARALLEL_RELAY` | — | 統括役の CLI の打ち方の差し替え(テスト用) | +| `NDF_RELAY_NAME`・`NDF_RELAY_DIR` | — | レーンの判定と役割のファイルの置き場(ラッパーが立てる) | +| `NDF_FACTORY_RUN` | — | Factory の実行ジョブの印。あれば `init`・`start`・`run` を断る | +| `NDF_ORGANIZER_DIR`・`NDF_ORGANIZER_LANE` | — | 外のオーガナイザーの置き場とレーンの名前。あれば副命令は `--external` で置き場のファイルを読み書きする | + +### 副命令の終了コード + +各副命令の意味と打つ場面は parallel.md と `parallel.py` の冒頭の表にある。ここでは終了コードだけを挙げる。 +`NDF_FACTORY_RUN` があるときの `init`・`start`・`run` は 1 で終わる。`--external` の副命令の終了コードは `lib/parallel_external_cli.py` の冒頭の表にある。 + +| 副命令 | 0 | 1 | 2 | 3 | ほか | +| --- | --- | --- | --- | --- | --- | +| `init` | 書いた | 検査の誤り・在る文書(何も書かない) | 読めない・書けない | — | — | +| `check` | 通った(警告だけを含む) | 誤り | 読めない | — | — | +| `start` | 切り離して起動した・動いている `run` を指した | 計画が無い・検査の誤り | 計画を読めない | ラッパーの外・tmux が無い・統括役が居ない(`manual: true`) | — | +| `run` | 終わった・同じ計画の `run` が動いている(`running: true`) | 計画が無い・検査の誤り | 耐久の記録を開けない | `start` と同じ | — | +| `wait` | `notice` / `release` / `confirmed` / `held` / `questions` / 終わり | 耐久ワークフローが失敗で終わった | 耐久の記録が無い | 上限に達した・まだ変化が無い | — | +| `request` | すでに本番チャネルにある(`released`) | 計画が動いていない | — | — | 10: 依頼を送った | +| `await` | `released` | `failed` | 依頼の記録を読めない | 答えがまだ無い | 10: `rejected` | +| `settle` | 送った | 計画が動いていない | done を読めない | — | 4: 本番のプランが承認ゲートで止まっている | +| `request-ask` | 送った | 計画が動いていない | 標準入力を読めない | — | 4: 送り手の資格が無い(hook は問いを通す) | +| `stop` | 停止を打った | 止まらないレーンがある・検査の誤り | — | — | — | + +## エラー処理 + +| 起きること | 検知 | 扱い | +| --- | --- | --- | +| オーガナイザーのセッションが切り替わる・`run` が落ちる | 新しいセッションが同じ 1 行で始まる | `start` が動いている `run` を指すか、切り離して起動し直す。耐久ワークフローは記録から続き、居ない間に届いたメッセージを読む。`wait --after 0` が答えていない問いを返す | +| 動かないレーン | 観測で、`relay.py ls` の `at` が `NDF_PARALLEL_STALL_MINUTES` より前で、承認待ちでも止まってもいない | `kind: stall` の問い(起こし直す / 止める / 待つ)。「待つ」の後は次の区切りまで問わない | +| 配布の途中で一時レーンが止まる | 観測で一時レーンが `stopped`、`settle` も `gate2` の依頼も無い | `kind: helper` の問い(続きから起こし直す / 止める)。「止める」は配布を `failed` で締める | +| レーンの起動が断られ続ける | `relay.py spawn` の断りが 3 回続く | `kind: spawn` の問い。「止める」は止まったレーンとして数える | +| 同じ原因の問いが複数のレーンから届く | 「依頼と答え」の正規化した鍵が同じ | 1 つにまとめ、答えを全レーンへ配る | +| 手元が待ちだけになったループのレーン | 周回の境で、選べる課題が無く保留の周回か答えの無い設計の承認がある | ループは `waiting_only` で止まり、統括役の枠を空ける。答えの後、オーガナイザーが起こす(loop.md) | +| 止まったレーンの保留の周回が残る | I11 を満たした時点で、`released` を受けたのにまとめを流していない保留の周回がある | `action: held` で一時レーン(`--parallel-helper <計画名> pending <ループ名>`)を 1 本ずつ起こし、流し終えてから終える | +| 配布の途中の `stop` | 一時レーンが動いていて `settle` がまだ | `settle` が届くまで待ってから `stopped` で終える。`--force` は待たずに一時レーンも止め、本番のプランの状態ディレクトリを残す | +| 複数のレーンがメインディレクトリで git を動かす | — | 後片付け・worktree の作成・メインディレクトリの追従を `<メインディレクトリ>/.git/ndf-main.lock` の排他で 1 本ずつにする(`merged_lib/main_lock.py`・`worktree-session.sh`) | +| `main` へのマージの後に配布が止まる | — | `release-steps.py release` が、マージ済みならマージを飛ばし、無いタグと GitHub Release だけを作って続ける | +| 耐久の記録が読めない・消えた | `run` が `DurableError` | `run` が 2 で止まる。計画は新しい計画名で始め直す。レーンは計画に属さなくなり(I12)、今の本番のプランを流す | +| 同じ課題で設計の「直して出し直す」が続く | `loop answer --revise` が人の判断待ちの行へ `revise_count`(その課題の「直して出し直す」の回数)を書く | 回数を記録するだけで、問いの選択肢は変えない | + +## セキュリティ + +- 置き場はどれもコミットしない。耐久の記録はユーザーの状態ディレクトリにあり、#1142 の掃除(30 日)に従う +- 問いの hook がオーガナイザーへ送るのは、問いの文面・選択肢・区分・作業の場所・レーン名・判定の記録の `id` だけである + +## テスト観点 + +耐久ワークフローのテストは、`NDF_DBOS_DIR` を一時ディレクトリにして本物の DBOS(SQLite)で流す。`relay.py`(`NDF_PARALLEL_RELAY`)と `git`・`gh` は差し替える。 + +| 観点 | 縛るテスト | +| --- | --- | +| `init` が計画・描画・レーンの数だけの引継ぎ文書を書き、在る文書があれば何も書かない(I5)。検査の誤りの種類(I1〜I3・I19)と警告(I4)、上限の比較(「より大きい」で誤り)、環境変数の値が正の整数でないときの既定 | `plugins/ndf/scripts/tests/test_parallel_plan.py` | +| 検査に誤りがあると `relay.py spawn` を 1 回も打たない(I6)。依存先が止まった後の周で起動し、保留中のレーンは止まったと数えない(I8)。打ち直しで起動に成功したレーンを起動し直さず、断られたレーンは起動し直す(I7) | `plugins/ndf/scripts/tests/test_parallel_run.py` | +| 依頼が 1 件届けば他のレーンを待たずに提示 1 を組み、範囲は提示したコミットで決まり、依頼の無いレーンの Pull Request も内訳に載る(I9) | 同上 | +| 答えの時点で先頭が同じなら確定、進んでいれば提示 2 を組む。提示 2 への答えでは比べず、提示 3 を組まない。確定まで本番のプランを流さない(I14) | 同上 | +| 範囲に入ったレーンは後で依頼に来ても送らずに `released` で進み、2 本目の本番のプランが組まれない(I15、Pull Request 2420 の件) | `test_parallel_run.py`・`test_parallel_lane.py` | +| 答えは `answer-<依頼>` に 1 回だけ立ち、`defer-<依頼>` では `await` が返らない(I10)。全レーンが止まり答えていない依頼が無いときだけ `finished`(I11) | `test_parallel_run.py` | +| 動いている計画に属さないレーンでは依頼を送らない(I12)。送り手の資格を `lane_of` だけで見ない(一時レーン) | `test_parallel_lane.py` | +| 依頼の `id` は受け取りの前後の打ち直しで変わらず、別の設計 PR では別になる(I20) | `test_parallel_lane.py` | +| 問いの依頼をまとめて 1 回で返し、答えの間に届いた依頼だけを続けて返す。同じ原因の問いを 1 つにまとめて全レーンへ答える。止まった持ち主を起こす 1 行を返す | `test_parallel_run.py` | +| 問いの hook がレーン・一時レーンの問いを送って止め、レーンでない・送れないときは通す | `plugins/ndf/scripts/tests/test_ask_guard.py` | +| オーガナイザーの印 1 本だけを数から外し、`helper` は 1 本だけ上限を超えて通る(I13) | `plugins/ndf/scripts/tests/test_relay_hub.py` | +| 配布の依頼の形の例が契約の形を通る | `plugins/ndf/scripts/tests/test_parallel_request_form.py` | +| `check-pr-base.sh` が `develop` と、先頭が `origin/develop` の祖先の `release/v*` だけを通す(I16) | `scripts/tests/test_pr_base_guard.py` | +| 版数を持つ箇所だけの衝突を本番の版で解き、ほかの衝突は止める | `plugins/ndf/scripts/tests/test_release_version_merge.py` | +| ループのレーンの `配布待ち`・`質問待ち`・`waiting_only` | `plugins/ndf/scripts/tests/test_sprint_loop.py` | +| 起動から配布までの通し | 単体テストでは縛れない。2 レーンの計画を `--parallel` で起動するリリース後テストで確かめる | + +## 運用 + +- 計画の途中でプロジェクト MVV が変わったり、耐久の記録を失ったりしたら、新しい計画名で始め直す。`finished` / `stopped` の計画は `running` に戻らない +- 1 本だけを止めるのは `relay.py stop <名前>`、計画の全レーンを止めるのは `parallel.py stop <計画名>` である +- 確かめ切れていないことが 4 つある。複数のプロセスが同じ SQLite に書くときのロックの待ちと、長く動く耐久ワークフローの記録の量(観測の周ごとに増える)である。 + 加えて、レーン 3 本と一時レーンが同時に動くときのメモリ・CPU と、GitHub API の同時本数の枠もまだ測っていない。いずれもリリース後テストで測る + +## 関連リンク + +- [issue #2436](https://github.com/devbasex/ai-plugins/issues/2436) — 要求と受け入れ条件 +- [PR #2437](https://github.com/devbasex/ai-plugins/pull/2437) — 設計 +- [PR #2451](https://github.com/devbasex/ai-plugins/pull/2451) / [PR #2454](https://github.com/devbasex/ai-plugins/pull/2454) / [PR #2455](https://github.com/devbasex/ai-plugins/pull/2455) / [PR #2462](https://github.com/devbasex/ai-plugins/pull/2462) / [PR #2452](https://github.com/devbasex/ai-plugins/pull/2452) — 実装(v10.27.15) +- [issue #2439](https://github.com/devbasex/ai-plugins/issues/2439) — Factory のオーガナイザー(同じ層の別の実装) +- [ndf-relay-multi-session-supervisor.md](ndf-relay-multi-session-supervisor.md) — 統括役(#2346) +- [ndf-execution-plan-and-parallel-capacity.md](ndf-execution-plan-and-parallel-capacity.md) — 1 つのスプリントの中の並列の実行計画と本数(この文書のレーンの並列とは別の層) +- [ndf-ask-guard.md](ndf-ask-guard.md)・[ndf-workflow-development-loop.md](ndf-workflow-development-loop.md)・[ndf-production-release-record.md](ndf-production-release-record.md) — レーンの側の問い・周回・本番のステージ diff --git a/docs/specifications/ndf-supervise-verification-mark-after-ok.md b/docs/specifications/ndf-supervise-verification-mark-after-ok.md index d88b5392c..f1e49ab91 100644 --- a/docs/specifications/ndf-supervise-verification-mark-after-ok.md +++ b/docs/specifications/ndf-supervise-verification-mark-after-ok.md @@ -119,8 +119,12 @@ worker への起動指示と手順の本文が食い違うときは、起動指 | 配布の記録の番号が実行の時点で 0 になる(`queue_fill.py` の `_fill_prs` が `{queue_pr:release-prod}` を `0` に置き換え、指示が「番号が 0 なら何もせずに終える」と定める) | #2408(ステップを置く条件を直す) | ok の後に印を付けた直後、耐久ステップの出力が書かれる前にプロセスが落ちると、`--from` の再開がステップを -流し直して印をもう一度打つ。同じ工程の 2 度目の記録は課題の本文で同じ結果になるが、時刻が後ろへずれる。 -始めに付ける印にも同じ性質がある。 +流し直して印をもう一度打つ。進行の記録(`progress-record.sh`)は同じ工程の 2 度目の記録で時刻を書き換えないため、 +課題の本文は変わらない。始めに付ける印にも同じ性質がある。 + +**印は最初に付いた時刻を保つ。** 前のスプリントで「リリース後テスト」を通った課題は、もう一度その工程を通っても +前の時刻の印のままになる。この課題の印の時刻と記録の時刻を比べても、印が記録の後に付いたかは分からない。 +確かめるときは、初めて「リリース後テスト」を通る課題で比べる。 ## テスト観点 diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index 4cd43cfb4..12e7c48ac 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.27.15` | 利用者が常用してよい | +| 正式版 | `10.27.16` | 利用者が常用してよい | | 開発版 | `10.28.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.28.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.15` の次を開発するなら `10.28.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.16` の次を開発するなら `10.28.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.28.0-dev.3` の次は `10.28.0` diff --git a/factory/README.md b/factory/README.md index 2bedaf9cc..46169eb03 100644 --- a/factory/README.md +++ b/factory/README.md @@ -21,7 +21,7 @@ CDK のスタック・ネットワーク・秘密の管理は別の private リ | ディレクトリ | 中身 | 作る Issue | | --- | --- | --- | | `contracts/` | 進捗の事象・実行ジョブの指示・受け口の応答の JSON Schema(`contracts/schemas/`)と、そこから生成した型(workspace `@ndf-factory/contracts`)。API の経路の形は `contracts/openapi/`(アカウントは `accounts.yaml`、進捗の受け口は `ingest.yaml`、スプリントの実行とランナー向けの経路は `runs.yaml`) | #2170・#2171 | -| `web/` | ログイン・リポジトリの登録・スプリントの一覧と詳細・承認・プランと請求の画面 | #2170・#2172〜#2174・#2186 | +| `web/` | ログイン・リポジトリの登録・スプリントの一覧と詳細・承認・料金プランと請求の画面 | #2170・#2172〜#2174・#2186 | | `api/` | 画面からの要求を受け、アカウントで認可して各集約の持ち主へ渡す処理 | #2170〜#2174・#2186 | | `runner/` | 実行ジョブのコンテナ。リポジトリの取り込み・NDF の導入・conductor の起動・進捗の事象の送信 | #2186 | @@ -60,13 +60,15 @@ TypeScript のコードは npm の workspaces で束ね、どの子課題も次 | `POST /ingest/api/events` | `api` | API Gateway の IAM 認可(SigV4)。Lambda が呼び出し元のロールを `INGEST_API_CALLER_ROLE_ARN` と照らす | | `POST /ingest/github` | `github` | `X-Hub-Signature-256`。Lambda が GitHub App の Webhook の秘密で確かめる。API Gateway の認可は付けない | -- `api/src/handlers/run-jobs.ts` の `handler`: ランナー向けの API(`/run-jobs/*`、`INGEST_URL` と同じ origin)。実行ジョブの token で認証し、導入の token の渡し直しと状態の置き場の署名つき URL を返す。API Gateway の認可は付けない +- `api/src/handlers/run-jobs.ts` の `handler`: ランナー向けの API(`/run-jobs/*`、`INGEST_URL` と同じ origin)。実行ジョブの token で認証し、導入の token の渡し直しと状態の置き場の署名つき URL と、配布の依頼の答え・まとめた配布の中身(`/run-jobs/organizer/*`)を返す。API Gateway の認可は付けない - `api/src/handlers/run-control.ts` の `handler`: HTTP を持たない。EventBridge の「ECS Task State Change」(ランナーのクラスタ)と 5 分ごとの起動(見回り)を受ける +Factory のオーガナイザー(`api/src/organizer/`)は新しい handler もインフラの部品も持たない。同じリポジトリで並行する実行の配布の依頼をまとめ、配布の実行を起こし、答えを範囲の実行へ返す処理を、実行の制御の停止の後の処理・再開の要求・見回りと画面向けの API の中で進める。状態は実行の表の行(`ORG#`・`ORGREQ#`・`ORGBATCH#`・`ORGLOG#`)で、見回りは GSI `gsi1` の鍵 `ORGANIZER_ACTIVE` で引く。 + 実行の表(`RUNS_TABLE_NAME`)は区分の鍵 `pk`・並びの鍵 `sk` と、GSI `gsi1`(区分の鍵 `gsi1pk`・並びの鍵 `gsi1sk`、射影は全部)を持つ。ランナーのイメージは `runner/Dockerfile` で、`factory/` を文脈にして作る。タスク定義は形態ごとに 2 つで、`managed`(`RUNNER_TASK_DEFINITION_ARN`)の役割だけが Bedrock を呼べ、`byok`(`RUNNER_BYOK_TASK_DEFINITION_ARN`)の役割はキーの置き場の読み出しと `AssumeRole` だけを持つ。どちらの役割も S3 の権限を持たない。 進捗の事象の表(`PROGRESS_EVENTS_TABLE_NAME`)は区分の鍵 `sprint_run_id`・並びの鍵 `order_key` と、区分の鍵 `pr_ref` の GSI `pr_ref-index`(射影に `sprint_run_id` を含める)を持つ。冪等の鍵の表(`PROGRESS_IDEMPOTENCY_TABLE_NAME`)は区分の鍵 `idempotency_key` だけを持ち、TTL を置かない。 -ほかの子課題は `api/src/index.ts` から export したものだけを使う。要求の利用者は `requireCaller`、アカウントの文脈は `resolveFromRequest`(関数としては `resolveAccount`)、使える量は `getLimits`・`judgeLimits`、プランの変更は `setPlan` で得る。実行ジョブの token は `signRunJobToken` で作り、受け口で `verifyRunJobToken` で確かめる。テストでは `createRunJobTokenKit` で鍵の組をメモリの中に作る。実行の制御は `job.finished` を `appendControlEvent` で直に書く。契約の型は `@ndf-factory/contracts` か `api/src/index.ts` から import する。 +ほかの子課題は `api/src/index.ts` から export したものだけを使う。要求の利用者は `requireCaller`、アカウントの文脈は `resolveFromRequest`(関数としては `resolveAccount`)、使える量は `getLimits`・`judgeLimits`、料金プランの変更は `setPlan` で得る。実行ジョブの token は `signRunJobToken` で作り、受け口で `verifyRunJobToken` で確かめる。テストでは `createRunJobTokenKit` で鍵の組をメモリの中に作る。実行の制御は `job.finished` を `appendControlEvent` で直に書く。契約の型は `@ndf-factory/contracts` か `api/src/index.ts` から import する。 全体の設計(構成要素・実行基盤・進捗の正本・構成要素の間の契約)は [docs/specifications/ndf-factory-service-boundary.md](../docs/specifications/ndf-factory-service-boundary.md) にある。 diff --git a/factory/api/src/accounts/limits.ts b/factory/api/src/accounts/limits.ts index 124e878e2..aada1b674 100644 --- a/factory/api/src/accounts/limits.ts +++ b/factory/api/src/accounts/limits.ts @@ -88,11 +88,11 @@ export function judgeLimits( current: current.runningSprintRuns, }); } - // リポジトリの同時の数は、プランの同時の数と登録の宣言の小さい方 + // リポジトリの同時の数は、料金プランの同時の数と登録の宣言の小さい方 if (current.runningRepoRuns !== undefined) { const declared = current.repoMaxRuns ?? null; const repoLimit = declared === null ? limits.concurrentSprintRuns : Math.min(limits.concurrentSprintRuns, declared); - // 宣言の無いリポジトリの上限はプランの値で、アカウントの上限と同時に当たる。理由を重ねない + // 宣言の無いリポジトリの上限は料金プランの値で、アカウントの上限と同時に当たる。理由を重ねない const accountHit = reasons.some((r) => r.quantity === "concurrent_sprint_runs"); if (!(current.runningRepoRuns < repoLimit) && (!accountHit || repoLimit < limits.concurrentSprintRuns)) { reasons.push({ quantity: "concurrent_repo_runs", limit: repoLimit, current: current.runningRepoRuns }); diff --git a/factory/api/src/accounts/model.ts b/factory/api/src/accounts/model.ts index a9d557d31..b8db8392d 100644 --- a/factory/api/src/accounts/model.ts +++ b/factory/api/src/accounts/model.ts @@ -76,7 +76,7 @@ export interface AccountContext { executionMode: ExecutionMode; } -// I13: 種類とプランの組 +// I13: 種類と料金プランの組 export function planFitsKind(kind: AccountKind, plan: Plan): boolean { return kind === "team" ? plan === "team" : PERSONAL_PLANS.includes(plan); } diff --git a/factory/api/src/accounts/service.test.ts b/factory/api/src/accounts/service.test.ts index 84f85f66e..2707d0c88 100644 --- a/factory/api/src/accounts/service.test.ts +++ b/factory/api/src/accounts/service.test.ts @@ -89,14 +89,14 @@ describe("席の変更と削除の競合", () => { }); describe("setPlan(E6、I13・I20・I21)", () => { - it("プランを変え、履歴を 1 つ足す", async () => { + it("料金プランを変え、履歴を 1 つ足す", async () => { const id = await personal(); await service.setPlan(id, "max"); expect(store.accounts.get(id)).toMatchObject({ plan: "max", version: 2 }); expect(store.history.at(-1)).toMatchObject({ type: "plan.changed", actor: "system", before: "free", after: "max" }); }); - it("知らないプランと、種類に合わない組は PlanError で、行も履歴も変わらない", async () => { + it("知らない料金プランと、種類に合わない組は PlanError で、行も履歴も変わらない", async () => { const id = await personal(); const team = await service.createTeam({ userId: "u-1", idp: "google" }, "t", "standard"); const before = store.history.length; @@ -119,7 +119,7 @@ describe("setPlan(E6、I13・I20・I21)", () => { expect(store.history.slice(before).map((h) => h.type)).toEqual(["plan.changed", "execution_mode.changed"]); }); - it("形態の書き込みが失敗すればプランも変わらない", async () => { + it("形態の書き込みが失敗すれば料金プランも変わらない", async () => { const id = await personal(); await service.setPlan(id, "pro"); await service.setExecutionMode(await service.resolveAccount({ userId: "u-1", idp: "google" }), "managed"); diff --git a/factory/api/src/accounts/service.ts b/factory/api/src/accounts/service.ts index 0ddc18508..241a98f96 100644 --- a/factory/api/src/accounts/service.ts +++ b/factory/api/src/accounts/service.ts @@ -137,7 +137,7 @@ export class AccountService { const user = await this.ensureUser(caller); const personal = await this.resolveFor(user, caller); const out: (AccountContext & { name: string | null })[] = [{ ...personal, name: null }]; - // GSI の行は候補。外した席が GSI に残る間に名前やプランを返さないよう、元の席を強い整合で確かめる(I18) + // GSI の行は候補。外した席が GSI に残る間に名前や料金プランを返さないよう、元の席を強い整合で確かめる(I18) for (const accountId of await this.store.listMemberships(caller.userId)) { const seat = await this.store.getSeat(accountId, caller.userId); if (!seat) continue; @@ -256,7 +256,7 @@ export class AccountService { if (!account) throw new ForbiddenError(); if (account.executionMode === mode) return account; if (mode === "managed" && account.plan === "free") { - throw new ConflictError("managed_not_allowed_on_free", "Free のプランでは managed(サービスの Bedrock)を選べません"); + throw new ConflictError("managed_not_allowed_on_free", "Free の料金プランでは managed(サービスの Bedrock)を選べません"); } const at = this.stamp(); try { @@ -271,10 +271,10 @@ export class AccountService { throw new AccountUnavailableError(); } - // アカウントのプランを変える。#2174 が決済の後に呼ぶ内側の関数(前提 8、E6) + // アカウントの料金プランを変える。#2174 が決済の後に呼ぶ内側の関数(前提 8、E6) // free へ下げるとき形態が managed なら、同じトランザクションで local へ変える(I21) async setPlan(accountId: string, plan: Plan): Promise { - if (!isPlan(plan)) throw new PlanError("unknown_plan", `知らないプランです: ${String(plan)}`); + if (!isPlan(plan)) throw new PlanError("unknown_plan", `知らない料金プランです: ${String(plan)}`); for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { const account = await this.store.getAccount(accountId); if (!account) throw new PlanError("account_not_found", "アカウントがありません"); diff --git a/factory/api/src/github/app.ts b/factory/api/src/github/app.ts index 2ef705596..de0bb5f86 100644 --- a/factory/api/src/github/app.ts +++ b/factory/api/src/github/app.ts @@ -8,6 +8,8 @@ import { type GitHubPort, type InstallationToken, type IssueSummary, + type PullByHead, + type PullHead, type ReachableRepository, type RepositoryVisibility, } from "./port.ts"; @@ -107,6 +109,34 @@ export class GitHubApp implements GitHubPort { return repo.private === false ? "public" : "private"; } + async branchHead(installationId: number, repositoryId: number, fullName: string, branch: string): Promise { + const token = await this.tokenFor(installationId, repositoryId); + if (!token) return null; + const ref = await this.get(`/repos/${fullName}/git/ref/heads/${encodeURIComponent(branch)}`, token); + const sha = (ref?.object as { sha?: unknown } | undefined)?.sha; + return typeof sha === "string" ? sha : null; + } + + async listOpenPullHeads(installationId: number, repositoryId: number, fullName: string, base: string): Promise { + const token = await this.tokenFor(installationId, repositoryId); + if (!token) return []; + const pulls = await this.getAllPages(`/repos/${fullName}/pulls?state=open&base=${encodeURIComponent(base)}`, token, null); + return pulls.map((p) => ({ head: ((p.head as { ref?: unknown } | undefined)?.ref as string) ?? "", url: p.html_url as string })); + } + + async findPullByHead(installationId: number, repositoryId: number, fullName: string, head: string, base: string): Promise { + const token = await this.tokenFor(installationId, repositoryId); + if (!token) return null; + const owner = fullName.split("/")[0]!; + const q = new URLSearchParams({ state: "all", head: `${owner}:${head}`, base, per_page: "10" }); + const pulls = (await this.getList(`/repos/${fullName}/pulls?${q}`, token)) ?? []; + const merged = pulls.find((p) => typeof p.merged_at === "string"); + const open = pulls.find((p) => p.state === "open"); + const pick = merged ?? open ?? pulls[0]; + if (!pick) return null; + return { state: pick === merged ? "merged" : pick.state === "open" ? "open" : "closed", url: pick.html_url as string }; + } + // リポジトリ 1 つと permissions の範囲だけに絞った導入の token。導入が届かなければ null private async scopedToken(installationId: number, repositoryId: number, permissions: Record): Promise { const res = await this.http(`${this.api}/app/installations/${installationId}/access_tokens`, { diff --git a/factory/api/src/github/port.ts b/factory/api/src/github/port.ts index 11bdb1529..e38a8e08a 100644 --- a/factory/api/src/github/port.ts +++ b/factory/api/src/github/port.ts @@ -68,6 +68,23 @@ export interface GitHubPort { createIssue(installationId: number, repositoryId: number, issue: { title: string; body: string }): Promise; // 公開範囲(metadata: read だけの token で読む)。導入が届かなければ null repositoryVisibility(installationId: number, repositoryId: number): Promise; + // ---- Factory のオーガナイザー(#2439)。読むだけ ---- + // 枝の先頭のコミット。枝が無いか導入が届かなければ null + branchHead(installationId: number, repositoryId: number, fullName: string, branch: string): Promise; + // base 宛ての開いている Pull Request の head の枝と URL + listOpenPullHeads(installationId: number, repositoryId: number, fullName: string, base: string): Promise; + // head の枝から base 宛ての Pull Request(開いている・マージ済み)。無ければ null + findPullByHead(installationId: number, repositoryId: number, fullName: string, head: string, base: string): Promise; +} + +export interface PullHead { + head: string; + url: string; +} + +export interface PullByHead { + state: "open" | "merged" | "closed"; + url: string; } // マイルストーンの名前の形(<2 桁の連番> <主題>) diff --git a/factory/api/src/handlers/api.test.ts b/factory/api/src/handlers/api.test.ts index b7628b781..b313eb9be 100644 --- a/factory/api/src/handlers/api.test.ts +++ b/factory/api/src/handlers/api.test.ts @@ -227,7 +227,7 @@ describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => { expect(res.status).toBe(409); expect((await res.json()).error).toEqual({ code: "managed_not_allowed_on_free", - message: "Free のプランでは managed(サービスの Bedrock)を選べません", + message: "Free の料金プランでは managed(サービスの Bedrock)を選べません", }); expect(api.store.accounts.get(id)?.executionMode).toBe("local"); expect(api.store.history.length).toBe(before); diff --git a/factory/api/src/handlers/run-jobs.ts b/factory/api/src/handlers/run-jobs.ts index a86d63548..e19fa95ab 100644 --- a/factory/api/src/handlers/run-jobs.ts +++ b/factory/api/src/handlers/run-jobs.ts @@ -8,6 +8,6 @@ import { runJobTokenVerifierFromEnv } from "../progress/run-job-token.ts"; export const handler = handle( createRunnerApiApp(() => { const d = factoryDepsFromEnv(); - return { verifier: runJobTokenVerifierFromEnv(), control: d.control, table: d.table, github: d.github, platform: d.platform }; + return { verifier: runJobTokenVerifierFromEnv(), control: d.control, table: d.table, github: d.github, platform: d.platform, organizer: d.organizer }; }), ); diff --git a/factory/api/src/index.ts b/factory/api/src/index.ts index f0419ed2b..65ec4f550 100644 --- a/factory/api/src/index.ts +++ b/factory/api/src/index.ts @@ -31,7 +31,7 @@ export function resolveAccount(caller: Caller, accountId?: string): Promise { return depsFromEnv().accounts.setPlan(accountId, plan); } @@ -84,8 +84,9 @@ export type { ResumeRejectedCode, RunControlConfig, RunControlDeps } from "./run export type { RunJob, SprintRun, RunState, JobState } from "./runs/model.ts"; export { repoKey } from "./progress/github.ts"; -// 承認待ちのスプリントの実行に resume の実行ジョブを作り起動する。承認待ちでない・動くジョブがある → ResumeRejectedError -export function requestResume(input: { sprintRunId: string; approvalRequestId: string }): Promise<{ jobId: string }> { +// 承認待ちのスプリントの実行に resume の実行ジョブを作り起動する。承認待ちでない・動くジョブがある → ResumeRejectedError。 +// 配布の実行(#2439)への差し戻しはジョブを起こさず jobId が null +export function requestResume(input: { sprintRunId: string; approvalRequestId: string }): Promise<{ jobId: string | null }> { return factoryDepsFromEnv().control.requestResume(input); } @@ -94,7 +95,7 @@ export function claimLocalRunJob(accountId: string) { return factoryDepsFromEnv().control.claimLocalRunJob(accountId); } -// 手元のランナーの終わりか心拍の切れ。停止の知らせと同じ処理 +// 手元のマシンの終わりか心拍の切れ。停止の知らせと同じ処理 export function reportLocalRunJobEnded(jobId: string): Promise { return factoryDepsFromEnv().control.reportLocalRunJobEnded(jobId); } diff --git a/factory/api/src/organizer/control.test.ts b/factory/api/src/organizer/control.test.ts new file mode 100644 index 000000000..1985d6c17 --- /dev/null +++ b/factory/api/src/organizer/control.test.ts @@ -0,0 +1,558 @@ +// Factory のオーガナイザー(#2439)の振る舞い。表と進捗の記録はメモリに置き、ランナーの事象は記録へ直に足す +import { beforeEach, describe, expect, it } from "vitest"; +import { validateRunJobInstruction } from "@ndf-factory/contracts"; +import { ApiError } from "../errors.ts"; +import { appendEvent } from "../progress/append.ts"; +import { accountContext, createRunsHarness, TEST_CONFIG, type RunsHarness } from "../testing/runs.ts"; +import { ResumeRejectedError } from "../runs/control.ts"; +import { laneOf } from "./model.ts"; + +const REPO_ID = 101; +const REPO = "org-a/repo-a"; +const CRED_ARN = "arn:aws:secretsmanager:us-east-1:111111111111:secret:ndf-factory/model-credentials/u-1"; +let h: RunsHarness; +let n = 0; + +beforeEach(async () => { + h = await createRunsHarness({ ...TEST_CONFIG, parallelMinVersion: "10.0.0" }); + h.table.migrated = true; + h.github.installations.set(9, new Map([[REPO_ID, REPO]])); + h.github.openIssues.set(REPO, [1, 2, 3, 4].map((number) => ({ number, title: `t${number}` }))); + h.github.heads.set(`${REPO}#develop`, "aaaa111"); + await h.table.putRegistration({ accountId: "acc-1", repositoryId: REPO_ID, fullName: REPO, installationId: 9, registeredBy: "user-1", registeredAt: "2026-11-01T00:00:00Z" }); + await h.table.putCredential({ accountId: "acc-1", kind: "anthropic_api_key", secretArn: CRED_ARN, updatedAt: "2026-11-01T00:00:00Z" }); +}); + +async function codeOf(p: Promise): Promise { + try { + await p; + } catch (err) { + if (err instanceof ApiError || err instanceof ResumeRejectedError) return err.code; + throw err; + } + return "ok"; +} + +const at = () => h.clock.now.toISOString(); + +async function event(sprintRunId: string, jobId: string | null, type: string, payload: Record, key?: string, source = "runner") { + await appendEvent(h.progress, { + sprint_run_id: sprintRunId, + idempotency_key: key ?? `${jobId}:${++n}`, + type, + at: at(), + source, + job_id: jobId, + schema_version: 1, + payload, + } as never); +} + +async function finish(sprintRunId: string, jobId: string, result: "gate" | "done" | "failed", reason?: string) { + await event(sprintRunId, jobId, "job.finished", { result, summary: "s", ...(reason ? { reason } : {}) }, `${jobId}:finished`); + await h.control.onTaskStateChange(jobId, "STOPPED"); +} + +const runOf = async (id: string) => (await h.table.getRun(id))!; +const startRun = async (issue: number) => (await h.control.startSprintRun(accountContext(), { repositoryId: REPO_ID, issues: [issue] })).sprint_run_id; + +// 手動の実行が配布の依頼を出して答えを待つ(I65) +async function requestRelease(sprintRunId: string, prs: number[], requestId = `run-${sprintRunId}-r${++n}`) { + const run = await runOf(sprintRunId); + const jobId = run.currentJobId!; + await event(sprintRunId, jobId, "release.requested", { request_id: requestId, lane: `run-${sprintRunId}`, prs, issues: run.issues, ndf_version: "10.27.16", requested_at: at() }, requestId); + await finish(sprintRunId, jobId, "gate", "release_requested"); + return requestId; +} + +const currentBatch = async () => { + const root = await h.table.getOrganizer("acc-1", REPO_ID); + return root?.currentSeq ? h.table.getBatch("acc-1", REPO_ID, root.currentSeq) : null; +}; + +// 配布の実行のジョブが承認資料をそろえて承認ゲート 2 で止まる(auto なら MVV 判定が通して続ける) +async function present(k: 1 | 2, sha: string, prs: number[], opts: { auto?: boolean } = {}) { + const batch = (await currentBatch())!; + const run = await runOf(batch.releaseRunId); + const jobId = run.currentJobId!; + await event(run.sprintRunId, jobId, "release.presented", { seq: batch.seq, k, sha, version: "10.28.0", prs, lanes: batch.lanes.map((l) => ({ lane: l.lane, requested: l.requestIds.length > 0 })), added_prs: [], auto: opts.auto ?? false }, `${run.sprintRunId}:presented:${k}`); + if (opts.auto) return { run, jobId, batch }; + await event(run.sprintRunId, jobId, "gate.reached", { kind: "gate2", pr_url: null, material: "資料" }); + await finish(run.sprintRunId, jobId, "gate"); + return { run, jobId, batch }; +} + +async function answer(releaseRunId: string, decision: "approved" | "rejected", approvalRequestId = `ar-${++n}`) { + await event(releaseRunId, null, "gate.answered", { approval_request_id: approvalRequestId, kind: "gate2", decision, answered_by_account_id: "acc-1" }, `api:${approvalRequestId}`, "api"); + return h.control.requestResume({ sprintRunId: releaseRunId, approvalRequestId }); +} + +async function settle(releaseRunId: string, prs: number[], sha = "aaaa111") { + const run = await runOf(releaseRunId); + const jobId = run.currentJobId!; + const seq = run.releaseSeq!; + await event(releaseRunId, jobId, "release.progress", { seq, stage: "main_merged" }, `${releaseRunId}:progress:main_merged`); + await event(releaseRunId, jobId, "release.settled", { seq, result: "released", version: "10.28.0", prod_pr_url: `https://github.com/${REPO}/pull/900`, sha, prs, lanes: [] }, `${releaseRunId}:settled:released`); + await finish(releaseRunId, jobId, "done"); +} + +const answeredOf = (sprintRunId: string) => h.progress.listRun(sprintRunId).filter((e) => e.type === "release.answered"); +const instructionOf = (jobId: string) => h.platform.taskOf(jobId)![1].instruction; + +describe("配布のまとめ(受け入れ条件 1・2・3・5・6、I56・I57・I61・I64)", () => { + it("同じリポジトリの 2 本の依頼で、まとめた配布 1 つ・配布の実行 1 本・本番の Pull Request 1 本になり、2 本とも同じ答えを持つ", async () => { + const a = await startRun(1); + const b = await startRun(2); + await requestRelease(a, [11]); + // 1 本目の依頼で、他を待たずに組む(AC 2) + const batch1 = (await currentBatch())!; + expect(batch1.seq).toBe(1); + await requestRelease(b, [12]); + // 流れている間の依頼は組まずに残る(AC 5・I56) + expect((await h.table.listBatches("acc-1", REPO_ID, 10)).length).toBe(1); + const release = await runOf(batch1.releaseRunId); + expect(release).toMatchObject({ purpose: "release", releaseSeq: 1, state: "starting" }); + expect(release.sprintRunId).not.toBe(a); + expect(release.sprintRunId).not.toBe(b); + const instruction = instructionOf(release.currentJobId!); + expect(validateRunJobInstruction(instruction).ok).toBe(true); + expect(instruction).toMatchObject({ START: "release", RELEASE_SEQ: "1", RELEASE_STEP: "present", NDF_VERSION: "10.27.16" }); + + // 配布の実行の承認資料は、作る時点の develop(2 本の変更を含む)で決まる(AC 3) + await present(1, "aaaa111", [11, 12]); + expect((await currentBatch())!.state).toBe("presented"); + const resumed = await answer(release.sprintRunId, "approved"); + expect(instructionOf(resumed.jobId!)).toMatchObject({ START: "release", RELEASE_STEP: "confirm:1" }); + await settle(release.sprintRunId, [11, 12]); + + const batches = await h.table.listBatches("acc-1", REPO_ID, 10); + expect(batches).toHaveLength(1); + expect(batches[0]).toMatchObject({ state: "released", result: { version: "10.28.0", prodPrUrl: `https://github.com/${REPO}/pull/900` } }); + for (const id of [a, b]) { + expect(answeredOf(id).map((e) => e.payload)).toEqual([expect.objectContaining({ seq: 1, result: "released", version: "10.28.0", prod_pr_url: `https://github.com/${REPO}/pull/900` })]); + // 手動の実行は答えで再開する + const run = await runOf(id); + expect(run.state).toBe("starting"); + expect(instructionOf(run.currentJobId!)).toMatchObject({ START: "resume", RELEASE_REQUEST_ID: expect.stringMatching(/^run-/) }); + } + expect(await runOf(release.sprintRunId)).toMatchObject({ state: "done" }); + expect((await h.table.getOrganizer("acc-1", REPO_ID))!.currentSeq).toBeNull(); + }); + + it("配布の実行は実行の枠を持たない(I64)", async () => { + const a = await startRun(1); + const before = await h.table.getSlots("acc-1", REPO_ID); + await requestRelease(a, [11]); + expect(await h.table.getSlots("acc-1", REPO_ID)).toEqual(before); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + await settle(batch.releaseRunId, [11]); + expect(await h.table.getSlots("acc-1", REPO_ID)).toEqual(before); + }); + + it("手動の実行は依頼の後 awaiting_approval(release_requested)で枠を持ち、承認の依頼の答えでは再開しない(AC 7・I65)", async () => { + const a = await startRun(1); + const id = await requestRelease(a, [11]); + expect(await runOf(a)).toMatchObject({ state: "awaiting_approval", awaitingReason: "release_requested", releaseRequestId: id }); + expect((await h.table.getSlots("acc-1", REPO_ID)).repository).toBe(1); + expect(await codeOf(h.control.requestResume({ sprintRunId: a, approvalRequestId: "ar-x" }))).toBe("not_awaiting_approval"); + }); + + it("流れている間に届いた依頼は、配布済みの後に次のまとめた配布になる(AC 5)", async () => { + const a = await startRun(1); + const b = await startRun(2); + await requestRelease(a, [11]); + const first = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await requestRelease(b, [12]); + await answer(first.releaseRunId, "approved"); + await settle(first.releaseRunId, [11]); + const second = (await currentBatch())!; + expect(second.seq).toBe(2); + expect(second.lanes.find((l) => l.requestIds.length > 0)!.sprintRunId).toBe(b); + expect(answeredOf(b)).toHaveLength(0); + }); + + it("組む書き込みが落ちれば、まとめた配布も配布の実行も無い(AC 6・I57)", async () => { + const a = await startRun(1); + h.table.failNextCompose = true; + await requestRelease(a, [11]); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(0); + expect([...h.table.runs.values()].filter((r) => r.purpose === "release")).toHaveLength(0); + // 見回りが続きから組む + await h.control.sweep(); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(1); + expect([...h.table.runs.values()].filter((r) => r.purpose === "release")).toHaveLength(1); + }); + + it("同時の 2 つの tick で組まれるのは 1 つ(I56)", async () => { + const a = await startRun(1); + h.table.failNextCompose = true; + await requestRelease(a, [11]); + await Promise.all([h.organizer.tick("acc-1", REPO_ID), h.organizer.tick("acc-1", REPO_ID)]); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(1); + expect([...h.table.runs.values()].filter((r) => r.purpose === "release")).toHaveLength(1); + }); +}); + +describe("答えと出し直し(受け入れ条件 4、I60)", () => { + it("提示 1 への承認で develop が進んでいれば represent、提示 2 への答えでは develop を読まずに confirm:2", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + h.github.heads.set(`${REPO}#develop`, "bbbb222"); + const r1 = await answer(batch.releaseRunId, "approved"); + expect(instructionOf(r1.jobId!)).toMatchObject({ RELEASE_STEP: "represent" }); + expect((await currentBatch())!.state).toBe("represented"); + await present(2, "bbbb222", [11, 13]); + h.github.heads.set(`${REPO}#develop`, "cccc333"); + const reads = h.github.headReads.length; + const r2 = await answer(batch.releaseRunId, "approved"); + expect(h.github.headReads.length).toBe(reads); + expect(instructionOf(r2.jobId!)).toMatchObject({ RELEASE_STEP: "confirm:2" }); + expect((await currentBatch())!).toMatchObject({ state: "confirmed", confirmedK: 2 }); + expect((await currentBatch())!.presentations.map((p) => p.sha)).toEqual(["aaaa111", "bbbb222"]); + }); + + it("MVV 判定が通した提示は承認の依頼を作らずに確定する(E12)", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const { run } = await present(1, "aaaa111", [11], { auto: true }); + await h.organizer.tick("acc-1", REPO_ID); + expect((await currentBatch())!).toMatchObject({ state: "confirmed", confirmedK: 1 }); + await settle(run.sprintRunId, [11]); + expect(answeredOf(a)).toHaveLength(1); + }); + + it("差し戻しはジョブを起こさず、最後の提示の範囲の依頼へ rejected を返す(I61)", async () => { + const a = await startRun(1); + const b = await startRun(2); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await requestRelease(b, [12]); + const r = await answer(batch.releaseRunId, "rejected"); + expect(r.jobId).toBeNull(); + expect(await h.table.getBatch("acc-1", REPO_ID, 1)).toMatchObject({ state: "rejected" }); + expect(await runOf(batch.releaseRunId)).toMatchObject({ state: "stopped" }); + expect(answeredOf(a).map((e) => e.payload)).toEqual([expect.objectContaining({ result: "rejected" })]); + // 範囲に入らない依頼は次のまとめた配布になる + expect(answeredOf(b)).toHaveLength(0); + expect((await currentBatch())!.seq).toBe(2); + }); + + it("提示 1 への承認の再送は、提示 2 の承認にしない", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + h.github.heads.set(`${REPO}#develop`, "bbbb222"); + await answer(batch.releaseRunId, "approved", "ar-first"); + await present(2, "bbbb222", [11, 13]); + expect(await codeOf(h.control.requestResume({ sprintRunId: batch.releaseRunId, approvalRequestId: "ar-first" }))).toBe("not_presented"); + expect((await currentBatch())!).toMatchObject({ state: "presented", confirmedK: null }); + }); + + it("確定した後に本番のプランの中の判断で止まったら、承認ゲート 2 の答えの再送で本番のプランを流し直さない", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + h.github.heads.set(`${REPO}#develop`, "aaaa111"); + await present(1, "aaaa111", [11]); + const r = await answer(batch.releaseRunId, "approved", "ar-ok"); + expect(instructionOf(r.jobId!)).toMatchObject({ RELEASE_STEP: "confirm:1" }); + const run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "gate.reached", { kind: "decision", pr_url: null, material: "判断" }); + await finish(run.sprintRunId, run.currentJobId!, "gate"); + expect(await codeOf(h.control.requestResume({ sprintRunId: batch.releaseRunId, approvalRequestId: "ar-ok" }))).toBe("not_presented"); + }); + + it("提示の前の判断への答えは、続き(continue)でなく提示のプランから流し直す", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + const run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "gate.reached", { kind: "decision", pr_url: null, material: "判断" }); + await finish(run.sprintRunId, run.currentJobId!, "gate"); + await event(run.sprintRunId, null, "gate.answered", { approval_request_id: "ar-d", kind: "decision", answered_by_account_id: "acc-1" }, "api:ar-d", "api"); + const r = await h.control.requestResume({ sprintRunId: run.sprintRunId, approvalRequestId: "ar-d" }); + expect(instructionOf(r.jobId!)).toMatchObject({ RELEASE_STEP: "present" }); + }); +}); + +describe("待ち(受け入れ条件 8、I62)", () => { + it("Factory の外の本番の Pull Request が開いている間は組まず、待ちの理由を残し、閉じた後の見回りで組む", async () => { + h.github.pulls.set(REPO, [{ head: "release/v10.27.9", base: "main", state: "open", url: `https://github.com/${REPO}/pull/77` }]); + const a = await startRun(1); + await requestRelease(a, [11]); + expect(await currentBatch()).toBeNull(); + const list = await h.organizer.list(accountContext(), REPO_ID); + expect(list.organizer).toEqual({ current_seq: null, wait_reason: "external_release_open", wait_detail: `https://github.com/${REPO}/pull/77` }); + expect((await h.table.listOrgLogs("acc-1", REPO_ID)).map((l) => l.kind)).toEqual(["waited"]); + h.github.pulls.set(REPO, [{ head: "release/v10.27.9", base: "main", state: "merged", url: `https://github.com/${REPO}/pull/77` }]); + await h.control.sweep(); + expect((await currentBatch())!.seq).toBe(1); + expect((await h.table.getOrganizer("acc-1", REPO_ID))!.waitReason).toBeNull(); + }); + + it("差し戻したまとめた配布が自分で開いた release/v<版> は外の本番と見なさず、次の配布を組む", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "rejected"); + h.github.pulls.set(REPO, [{ head: "release/v10.28.0", base: "main", state: "open", url: `https://github.com/${REPO}/pull/90` }]); + await requestRelease(await startRun(2), [12]); + expect((await currentBatch())!.seq).toBe(2); + }); + + it("head が develop の main 宛ての Pull Request でも待つ", async () => { + h.github.pulls.set(REPO, [{ head: "develop", base: "main", state: "open", url: `https://github.com/${REPO}/pull/78` }]); + await requestRelease(await startRun(1), [11]); + expect(await currentBatch()).toBeNull(); + }); + + it("依頼を出していない古い版の実行がある間は組まない。依頼を出して待つ古い版の実行は数えない", async () => { + h.table.migrated = false; + const a = await startRun(1); + expect((await runOf(a)).legacy).toBe(true); + await requestRelease(a, [11]); + // 古い版の実行は依頼を出して待っているので数えず、組む + expect((await currentBatch())!.seq).toBe(1); + }); + + it("まとめた配布が流れている間は古い版の実行を始めない", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + expect(await currentBatch()).not.toBeNull(); + // 依頼を待つ実行を取り消してリポジトリの枠を空け、古い版の実行を始める + await h.control.cancelSprintRun(accountContext(), a); + h.table.migrated = false; + expect(await codeOf(startRun(2))).toBe("parallel_unsupported_version"); + }); + + it("照らした後に古い版の実行が数えに入れば、組む書き込みは落ちる(legacySeq)", async () => { + const a = await startRun(1); + h.table.failNextCompose = true; + await requestRelease(a, [11]); + // 古い版の実行が 1 本入って抜け、legacyCount は 0 に戻るが legacySeq は 1 進む + h.table.legacySeqs.set(`acc-1#${REPO_ID}`, 1); + const getSlots = h.table.getSlots.bind(h.table); + h.table.getSlots = async (acc, repo) => ({ ...(await getSlots(acc, repo)), legacySeq: undefined }); + await h.organizer.tick("acc-1", REPO_ID); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(0); + h.table.getSlots = getSlots; + await h.organizer.tick("acc-1", REPO_ID); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(1); + }); +}); + +describe("止まった配布と再開(受け入れ条件 12・13、I58・I59・I63)", () => { + it("配布の実行が失敗すると止まった配布になり、main のマージの後は continue だけで、差し戻しは 409", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + const run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "release.progress", { seq: 1, stage: "main_merged" }, `${run.sprintRunId}:progress:main_merged`); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect(await runOf(run.sprintRunId)).toMatchObject({ state: "awaiting_approval", awaitingReason: "release_stalled" }); + expect((await currentBatch())!).toMatchObject({ state: "stalled", stalledFrom: "confirmed", mainMerged: true }); + const list = await h.organizer.list(accountContext(), REPO_ID); + expect(list.items).toEqual([expect.objectContaining({ kind: "release_stalled", release: expect.objectContaining({ choices: ["continue"] }) })]); + expect(await codeOf(h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "reject" }))).toBe("release_after_main"); + expect(await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" })).toEqual({ seq: 1, state: "confirmed" }); + const resumed = await runOf(run.sprintRunId); + expect(instructionOf(resumed.currentJobId!)).toMatchObject({ START: "release", RELEASE_STEP: "continue" }); + expect(await codeOf(h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" }))).toBe("not_stalled"); + }); + + it("配布の実行が取り消されても、GitHub で main へマージ済みなら差し戻さない", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + h.github.pulls.set(REPO, [{ head: "release/v10.28.0", base: "main", state: "merged", url: `https://github.com/${REPO}/pull/91` }]); + await h.table.finishRun(batch.releaseRunId, "cancelled", null, at()); + await h.organizer.tick("acc-1", REPO_ID); + expect(await h.table.getBatch("acc-1", REPO_ID, 1)).toMatchObject({ state: "confirmed", mainMerged: true }); + expect(answeredOf(a)).toHaveLength(0); + }); + + it("main へのマージの後に取り消された配布の実行は、止まった配布として残り、続きから流せる。ジョブの無いときの取り消しは 409", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + let run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "release.progress", { seq: 1, stage: "main_merged" }, `${run.sprintRunId}:progress:main_merged`); + await h.control.cancelSprintRun(accountContext(), run.sprintRunId); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect(await runOf(run.sprintRunId)).toMatchObject({ state: "awaiting_approval", awaitingReason: "release_stalled", cancelRequested: false }); + expect((await currentBatch())!).toMatchObject({ state: "stalled", stalledFrom: "confirmed", mainMerged: true }); + expect(await codeOf(h.control.cancelSprintRun(accountContext(), run.sprintRunId))).toBe("release_after_main"); + await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" }); + run = await runOf(batch.releaseRunId); + expect(instructionOf(run.currentJobId!)).toMatchObject({ RELEASE_STEP: "continue" }); + }); + + it("main へのマージの前に取り消された配布の実行は、今のとおり差し戻す", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + const run = await runOf(batch.releaseRunId); + await h.control.cancelSprintRun(accountContext(), run.sprintRunId); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect((await runOf(run.sprintRunId)).state).toBe("cancelled"); + expect(await h.table.getBatch("acc-1", REPO_ID, 1)).toMatchObject({ state: "rejected" }); + }); + + it("提示を受けた後に承認ゲート 2 へ届かずに落ちた配布は止まった配布になり、続きは提示のプランを流し直して承認を待つ", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + let run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "release.presented", { seq: 1, k: 1, sha: "aaaa111", version: "10.28.0", prs: [11], lanes: [], added_prs: [], auto: false }, `${run.sprintRunId}:presented:1`); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect((await currentBatch())!).toMatchObject({ state: "stalled", stalledFrom: "presented" }); + expect(await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" })).toEqual({ seq: 1, state: "presented" }); + run = await runOf(batch.releaseRunId); + expect(instructionOf(run.currentJobId!)).toMatchObject({ RELEASE_STEP: "present" }); + await event(run.sprintRunId, run.currentJobId, "gate.reached", { kind: "gate2", pr_url: null, material: "資料" }); + await finish(run.sprintRunId, run.currentJobId!, "gate"); + const r = await answer(batch.releaseRunId, "approved"); + expect(instructionOf(r.jobId!)).toMatchObject({ RELEASE_STEP: "confirm:1" }); + }); + + it("提示を受けた後に落ちた配布は差し戻せる", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + const run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "release.presented", { seq: 1, k: 1, sha: "aaaa111", version: "10.28.0", prs: [11], lanes: [], added_prs: [], auto: false }, `${run.sprintRunId}:presented:1`); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect(await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "reject" })).toEqual({ seq: 1, state: "rejected" }); + }); + + it("確定の後に止まり本番に手を付けた跡が無ければ confirm:、GitHub に枝があれば continue", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + let run = await runOf(batch.releaseRunId); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" }); + run = await runOf(batch.releaseRunId); + expect(instructionOf(run.currentJobId!)).toMatchObject({ RELEASE_STEP: "confirm:1" }); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + h.github.heads.set(`${REPO}#release/v10.28.0`, "dddd444"); + await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" }); + run = await runOf(batch.releaseRunId); + expect(instructionOf(run.currentJobId!)).toMatchObject({ RELEASE_STEP: "continue" }); + }); + + it("提示の前に止まった配布の差し戻しは、受けた依頼すべてへ rejected を返す", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + const run = await runOf(batch.releaseRunId); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect(await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "reject" })).toEqual({ seq: 1, state: "rejected" }); + expect(answeredOf(a).map((e) => e.payload)).toEqual([expect.objectContaining({ result: "rejected" })]); + }); + + it("答えを書いた後に届け損ねた依頼は、見回りが 1 回だけ届け直し、届け終えたら索引から外れる", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + // 手動の実行の再開を一度だけ落とす(busy として扱われ、届け終えない) + const original = h.control.resumeForRelease.bind(h.control); + h.control.resumeForRelease = async () => "busy"; + await settle(batch.releaseRunId, [11]); + expect(answeredOf(a)).toHaveLength(1); + expect((await h.table.listOrgRequests("acc-1", REPO_ID))[0]!.deliveredAt).toBeNull(); + expect(await h.table.listOrganizerActive()).toEqual([{ accountId: "acc-1", repositoryId: REPO_ID }]); + h.control.resumeForRelease = original; + await h.control.sweep(); + await h.control.sweep(); + expect(answeredOf(a)).toHaveLength(1); + expect((await h.table.listOrgRequests("acc-1", REPO_ID))[0]!.deliveredAt).not.toBeNull(); + expect(await h.table.listOrganizerActive()).toEqual([]); + expect((await runOf(a)).state).toBe("starting"); + }); + + it("再開した実行が別の承認ゲートで待っていれば、届け直しは再開せずに届け終える", async () => { + const a = await startRun(1); + const id = await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + await settle(batch.releaseRunId, [11]); + // 再開した手動の実行が別の承認ゲートで止まる + const run = await runOf(a); + await finish(a, run.currentJobId!, "gate"); + expect(await runOf(a)).toMatchObject({ state: "awaiting_approval", awaitingReason: null }); + expect(await h.control.resumeForRelease(a, id)).toBe("not_waiting"); + expect((await runOf(a)).currentJobId).toBeNull(); + }); + + it("同じ依頼をランナーが再送しても受けた依頼の行は 1 行(I59)", async () => { + const a = await startRun(1); + const id = await requestRelease(a, [11]); + const rows = await h.organizer.newRequestRows(await runOf(a), { requested: [{ request_id: id, lane: `run-${a}`, prs: [11], issues: [1], ndf_version: "10.27.16", requested_at: at() }], presented: [], progress: [], settled: null }); + expect(rows).toEqual([]); + expect(await h.table.listOrgRequests("acc-1", REPO_ID)).toHaveLength(1); + }); +}); + +describe("一覧と履歴(受け入れ条件 9・11、I66)", () => { + it("一覧は同じリポジトリの承認の依頼とループの人の判断待ちを 1 つの並びで返し、別のリポジトリのものを含まない", async () => { + h.github.installations.get(9)!.set(202, "org-a/repo-b"); + h.github.openIssues.set("org-a/repo-b", [{ number: 1, title: "x" }]); + await h.table.putRegistration({ accountId: "acc-1", repositoryId: 202, fullName: "org-a/repo-b", installationId: 9, registeredBy: "user-1", registeredAt: "2026-11-01T00:00:00Z" }); + const a = await startRun(1); + const other = (await h.control.startSprintRun(accountContext(), { repositoryId: 202, issues: [1] })).sprint_run_id; + for (const id of [a, other]) { + const run = await runOf(id); + await event(id, run.currentJobId, "gate.reached", { kind: "gate1", pr_url: `https://github.com/${REPO}/pull/5`, material: "m" }); + await finish(id, run.currentJobId!, "gate"); + } + const list = await h.organizer.list(accountContext(), REPO_ID); + expect(list.items).toEqual([expect.objectContaining({ kind: "gate1", sprint_run_id: a, pr_url: `https://github.com/${REPO}/pull/5` })]); + }); + + it("履歴は提示・確定・結果と記録を新しい順に返す", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + await settle(batch.releaseRunId, [11]); + const history = await h.organizer.history(accountContext(), REPO_ID, 20); + expect(history.items).toHaveLength(1); + expect(history.items[0]).toMatchObject({ seq: 1, state: "released", confirmed_k: 1, result: { version: "10.28.0" } }); + expect(history.items[0]!.log.map((l) => l.kind)).toEqual(["composed", "presented", "confirmed", "released"]); + }); + + it("別のアカウントのリポジトリは 404", async () => { + expect(await codeOf(h.organizer.list(accountContext({ accountId: "acc-2" }), REPO_ID))).toBe("repository_not_found"); + }); +}); + +describe("laneOf", () => { + it("ループはループの名前、手動の実行は実行の識別子を切り詰めてレーンの名前にする", () => { + expect(laneOf({ sprintRunId: "x" }, "LFactory3-very-long-loop-name-over-limit")).toBe("loop-lfactory3-very-long-loop-na"); + expect(laneOf({ sprintRunId: "0123456789ABCDEF0123456789abcdef" }, null)).toBe("run-0123456789abcdef"); + expect(laneOf({ sprintRunId: "sr1" }, null)).toBe("run-sr1"); + }); +}); diff --git a/factory/api/src/organizer/control.ts b/factory/api/src/organizer/control.ts new file mode 100644 index 000000000..82e1ded70 --- /dev/null +++ b/factory/api/src/organizer/control.ts @@ -0,0 +1,689 @@ +// 配布のまとめ(OrganizerControl。#2439 の F2〜F8)。同じリポジトリで並行する実行の配布の依頼を受け、まとめた配布を 1 つずつ組み、 +// 配布の実行を起こし、承認の答えで出し直すか確定し、結果を範囲の実行へ返す。常駐せず、実行の制御の停止の後の処理・再開の要求・ +// 見回りと、画面の経路の中で動く(非機能の「可用性」)。状態はすべて実行の表の行で、tick は根の状態だけで次の 1 手が決まる +import type { ReleaseAnsweredEvent } from "@ndf-factory/contracts"; +import type { AccountContext } from "../accounts/model.ts"; +import { ConflictError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts"; +import type { GitHubPort } from "../github/port.ts"; +import { releaseAnsweredKey } from "../progress/authorize.ts"; +import type { ProgressReader, ReleaseEvents } from "../progress/read.ts"; +import type { RunControl } from "../runs/control.ts"; +import { isTerminalRun, type Registration, type ReleaseStep, type SprintRun } from "../runs/model.ts"; +import type { RunsTable } from "../runs/table.ts"; +import { + choicesOf, + emptyRoot, + isTerminalBatch, + laneOf, + newestVersion, + presentedVersion, + requestsToAnswer, + resumeStepOf, + stalledFromOf, + stepFrom, + type BatchLane, + type OrganizerRoot, + type OrgLog, + type OrgLogKind, + type OrgRequest, + type ReleaseAnswer, + type ReleaseBatch, +} from "./model.ts"; + +// 根の状態を変える書き込みが落ちたときに読み直す回数(I67) +const ROOT_RETRIES = 3; +// 起動の記録が無いまま pending の配布の実行のジョブを起こし直すまでの時間(組んだ直後の起動と取り合わない) +const RELAUNCH_AFTER_MS = 60_000; + +export interface OrganizerDeps { + table: RunsTable; + github: GitHubPort; + reader: ProgressReader; + control: RunControl; + appendAnswered: (event: ReleaseAnsweredEvent) => Promise<{ written: boolean }>; + now?: () => Date; + log?: (entry: Record) => void; +} + +// 自分で開いた本番の枝を探す、差し戻したまとめた配布の件数(新しい方から) +const OWN_RELEASE_LOOKBACK = 20; + +const isExternalRelease = (head: string) => head.startsWith("release/v") || head === "develop"; + +export class OrganizerControl { + private readonly now: () => Date; + private readonly log: (entry: Record) => void; + + constructor(private readonly deps: OrganizerDeps) { + this.now = deps.now ?? (() => new Date()); + this.log = deps.log ?? ((entry) => console.log(JSON.stringify(entry))); + } + + private iso(): string { + return this.now().toISOString(); + } + + private logRow(root: Pick, kind: OrgLogKind, seq: number | null, detail: Record): OrgLog { + return { accountId: root.accountId, repositoryId: root.repositoryId, at: this.iso(), kind, seq, detail }; + } + + // ---- 受ける(停止の後の処理が呼ぶ) ---- + + // ジョブが送った release.requested から、まだ無い受けた依頼の行を作る(I59)。実行の移りと同じトランザクションで書く + async newRequestRows(run: SprintRun, events: ReleaseEvents): Promise { + if (events.requested.length === 0) return []; + const existing = new Set((await this.deps.table.listOrgRequests(run.accountId, run.repositoryId)).map((r) => r.requestId)); + const out: OrgRequest[] = []; + for (const p of events.requested) { + if (existing.has(p.request_id) || out.some((r) => r.requestId === p.request_id)) continue; + out.push({ + accountId: run.accountId, + repositoryId: run.repositoryId, + requestId: p.request_id, + sprintRunId: run.sprintRunId, + loopId: run.loopId ?? null, + lane: p.lane, + prs: p.prs, + issues: p.issues, + ndfVersion: p.ndf_version, + requestedAt: p.requested_at, + answer: null, + deliveredAt: null, + }); + } + return out; + } + + // ---- tick: 根の続き(届け直し・流れている配布の続き・待ちの判定と組む) ---- + + async tick(accountId: string, repositoryId: number): Promise { + for (let attempt = 0; attempt < ROOT_RETRIES; attempt++) { + await this.deliverAll(accountId, repositoryId); + const root = (await this.deps.table.getOrganizer(accountId, repositoryId)) ?? null; + const state = root ?? emptyRoot(accountId, repositoryId, this.iso()); + if (state.currentSeq !== null) { + const batch = await this.deps.table.getBatch(accountId, repositoryId, state.currentSeq); + if (!batch) return; + const moved = await this.advance(state, batch); + // 流れている配布が終わったら、残った依頼で次のまとめた配布を組む(同じ tick で続ける) + if (moved === "ended") continue; + if (moved === "retry") continue; + return; + } + const composed = await this.compose(root, state); + if (composed !== "retry") return; + } + } + + // 流れているまとめた配布を、配布の実行の状態と事象に合わせて 1 手進める + private async advance(root: OrganizerRoot, batch: ReleaseBatch): Promise<"ended" | "retry" | "same"> { + const { table, reader } = this.deps; + const run = await table.getRun(batch.releaseRunId); + if (!run) return "same"; + const events = await reader.findReleaseEvents(run.sprintRunId); + let next: ReleaseBatch = structuredClone(batch); + const logs: OrgLog[] = []; + + // 提示(E3・E12)。提示は上書きせず足す。3 つ目は組まない(I60) + for (const p of events.presented) { + if (p.seq !== batch.seq || next.presentations.some((x) => x.k === p.k) || p.k !== next.presentations.length + 1) continue; + next.presentations.push({ k: p.k as 1 | 2, sha: p.sha, version: p.version, prs: p.prs, lanes: p.lanes, addedPrs: p.added_prs, at: this.iso() }); + if (p.auto) { + next.state = "confirmed"; + next.confirmedK = p.k as 1 | 2; + logs.push(this.logRow(root, "confirmed", batch.seq, { k: p.k, sha: p.sha, auto: true })); + } else if (next.state === "composed" || next.state === "represented") { + next.state = "presented"; + logs.push(this.logRow(root, "presented", batch.seq, { k: p.k, sha: p.sha, prs: p.prs, added_prs: p.added_prs })); + } + } + if (!next.mainMerged && events.progress.some((p) => p.seq === batch.seq && p.stage === "main_merged")) next.mainMerged = true; + + // 結果(E7) + const settled = events.settled && events.settled.seq === batch.seq ? events.settled : null; + if (settled?.result === "released" && !isTerminalBatch(next.state)) { + next.state = "released"; + next.mainMerged = true; + next.result = { version: settled.version ?? null, prodPrUrl: settled.prod_pr_url ?? null, sha: settled.sha ?? null, prs: settled.prs }; + next.stalledFrom = null; + next.stallReason = null; + const ok = await this.end(root, batch, next, "released", settled.prs, this.logRow(root, "released", batch.seq, { ...next.result })); + return ok ? "ended" : "retry"; + } + + // 配布の実行が止まった(I65・決定 11) + if (run.state === "awaiting_approval" && run.awaitingReason === "release_stalled" && next.state !== "stalled") { + const from = stalledFromOf(next.state); + if (from) { + next.mainMerged = next.mainMerged || (await this.mergedOnGitHub(run, next)); + next.stalledFrom = from; + next.stallReason = settled?.reason ?? run.failureReason ?? "job_failed"; + next.state = "stalled"; + logs.push(this.logRow(root, "stalled", batch.seq, { from, reason: next.stallReason, main_merged: next.mainMerged })); + } + } + + // 配布の実行が取り消しなどで終わった。main へのマージの前なら差し戻す(I63)。ランナーが段を送る前に取り消されても + // 公開済みの配布を差し戻さないよう、GitHub の main へのマージを照らしてから決める + if (isTerminalRun(run.state) && !isTerminalBatch(next.state) && !next.mainMerged) next.mainMerged = await this.mergedOnGitHub(run, next); + if (isTerminalRun(run.state) && !isTerminalBatch(next.state) && !next.mainMerged) { + next.state = "rejected"; + const ok = await this.end(root, batch, next, "rejected", [], this.logRow(root, "rejected", batch.seq, { reason: `release_run_${run.state}` })); + return ok ? "ended" : "retry"; + } + + if (JSON.stringify(next) !== JSON.stringify(batch)) { + next.updatedAt = this.iso(); + const ok = await table.writeOrganizer({ + root: { ...root, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: root.version, + batch: next, + expectBatchState: [batch.state], + ...(logs[0] ? { log: logs[0] } : {}), + }); + if (!ok) return "retry"; + for (const extra of logs.slice(1)) await table.appendOrgLog(extra); + } + + // 組んだ後に起動の前で止まった配布の実行を起こし直す(ジョブの冪等は startedBy) + if (run.state === "starting" && run.currentJobId) { + const job = await table.getJob(run.currentJobId); + if ( + job && + job.state === "pending" && + job.runnerKind === "ecs" && + !job.taskArn && + !job.tokenSecretArn && + this.now().getTime() - Date.parse(job.createdAt) > RELAUNCH_AFTER_MS + ) { + await this.deps.control.launchJob(run, job); + } + } + return "same"; + } + + // 本番のプランに手を付けた跡(届いた段の事象か、確定した提示の release/v<版> の枝・main 宛ての Pull Request)。 + // 差し戻したまとめた配布が残した同じ版の枝は跡に数えない(出していないので次の配布は同じ版を提示する) + private async releaseStarted(run: SprintRun, batch: ReleaseBatch, reg: Registration, checkGitHub = true): Promise { + const { reader, github } = this.deps; + if ((await reader.findReleaseEvents(run.sprintRunId)).progress.length > 0) return true; + const version = checkGitHub ? presentedVersion(batch) : null; + if (!version) return false; + const branch = `release/v${version}`; + if ((await this.rejectedReleaseHeads(batch)).has(branch)) return false; + return ( + (await github.branchHead(reg.installationId, reg.repositoryId, reg.fullName, branch)) !== null || + (await github.findPullByHead(reg.installationId, reg.repositoryId, reg.fullName, branch, "main")) !== null + ); + } + + // 配布の実行のまとめた配布が main へマージ済みか(記録・届いた段・GitHub の順に照らす)。終わったまとめた配布は偽 + async releaseMerged(run: SprintRun): Promise { + if (!run.releaseSeq) return false; + const batch = await this.deps.table.getBatch(run.accountId, run.repositoryId, run.releaseSeq); + if (!batch || isTerminalBatch(batch.state)) return false; + if (batch.mainMerged) return true; + const { progress } = await this.deps.reader.findReleaseEvents(run.sprintRunId); + if (progress.some((p) => p.seq === batch.seq && p.stage === "main_merged")) return true; + return this.mergedOnGitHub(run, batch); + } + + // main へのマージを GitHub で照らす(ジョブが段を書く前に落ちても分かるように。決定 11) + private async mergedOnGitHub(run: SprintRun, batch: ReleaseBatch): Promise { + const version = presentedVersion(batch); + if (!version) return false; + const reg = await this.deps.table.getRegistration(run.accountId, run.repositoryId); + if (!reg) return false; + const pull = await this.deps.github.findPullByHead(reg.installationId, reg.repositoryId, reg.fullName, `release/v${version}`, "main"); + return pull?.state === "merged"; + } + + // まとめた配布を終わりの状態にして根を空け、範囲の依頼へ答えを書く(I58・I61) + private async end(root: OrganizerRoot, before: ReleaseBatch, next: ReleaseBatch, end: "released" | "rejected", settledPrs: number[], log: OrgLog): Promise { + const { table } = this.deps; + next.updatedAt = this.iso(); + const ok = await table.writeOrganizer({ + root: { ...root, currentSeq: null, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: root.version, + batch: next, + expectBatchState: [before.state], + log, + }); + if (!ok) return false; + await this.answerRange(next, end, settledPrs); + if (end === "rejected") { + const run = await table.getRun(next.releaseRunId); + if (run && !isTerminalRun(run.state)) await table.finishRun(run.sprintRunId, "stopped", null, this.iso()); + } + return true; + } + + // 範囲の依頼へ答えを書く。終わった後に止まっても、見回りが同じ規則で書き直す(2 回目は I58 で書かれない) + // before を渡すと、その時刻までに出した依頼だけを照らす(終わった後に出し直した依頼を古い配布で答えない) + // known を渡すと読み直さずにそれを照らし、書けた答えをその行へ写す。別の書き手と取り合ったら false(呼び手が読み直す) + private async answerRange(batch: ReleaseBatch, end: "released" | "rejected", settledPrs: number[], before?: string, known?: OrgRequest[]): Promise { + const requests = (known ?? (await this.deps.table.listOrgRequests(batch.accountId, batch.repositoryId))).filter( + (r) => before === undefined || Date.parse(r.requestedAt) <= Date.parse(before), + ); + const targets = requestsToAnswer(batch, requests, end, settledPrs); + let fresh = true; + for (const r of targets) { + const answer: ReleaseAnswer = { + seq: batch.seq, + result: end, + version: end === "released" ? (batch.result?.version ?? null) : null, + prodPrUrl: end === "released" ? (batch.result?.prodPrUrl ?? null) : null, + reason: end === "rejected" ? "release_rejected" : null, + at: this.iso(), + }; + if (await this.deps.table.answerRequest(r.accountId, r.repositoryId, r.requestId, answer)) r.answer = answer; + else fresh = false; + } + return fresh; + } + + // 答えがあって届け終えていない依頼を届ける(E8)。release.answered を追記し、手動の実行は再開する。 + // 実行がまだ別のジョブで動いている(busy)ときは届け終えたことにせず、次の tick で届け直す + private async deliverAll(accountId: string, repositoryId: number): Promise { + const { table, control } = this.deps; + // 終わった配布の答えを書き終える前に止まっていたら、ここで書き直す。依頼の一覧は 1 回だけ読み、書いた答えはその行へ写す + let requests = await table.listOrgRequests(accountId, repositoryId); + let fresh = true; + for (const batch of await table.listBatches(accountId, repositoryId, 5)) { + if (batch.state === "released" || batch.state === "rejected") { + if (!requests.some((r) => r.answer === null)) break; + if (!(await this.answerRange(batch, batch.state, batch.result?.prs ?? [], batch.updatedAt, requests))) fresh = false; + } + } + if (!fresh) requests = await table.listOrgRequests(accountId, repositoryId); + for (const r of requests) { + if (!r.answer || r.deliveredAt !== null) continue; + const event: ReleaseAnsweredEvent = { + sprint_run_id: r.sprintRunId, + idempotency_key: releaseAnsweredKey(r.answer.seq, r.sprintRunId), + type: "release.answered", + at: r.answer.at, + source: "control", + job_id: null, + schema_version: 1, + payload: { + seq: r.answer.seq, + request_id: r.requestId, + result: r.answer.result, + ...(r.answer.version ? { version: r.answer.version } : {}), + ...(r.answer.prodPrUrl ? { prod_pr_url: r.answer.prodPrUrl } : {}), + }, + }; + await this.deps.appendAnswered(event); + if (r.loopId === null) { + const resumed = await control.resumeForRelease(r.sprintRunId, r.requestId); + if (resumed === "busy") continue; + } + await table.markDelivered(accountId, repositoryId, r.requestId, this.iso()); + } + } + + // 流れている配布が無いとき: 未回答の依頼があれば、待ちの理由を照らしてから組む(F2・I62) + private async compose(stored: OrganizerRoot | null, root: OrganizerRoot): Promise<"done" | "retry"> { + const { table, github, control } = this.deps; + const requests = await table.listOrgRequests(root.accountId, root.repositoryId); + const pending = requests.filter((r) => r.answer === null); + if (pending.length === 0) { + if (stored && stored.waitReason !== null) { + await table.writeOrganizer({ root: { ...stored, waitReason: null, waitDetail: null, version: stored.version + 1, updatedAt: this.iso() }, expectVersion: stored.version }); + } + return "done"; + } + const reg = await table.getRegistration(root.accountId, root.repositoryId); + if (!reg) return "done"; + + // Factory の外の本番の Pull Request(I62・決定 8)。差し戻したまとめた配布が自分で開いた release/v<版> は外のものと見なさない + // (本番の Pull Request を開いた後に止まって差し戻すと開いたまま残り、数えると人が閉じるまで次の配布が待ち続ける) + const candidates = (await github.listOpenPullHeads(reg.installationId, reg.repositoryId, reg.fullName, "main")).filter((p) => isExternalRelease(p.head)); + const own = candidates.length > 0 ? await this.rejectedReleaseHeads(root) : new Set(); + const external = candidates.find((p) => !own.has(p.head)); + if (external) return this.wait(stored, root, "external_release_open", external.url); + + // 依頼を出していない古い版の実行(依頼を出して待つ実行は自分で配布しないため数えない) + const slots = await table.getSlots(root.accountId, root.repositoryId); + let requestedLegacy = 0; + const runs = new Map(); + for (const id of new Set(pending.map((r) => r.sprintRunId))) { + const run = await table.getRun(id); + if (!run) continue; + runs.set(id, run); + if (run.legacy === true && run.state === "awaiting_approval" && run.awaitingReason === "release_requested") requestedLegacy += 1; + } + const legacyOpen = (slots.legacy ?? 0) - requestedLegacy; + if (legacyOpen > 0) return this.wait(stored, root, "legacy_run", String(legacyOpen)); + + // レーン: 依頼のあるレーンと、同じリポジトリで動いている依頼の無いレーン + const lanes = new Map(); + for (const r of pending) { + const lane = lanes.get(r.lane) ?? { lane: r.lane, sprintRunId: r.sprintRunId, loopId: r.loopId, requestIds: [], issues: [] }; + lane.requestIds.push(r.requestId); + lane.issues = [...new Set([...lane.issues, ...r.issues])]; + lanes.set(r.lane, lane); + } + for (const run of await table.listRuns(root.accountId, 1000)) { + if (run.repositoryId !== root.repositoryId || run.purpose === "release" || isTerminalRun(run.state)) continue; + const loopName = run.loopId ? ((await table.getLoop(run.accountId, run.loopId))?.name ?? null) : null; + const name = laneOf(run, loopName); + if (!lanes.has(name)) lanes.set(name, { lane: name, sprintRunId: run.sprintRunId, loopId: run.loopId ?? null, requestIds: [], issues: run.issues }); + } + + const base = runs.get(pending[0]!.sprintRunId) ?? [...runs.values()][0]; + if (!base) return "done"; + const seq = root.nextSeq; + const ndfVersion = newestVersion(pending.map((r) => r.ndfVersion), base.ndfVersion); + const { run, job } = control.buildReleaseRun(base, seq, ndfVersion); + const now = this.iso(); + const batch: ReleaseBatch = { + accountId: root.accountId, + repositoryId: root.repositoryId, + seq, + state: "composed", + releaseRunId: run.sprintRunId, + lanes: [...lanes.values()], + presentations: [], + confirmedK: null, + mainMerged: false, + result: null, + stallReason: null, + stalledFrom: null, + createdAt: now, + updatedAt: now, + }; + const ok = await table.composeRelease({ + write: { + root: { ...root, currentSeq: seq, nextSeq: seq + 1, version: root.version + 1, waitReason: null, waitDetail: null, updatedAt: now }, + expectVersion: stored ? stored.version : null, + batch, + log: this.logRow(root, "composed", seq, { release_run_id: run.sprintRunId, lanes: batch.lanes.map((l) => ({ lane: l.lane, requested: l.requestIds.length > 0 })) }), + }, + run, + job, + legacySeq: slots.legacySeq ?? null, + }); + if (!ok) return "retry"; + await control.launchJob(run, job); + return "done"; + } + + // 差し戻したまとめた配布の本番の枝(release/v<版>)。新しい方から OWN_RELEASE_LOOKBACK 件を見る + private async rejectedReleaseHeads(root: Pick): Promise> { + const batches = await this.deps.table.listBatches(root.accountId, root.repositoryId, OWN_RELEASE_LOOKBACK); + const versions = batches.filter((b) => b.state === "rejected").map((b) => presentedVersion(b)); + return new Set(versions.filter((v): v is string => v !== null).map((v) => `release/v${v}`)); + } + + private async wait(stored: OrganizerRoot | null, root: OrganizerRoot, reason: "external_release_open" | "legacy_run", detail: string): Promise<"done" | "retry"> { + if (stored && stored.waitReason === reason && stored.waitDetail === detail) return "done"; + const ok = await this.deps.table.writeOrganizer({ + root: { ...root, waitReason: reason, waitDetail: detail, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: stored ? stored.version : null, + log: this.logRow(root, "waited", null, { reason, detail }), + }); + return ok ? "done" : "retry"; + } + + // ---- 答え(再開の要求が配布の実行を回す。F4) ---- + + // 配布の実行への承認の依頼の答えから、再開の手を決める。差し戻しなら null(ジョブを起こさない) + async decide(run: SprintRun, approvalRequestId: string): Promise { + const { table, reader, github } = this.deps; + // 提示の記録が停止の後の処理で書けていなければ先に進める + await this.tick(run.accountId, run.repositoryId); + const answer = await reader.findGateAnswer(run.sprintRunId, approvalRequestId); + if (!answer) throw new ServiceUnavailableError("approval_answer_unavailable"); + for (let attempt = 0; attempt < ROOT_RETRIES; attempt++) { + const root = await table.getOrganizer(run.accountId, run.repositoryId); + const batch = run.releaseSeq ? await table.getBatch(run.accountId, run.repositoryId, run.releaseSeq) : null; + if (!root || !batch) throw new ConflictError("release_not_found", "まとめた配布が見つかりません"); + // 判断(承認ゲートの外の問い)は、止まったプランをまとめた配布の状態から流し直す(止まった配布の「続ける」と同じ規則)。 + // 常に continue だと、提示のプランの中で止まった配布が承認ゲート 2 を通らずに本番のプランへ進む + if (answer.kind !== "gate2") { + const from = stalledFromOf(batch.state); + if (!from) throw new ConflictError("not_presented", "まとめた配布は判断の答えで続けられる状態にありません"); + const reg = from === "confirmed" ? await table.getRegistration(run.accountId, run.repositoryId) : null; + const started = from === "confirmed" && (batch.mainMerged || (reg !== null && (await this.releaseStarted(run, batch, reg)))); + return stepFrom(from, batch, started); + } + // 同じ答えの再送(ジョブを作る前に止まった)は、書いた状態から同じ手を返す。その提示に記録した承認の依頼と一致し、 + // 実行が今も承認ゲート 2 で止まっているときだけにする(本番のプランの中の判断で止まった後の古い答えで流し直さない) + if (batch.state === "represented" || batch.state === "confirmed") { + const k = batch.state === "represented" ? 1 : batch.confirmedK; + const answered = batch.presentations.find((p) => p.k === k); + const gate = await reader.findLastGate(run.sprintRunId); + if (!answered?.approvalRequestId || answered.approvalRequestId !== approvalRequestId || gate?.payload.kind !== "gate2") { + throw new ConflictError("not_presented", "この承認は今待っている提示への答えではありません"); + } + if (batch.state === "represented") return "represent"; + return k === 2 ? "confirm:2" : "confirm:1"; + } + if (batch.state !== "presented") throw new ConflictError("not_presented", "まとめた配布は承認を待っていません"); + // 前の提示へ答えた承認の再送は、今待っている提示の承認にしない + if (batch.presentations.some((p) => p.approvalRequestId === approvalRequestId)) { + throw new ConflictError("not_presented", "この承認は前の提示への答えです。今の提示は承認を待っています"); + } + if (answer.decision === "rejected") { + const next: ReleaseBatch = { ...structuredClone(batch), state: "rejected" }; + if (await this.end(root, batch, next, "rejected", [], this.logRow(root, "rejected", batch.seq, { approval_request_id: approvalRequestId }))) { + await this.tick(run.accountId, run.repositoryId); + return null; + } + continue; + } + const last = batch.presentations.at(-1); + if (!last) throw new ConflictError("not_presented", "まとめた配布は承認を待っていません"); + let step: ReleaseStep; + const next: ReleaseBatch = { ...structuredClone(batch), updatedAt: this.iso() }; + next.presentations.at(-1)!.approvalRequestId = approvalRequestId; + let log: OrgLog; + if (last.k === 1) { + // 提示 1 への承認だけ develop の先頭を比べる(I60・決定 7) + const reg = await table.getRegistration(run.accountId, run.repositoryId); + const head = reg ? await github.branchHead(reg.installationId, reg.repositoryId, reg.fullName, "develop") : null; + if (head === null) throw new ServiceUnavailableError("develop_unreadable"); + if (head !== last.sha) { + next.state = "represented"; + step = "represent"; + log = this.logRow(root, "represented", batch.seq, { from: last.sha, head }); + } else { + next.state = "confirmed"; + next.confirmedK = 1; + step = "confirm:1"; + log = this.logRow(root, "confirmed", batch.seq, { k: 1, sha: last.sha }); + } + } else { + next.state = "confirmed"; + next.confirmedK = 2; + step = "confirm:2"; + log = this.logRow(root, "confirmed", batch.seq, { k: 2, sha: last.sha }); + } + const ok = await table.writeOrganizer({ + root: { ...root, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: root.version, + batch: next, + expectBatchState: ["presented"], + log, + }); + if (ok) return step; + } + throw new ServiceUnavailableError("organizer_busy"); + } + + // ---- 画面の経路(F6・F7) ---- + + private async registration(ctx: AccountContext, repositoryId: number): Promise { + const reg = await this.deps.table.getRegistration(ctx.accountId, repositoryId); + if (!reg) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); + return reg; + } + + private releaseSummary(batch: ReleaseBatch, requests: OrgRequest[], choices: ("continue" | "reject")[]) { + const last = batch.presentations.at(-1) ?? null; + return { + seq: batch.seq, + k: last?.k ?? null, + sha: last?.sha ?? null, + lanes: batch.lanes.map((l) => ({ + lane: l.lane, + requested: l.requestIds.length > 0, + prs: [...new Set(requests.filter((r) => l.requestIds.includes(r.requestId)).flatMap((r) => r.prs))], + })), + added_prs: last?.addedPrs ?? [], + choices, + }; + } + + // リポジトリの承認と問いの一覧(AC 9)。読むだけで、承認の依頼へ答えを書く経路を持たない(I66) + async list(ctx: AccountContext, repositoryId: number) { + const { table, reader } = this.deps; + await this.registration(ctx, repositoryId); + const root = await table.getOrganizer(ctx.accountId, repositoryId); + const requests = await table.listOrgRequests(ctx.accountId, repositoryId); + const items: Record[] = []; + for (const run of await table.listRuns(ctx.accountId, 1000)) { + if (run.repositoryId !== repositoryId || run.state !== "awaiting_approval") continue; + if (run.awaitingReason === "release_requested") continue; + const batch = run.purpose === "release" && run.releaseSeq ? await table.getBatch(ctx.accountId, repositoryId, run.releaseSeq) : null; + if (run.awaitingReason === "release_stalled") { + if (!batch) continue; + items.push({ kind: "release_stalled", sprint_run_id: run.sprintRunId, created_at: run.updatedAt, release: this.releaseSummary(batch, requests, choicesOf(batch)) }); + continue; + } + const gate = await reader.findLastGate(run.sprintRunId); + if (!gate) continue; + items.push({ + kind: gate.payload.kind, + sprint_run_id: run.sprintRunId, + ...(run.loopId ? { loop_id: run.loopId } : {}), + pr_url: gate.payload.pr_url, + created_at: gate.at, + ...(batch ? { release: this.releaseSummary(batch, requests, gate.payload.choices ?? []) } : {}), + }); + } + for (const loop of await table.listLoops(ctx.accountId)) { + if (loop.repositoryId !== repositoryId) continue; + for (const p of loop.pending) items.push({ kind: "loop_pending", loop_id: loop.loopId, number: p.number, reason: p.reason, created_at: loop.updatedAt }); + } + items.sort((a, b) => ((a.created_at as string) < (b.created_at as string) ? -1 : (a.created_at as string) > (b.created_at as string) ? 1 : 0)); + return { + organizer: { current_seq: root?.currentSeq ?? null, wait_reason: root?.waitReason ?? null, wait_detail: root?.waitDetail ?? null }, + items, + }; + } + + // まとめた配布の履歴(新しい順) + async history(ctx: AccountContext, repositoryId: number, limit: number) { + const { table } = this.deps; + await this.registration(ctx, repositoryId); + const logs = await table.listOrgLogs(ctx.accountId, repositoryId); + const batches = await table.listBatches(ctx.accountId, repositoryId, limit); + return { + items: batches.map((b) => ({ + seq: b.seq, + state: b.state, + release_run_id: b.releaseRunId, + presentations: b.presentations.map((p) => ({ k: p.k, sha: p.sha, version: p.version, prs: p.prs, lanes: p.lanes, added_prs: p.addedPrs, at: p.at })), + confirmed_k: b.confirmedK, + result: b.result ? { version: b.result.version, prod_pr_url: b.result.prodPrUrl, sha: b.result.sha, prs: b.result.prs } : null, + log: logs.filter((l) => l.seq === b.seq).map((l) => ({ at: l.at, kind: l.kind, detail: l.detail })), + })), + }; + } + + // 止まった配布を解く(F7)。main へのマージの後の差し戻しは断る(I63) + async resolve(ctx: AccountContext, repositoryId: number, seq: number, input: { choice?: unknown }) { + const { table, reader, github, control } = this.deps; + const choice = input.choice; + if (choice !== "continue" && choice !== "reject") throw new InvalidRequestError("choice は continue か reject です"); + const reg = await this.registration(ctx, repositoryId); + // 止まった印が停止の後の処理で書けていなければ先に進める + await this.tick(ctx.accountId, repositoryId); + for (let attempt = 0; attempt < ROOT_RETRIES; attempt++) { + const root = await table.getOrganizer(ctx.accountId, repositoryId); + const batch = await table.getBatch(ctx.accountId, repositoryId, seq); + if (!root || !batch) throw new NotFoundError("release_not_found", "まとめた配布が見つかりません"); + if (batch.state !== "stalled" || !batch.stalledFrom) throw new ConflictError("not_stalled", "まとめた配布は止まっていません"); + if (choice === "reject") { + if (batch.mainMerged) throw new ConflictError("release_after_main", "main へマージした配布は差し戻せません"); + const next: ReleaseBatch = { ...structuredClone(batch), state: "rejected" }; + if (await this.end(root, batch, next, "rejected", [], this.logRow(root, "resolved", seq, { choice }))) { + await this.tick(ctx.accountId, repositoryId); + return { seq, state: "rejected" as const }; + } + continue; + } + const run = await table.getRun(batch.releaseRunId); + if (!run) throw new NotFoundError("release_not_found", "まとめた配布が見つかりません"); + const started = batch.mainMerged || (await this.releaseStarted(run, batch, reg, batch.stalledFrom === "confirmed")); + const step = resumeStepOf(batch, started); + const next: ReleaseBatch = { ...structuredClone(batch), state: batch.stalledFrom, stalledFrom: null, stallReason: null, updatedAt: this.iso() }; + // 状態の戻しと再開のジョブの作成を同じトランザクションで書く(間に見回りが止まった印を書き戻さない) + const ok = await control.resumeRelease(run, step, { + root: { ...root, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: root.version, + batch: next, + expectBatchState: ["stalled"], + log: this.logRow(root, "resolved", seq, { choice, step }), + }); + if (ok) return { seq, state: next.state }; + const now = await table.getRun(batch.releaseRunId); + if (!now || now.state !== "awaiting_approval" || now.awaitingReason !== "release_stalled" || now.currentJobId !== null) { + throw new ConflictError("not_stalled", "配布の実行は止まっていません"); + } + } + throw new ServiceUnavailableError("organizer_busy"); + } + + // ---- ランナー向け ---- + + // そのジョブの実行のレーンの依頼の答えと、リポジトリの配布済みの範囲(#2436 の答えと範囲の形) + async answersFor(run: SprintRun) { + const { table } = this.deps; + const requests = (await table.listOrgRequests(run.accountId, run.repositoryId)).filter((r) => + run.loopId ? r.loopId === run.loopId : r.sprintRunId === run.sprintRunId, + ); + const batches = await table.listBatches(run.accountId, run.repositoryId, 100); + return { + answers: requests + .filter((r) => r.answer !== null) + .map((r) => ({ request_id: r.requestId, result: r.answer!.result, version: r.answer!.version, release: r.answer!.prodPrUrl, reason: r.answer!.reason, at: r.answer!.at })), + ranges: batches + .filter((b) => b.state === "released" && b.result) + .map((b) => ({ seq: b.seq, sha: b.result!.sha, version: b.result!.version, prs: b.result!.prs, lanes: b.lanes.map((l) => l.lane) })), + }; + } + + // 配布の実行のまとめた配布の中身(batch.json) + async batchFor(run: SprintRun, step: ReleaseStep | null) { + const { table } = this.deps; + if (run.purpose !== "release" || !run.releaseSeq) return null; + const batch = await table.getBatch(run.accountId, run.repositoryId, run.releaseSeq); + if (!batch) return null; + const requests = await table.listOrgRequests(run.accountId, run.repositoryId); + return { + seq: batch.seq, + step: step ?? "present", + lanes: batch.lanes.map((l) => ({ + lane: l.lane, + requests: l.requestIds.map((id) => ({ id, prs: requests.find((r) => r.requestId === id)?.prs ?? [] })), + issues: l.issues, + })), + presentations: batch.presentations.map((p) => ({ k: p.k, sha: p.sha, version: p.version, prs: p.prs, lanes: p.lanes, added_prs: p.addedPrs, at: p.at })), + choices: choicesOf(batch), + }; + } + + // ---- 見回り(F8) ---- + + async sweep(): Promise { + for (const { accountId, repositoryId } of await this.deps.table.listOrganizerActive()) { + await this.tick(accountId, repositoryId).catch((err: Error) => + this.log({ event: "organizer_tick_failed", account_id: accountId, repository_id: repositoryId, error: err.name }), + ); + } + } +} diff --git a/factory/api/src/organizer/model.ts b/factory/api/src/organizer/model.ts new file mode 100644 index 000000000..5182990de --- /dev/null +++ b/factory/api/src/organizer/model.ts @@ -0,0 +1,209 @@ +// 集約「オーガナイザーの進行」(#2439 の設計の「データ構造」)。根・受けた依頼・まとめた配布・記録の行の型と、 +// 状態の移りを決める純粋な関数。表への読み書きは実行の表(runs/table.ts)が持つ +import { organizerLaneName } from "@ndf-factory/contracts"; +import type { ReleaseStep } from "../runs/model.ts"; + +export type OrganizerWaitReason = "external_release_open" | "legacy_run"; + +// 根(1 リポジトリに 1 行)。version は楽観的な排他の版(I67) +export interface OrganizerRoot { + accountId: string; + repositoryId: number; + version: number; + // 流れているまとめた配布の連番。空は「流れていない」(I56) + currentSeq: number | null; + nextSeq: number; + waitReason: OrganizerWaitReason | null; + waitDetail: string | null; + updatedAt: string; +} + +export interface ReleaseAnswer { + seq: number; + result: "released" | "rejected"; + version: string | null; + prodPrUrl: string | null; + reason: string | null; + at: string; +} + +// 受けた依頼(依頼 1 件に 1 行。I59)。答えは 1 回だけ書く(I58) +export interface OrgRequest { + accountId: string; + repositoryId: number; + requestId: string; + sprintRunId: string; + loopId: string | null; + lane: string; + prs: number[]; + issues: number[]; + ndfVersion: string; + requestedAt: string; + answer: ReleaseAnswer | null; + // 答えを依頼元へ届け終えた時刻。空の間は見回りの索引に残る + deliveredAt: string | null; +} + +export const BATCH_STATES = ["composed", "presented", "represented", "confirmed", "stalled", "released", "rejected"] as const; +export type BatchState = (typeof BATCH_STATES)[number]; +export const isTerminalBatch = (state: BatchState) => state === "released" || state === "rejected"; +export type StalledFrom = "composed" | "presented" | "represented" | "confirmed"; + +export interface BatchLane { + lane: string; + sprintRunId: string; + loopId: string | null; + requestIds: string[]; + issues: number[]; +} + +export interface Presentation { + k: 1 | 2; + sha: string; + version: string; + prs: number[]; + lanes: { lane: string; requested: boolean }[]; + addedPrs: number[]; + at: string; + // この提示へ答えた承認の依頼(承認ゲート 2)。古い承認の再送を次の提示の承認と取り違えないために残す。前の記録には無い + approvalRequestId?: string | null; +} + +export interface BatchResult { + version: string | null; + prodPrUrl: string | null; + sha: string | null; + prs: number[]; +} + +// まとめた配布(I56・I57) +export interface ReleaseBatch { + accountId: string; + repositoryId: number; + seq: number; + state: BatchState; + releaseRunId: string; + lanes: BatchLane[]; + // 提示は上書きせず足す。2 つまで(I60) + presentations: Presentation[]; + confirmedK: 1 | 2 | null; + mainMerged: boolean; + result: BatchResult | null; + stallReason: string | null; + stalledFrom: StalledFrom | null; + createdAt: string; + updatedAt: string; +} + +export const LOG_KINDS = ["waited", "composed", "presented", "represented", "confirmed", "stalled", "resolved", "released", "rejected"] as const; +export type OrgLogKind = (typeof LOG_KINDS)[number]; + +// 記録の行(追記だけ) +export interface OrgLog { + accountId: string; + repositoryId: number; + at: string; + kind: OrgLogKind; + seq: number | null; + detail: Record; +} + +// 根の状態を変える書き込み(I67)。expectVersion が null なら根がまだ無いことを条件にする。 +// batch を渡すと同じトランザクションで書き、expectBatchState があればその状態のときだけ書く +export interface OrganizerWrite { + root: OrganizerRoot; + expectVersion: number | null; + batch?: ReleaseBatch; + expectBatchState?: BatchState[]; + log?: OrgLog; +} + +export const emptyRoot = (accountId: string, repositoryId: number, now: string): OrganizerRoot => ({ + accountId, + repositoryId, + version: 0, + currentSeq: null, + nextSeq: 1, + waitReason: null, + waitDetail: null, + updatedAt: now, +}); + +// Factory の実行のレーンの名前。式は契約の organizerLaneName(ランナーの laneName と共有) +export const laneOf = (run: { sprintRunId: string }, loopName: string | null) => organizerLaneName(run.sprintRunId, loopName); + +const overlaps = (a: number[], b: number[]) => a.some((n) => b.includes(n)); + +// 範囲に入る依頼か。Pull Request が範囲に 1 つでも入るか、Pull Request の無い依頼はまとめた配布が受けた依頼であること +export function inRange(request: OrgRequest, prs: number[], batch: ReleaseBatch): boolean { + if (request.prs.length === 0) return batch.lanes.some((l) => l.requestIds.includes(request.requestId)); + return overlaps(request.prs, prs); +} + +// まとめた配布の終わり方で答える依頼を決める(I61)。配布済みは結果の prs、差し戻しは最後の提示の prs、 +// 提示の前の差し戻しはまとめた配布が受けた依頼すべて。どれにも入らない依頼は次のまとめた配布へ残る +export function requestsToAnswer(batch: ReleaseBatch, requests: OrgRequest[], end: "released" | "rejected", settledPrs: number[] = []): OrgRequest[] { + const open = requests.filter((r) => r.answer === null); + if (end === "released") return open.filter((r) => inRange(r, settledPrs, batch)); + const last = batch.presentations.at(-1); + if (!last) { + const received = new Set(batch.lanes.flatMap((l) => l.requestIds)); + return open.filter((r) => received.has(r.requestId)); + } + return open.filter((r) => inRange(r, last.prs, batch)); +} + +// 止まった配布の選択肢(I63)。main へのマージの後は continue だけ +export const choicesOf = (batch: Pick): ("continue" | "reject")[] => (batch.mainMerged ? ["continue"] : ["continue", "reject"]); + +// 止まる前の状態(ジョブが失敗した時点の state から)。presented は提示を受けた後にジョブが落ちたとき +// (ランナーが release.presented を送った後の状態の保存に失敗したなど)で、承認ゲート 2 に届いていない +export function stalledFromOf(state: BatchState): StalledFrom | null { + if (state === "composed" || state === "presented" || state === "represented" || state === "confirmed") return state; + return null; +} + +// 「続きから流す」の再開の手(#2439 の設計の「止まったときと再開」)。confirmed は本番のプランに手を付けた跡があれば continue +export function resumeStepOf(batch: ReleaseBatch, startedRelease: boolean): ReleaseStep { + return stepFrom(batch.stalledFrom, batch, startedRelease); +} + +// 止まる前(か今)の状態から流し直す手。判断(承認ゲートの外の問い)への答えの再開も同じ規則で決める +// presented は受けた最後の提示のプランを流し直す(提示 1 なら present、提示 2 なら represent。状態は presented のまま承認ゲート 2 を待つ) +export function stepFrom(state: StalledFrom | null, batch: Pick, startedRelease: boolean): ReleaseStep { + if (state === "composed") return "present"; + if (state === "represented") return "represent"; + if (state === "presented") return batch.presentations.at(-1)?.k === 2 ? "represent" : "present"; + if (startedRelease || batch.mainMerged) return "continue"; + return batch.confirmedK === 2 ? "confirm:2" : "confirm:1"; +} + +// 提示の版(本番のプランの枝 release/v<版>)。確定した提示があればその版、無ければ最後の提示の版 +export function presentedVersion(batch: ReleaseBatch): string | null { + const k = batch.confirmedK; + const p = k ? batch.presentations.find((x) => x.k === k) : batch.presentations.at(-1); + return p?.version ?? null; +} + +// 配布の実行の NDF の版: 範囲の依頼の版のうち最も新しいもの(下限とは比べない) +export function newestVersion(versions: string[], fallback: string): string { + const parse = (v: string) => v.split("@")[0]!.split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : -1)); + let best: string | null = null; + for (const v of versions) { + if (best === null) { + best = v; + continue; + } + const a = parse(v); + const b = parse(best); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x !== y) { + if (x > y) best = v; + break; + } + } + } + return best ?? fallback; +} diff --git a/factory/api/src/progress/append.test.ts b/factory/api/src/progress/append.test.ts index ededcddef..14c518cc2 100644 --- a/factory/api/src/progress/append.test.ts +++ b/factory/api/src/progress/append.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it } from "vitest"; -import type { IssueRequestResolvedEvent, JobFinishedEvent } from "@ndf-factory/contracts"; -import { appendControlEventTo, appendIssueRequestResolvedTo, orderKey } from "./append.ts"; +import type { IssueRequestResolvedEvent, JobFinishedEvent, ProgressEvent, ReleaseAnsweredEvent } from "@ndf-factory/contracts"; +import { appendControlEventTo, appendIssueRequestResolvedTo, appendReleaseAnsweredTo, orderKey } from "./append.ts"; import { ProgressEventError } from "./errors.ts"; import { MemoryProgressEventStore } from "./memory-store.ts"; -import { isCallerRole } from "./authorize.ts"; +import { authorizeRunner, isCallerRole } from "./authorize.ts"; import { verifyWebhookSignature } from "./github.ts"; let store: MemoryProgressEventStore; @@ -93,6 +93,43 @@ describe("appendIssueRequestResolved(#2216 の決定 10、I48)", () => { }); }); +describe("配布の事象の出所と鍵(#2439)", () => { + const answered = (patch: Partial = {}): ReleaseAnsweredEvent => ({ + sprint_run_id: "sr-1", + idempotency_key: "3:sr-1", + type: "release.answered", + at: "2026-11-02T02:00:00Z", + source: "control", + job_id: null, + schema_version: 1, + payload: { seq: 3, request_id: "run-sr-1-r1", result: "released", version: "10.28.0" }, + ...patch, + }); + + it("release.answered は範囲の実行 1 本に 1 件だけ残り、鍵が : でなければ forbidden", async () => { + expect(await appendReleaseAnsweredTo(store, answered())).toEqual({ written: true }); + expect(await appendReleaseAnsweredTo(store, answered())).toEqual({ written: false }); + expect(await errorOf(appendReleaseAnsweredTo(store, answered({ idempotency_key: "3:sr-2" })))).toBe("forbidden"); + expect(await errorOf(appendReleaseAnsweredTo(store, answered({ job_id: "job-1" })))).toBe("forbidden"); + }); + + it("ランナーは配布の事象を決まった鍵で書け、release.answered は書けない", () => { + const job = { jobId: "job-1", sprintRunId: "sr-1", repo: "o/r" } as never; + const ev = (type: string, key: string, payload: Record) => + ({ sprint_run_id: "sr-1", idempotency_key: key, type, at: "2026-11-02T02:00:00Z", source: "runner", job_id: "job-1", schema_version: 1, payload }) as unknown as ProgressEvent; + const requested = { request_id: "run-sr-1-r1", lane: "run-sr-1", prs: [1], issues: [], ndf_version: "10.27.16", requested_at: "2026-11-02T02:00:00Z" }; + expect(authorizeRunner(ev("release.requested", "run-sr-1-r1", requested), job)).toBe(true); + expect(authorizeRunner(ev("release.requested", "job-1:3", requested), job)).toBe(false); + expect(authorizeRunner(ev("release.settled", "sr-1:settled:failed", { seq: 1, result: "failed", prs: [], lanes: [] }), job)).toBe(true); + // 失敗の後の再開の成功は別の鍵(同じ実行の failed の鍵では受けない) + expect(authorizeRunner(ev("release.settled", "sr-1:settled:released", { seq: 1, result: "released", prs: [], lanes: [] }), job)).toBe(true); + expect(authorizeRunner(ev("release.settled", "sr-1:settled:failed", { seq: 1, result: "released", prs: [], lanes: [] }), job)).toBe(false); + expect(authorizeRunner(ev("release.settled", "1:settled", { seq: 1, result: "failed", prs: [], lanes: [] }), job)).toBe(false); + expect(authorizeRunner(ev("release.progress", "sr-1:progress:tagged", { seq: 1, stage: "tagged" }), job)).toBe(true); + expect(authorizeRunner(ev("release.answered", "1:sr-1", { seq: 1, result: "released" }), job)).toBe(false); + }); +}); + describe("並びの鍵(決定 6)", () => { it("小数の桁が違う時刻をミリ秒の 3 桁にそろえ、文字列の順が時刻の順になる", () => { const keys = ["2026-10-30T00:00:00.5Z", "2026-10-30T00:00:00Z", "2026-10-30T00:00:00.123456Z"].map((at) => diff --git a/factory/api/src/progress/append.ts b/factory/api/src/progress/append.ts index ef0b0fd2f..f4275a8c4 100644 --- a/factory/api/src/progress/append.ts +++ b/factory/api/src/progress/append.ts @@ -1,5 +1,5 @@ // 追記。受け口と appendControlEvent が同じ関数を通り、同じ重複の拒否で 1 件を残す(I10) -import type { IssueRequestResolvedEvent, JobFinishedEvent, ProgressEvent } from "@ndf-factory/contracts"; +import type { IssueRequestResolvedEvent, JobFinishedEvent, ProgressEvent, ReleaseAnsweredEvent } from "@ndf-factory/contracts"; import { authorizeControl } from "./authorize.ts"; import { ProgressEventError } from "./errors.ts"; import { prRef } from "./github.ts"; @@ -46,3 +46,14 @@ export async function appendIssueRequestResolvedTo( } return appendEvent(store, checked.event); } + +// Factory のオーガナイザー(#2439)が範囲の実行の進捗の記録へ release.answered(source は control、鍵は :)を +// 直に追記する。appendControlEventTo と同じ 3 つを通る。false は同じ答えが既にある +export async function appendReleaseAnsweredTo(store: ProgressEventStore, event: ReleaseAnsweredEvent): Promise<{ written: boolean }> { + const checked = checkBuiltEvent(event); + if (!checked.ok) throw new ProgressEventError("invalid_event", checked.message); + if (checked.event.type !== "release.answered" || !authorizeControl(checked.event)) { + throw new ProgressEventError("forbidden", "ここで書けるのは : の release.answered だけです"); + } + return appendEvent(store, checked.event); +} diff --git a/factory/api/src/progress/authorize.ts b/factory/api/src/progress/authorize.ts index 498726e20..393b84bff 100644 --- a/factory/api/src/progress/authorize.ts +++ b/factory/api/src/progress/authorize.ts @@ -6,8 +6,14 @@ import type { IssueRequestedEvent, IssueRequestResolvedEvent, ProgressEvent, + ReleaseAnsweredEvent, + ReleasePresentedEvent, + ReleaseProgressEvent, + ReleaseRequestedEvent, + ReleaseSettledEvent, StepRecordedEvent, } from "@ndf-factory/contracts"; +import { releasePresentedKey, releaseProgressKey, releaseSettledKey } from "@ndf-factory/contracts"; import { repoKey } from "./github.ts"; import type { VerifiedRunJob } from "./run-job-token.ts"; import type { SavedAnswerReader } from "./saved-answers.ts"; @@ -28,10 +34,15 @@ export const WRITABLE_TYPES: Record> = { "section.started", "loop.lap_closed", "loop.stopped", + // 配布の依頼と配布の実行の事象(#2439)。Factory のオーガナイザーが停止の後の処理で読む + "release.requested", + "release.presented", + "release.progress", + "release.settled", ]), api: new Set(["gate.answered"]), github: new Set(["pr.updated", "ci.updated"]), - control: new Set(["job.finished", "issue.request_resolved"]), + control: new Set(["job.finished", "issue.request_resolved", "release.answered"]), }; const canWrite = (source: EventSource, event: ProgressEvent) => @@ -40,9 +51,32 @@ const canWrite = (source: EventSource, event: ProgressEvent) => // 実行ジョブの終わりの冪等の鍵。ランナーと実行の制御が同じ鍵を使い、先の 1 つだけが残る export const finishedKey = (jobId: string) => `${jobId}:finished`; -// ランナーの冪等の鍵の形(I27): job.finished は :finished、ほかは :(seq は 1 以上) +// 配布の事象の冪等の鍵(#2439)。依頼は依頼の id、配布の実行の事象は実行の識別子を頭にする(式は契約が持ち、 +// ランナーの送信と同じ式で照らす)。ランナーが送り直しても 1 件になる +export const releaseRequestedKey = (requestId: string) => requestId; +export { releasePresentedKey, releaseProgressKey, releaseSettledKey }; +export const releaseAnsweredKey = (seq: number, sprintRunId: string) => `${seq}:${sprintRunId}`; + +function releaseKeyOf(event: ProgressEvent): string | null { + switch (event.type) { + case "release.requested": + return releaseRequestedKey((event as ReleaseRequestedEvent).payload.request_id); + case "release.presented": + return releasePresentedKey(event.sprint_run_id, (event as ReleasePresentedEvent).payload.k); + case "release.progress": + return releaseProgressKey(event.sprint_run_id, (event as ReleaseProgressEvent).payload.stage); + case "release.settled": + return releaseSettledKey(event.sprint_run_id, (event as ReleaseSettledEvent).payload.result); + default: + return null; + } +} + +// ランナーの冪等の鍵の形(I27): job.finished は :finished、配布の事象は releaseKeyOf、ほかは :(seq は 1 以上) function isRunnerKey(event: ProgressEvent, jobId: string): boolean { if (event.type === "job.finished") return event.idempotency_key === finishedKey(jobId); + const releaseKey = releaseKeyOf(event); + if (releaseKey !== null) return event.idempotency_key === releaseKey; const prefix = `${jobId}:`; return event.idempotency_key.startsWith(prefix) && /^[1-9][0-9]*$/.test(event.idempotency_key.slice(prefix.length)); } @@ -78,7 +112,12 @@ export const resolvedKey = (requestKey: string) => `${requestKey}:resolved`; // control: job.finished は鍵が :finished(決定 9)。issue.request_resolved は request_key が // そのジョブの依頼の鍵(:)で、鍵が :resolved(#2216) +// release.answered はジョブの外の事象(job_id は null)で、鍵が :(#2439) export function authorizeControl(event: ProgressEvent): boolean { + if (event.type === "release.answered") { + const { seq } = (event as ReleaseAnsweredEvent).payload; + return canWrite("control", event) && event.job_id === null && event.idempotency_key === releaseAnsweredKey(seq, event.sprint_run_id); + } if (!canWrite("control", event) || event.job_id === null) return false; if (event.type === "job.finished") return event.idempotency_key === finishedKey(event.job_id); const { request_key } = (event as IssueRequestResolvedEvent).payload; diff --git a/factory/api/src/progress/read.ts b/factory/api/src/progress/read.ts index 630fc2c4d..313eb18b6 100644 --- a/factory/api/src/progress/read.ts +++ b/factory/api/src/progress/read.ts @@ -1,7 +1,19 @@ // 進捗の記録の読み出し(#2186 の実行の制御が使う)。読むだけで、保存の型に書き換えを足さない import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; -import type { JobFinishedEvent, LoopLapClosedPayload, LoopStoppedPayload, UsageRecordedEvent } from "@ndf-factory/contracts"; +import type { + GateAnsweredPayload, + GateReachedPayload, + JobFinishedEvent, + LoopLapClosedPayload, + LoopStoppedPayload, + ReleasePresentedPayload, + ReleaseProgressPayload, + ReleaseRequestedPayload, + ReleaseSettledPayload, + StepRecordedEvent, + UsageRecordedEvent, +} from "@ndf-factory/contracts"; import { finishedKey } from "./authorize.ts"; import type { MemoryProgressEventStore } from "./memory-store.ts"; import type { ProgressEventRecord } from "./store.ts"; @@ -16,8 +28,110 @@ export interface ProgressReader { // ループの詳細の画面: 実行が送った締めた周回と、実行ごとの usage.recorded の費用の和・区間の数(section.started の数)。 // 実行ごとに事象を 1 回だけ読む loopRunsDetail(sprintRunIds: string[]): Promise; + // 実行が送った配布の事象(#2439)。jobId を渡すとそのジョブの分だけ + findReleaseEvents(sprintRunId: string, jobId?: string): Promise; + // 実行の最後の gate.reached(一覧の承認の依頼の要約。#2439)。無ければ null + findLastGate(sprintRunId: string): Promise<{ at: string; jobId: string | null; payload: GateReachedPayload } | null>; + // 承認の依頼への答え(gate.answered。#2439 の配布のまとめが読む)。無ければ null + findGateAnswer(sprintRunId: string, approvalRequestId: string): Promise; + // ---- スプリントの一覧と詳細(#2172)。どれも本文(step.recorded の files・decisions・output)を返さない ---- + // 実行の事象を order_key の順に。step.recorded は新しい方から stepLimit 件までで、ほかの種類はすべて(I11) + listRunEvents(sprintRunId: string, stepLimit: number): Promise; + // 最後の phase.changed・最後の step.recorded・最後の事象(一覧の行に足す 4 項目のもと) + latestOfRun(sprintRunId: string): Promise; + // 冪等の鍵が指す、その実行の step.recorded(本文を含む)。別の実行の鍵・別の種類は null + getStep(sprintRunId: string, idempotencyKey: string): Promise; + // payload.step が step の step.recorded(order_key の順) + listStepsNamed(sprintRunId: string, step: string): Promise; } +export type StepRecord = StepRecordedEvent & ProgressEventRecord; + +export interface RunEvents { + events: ProgressEventRecord[]; + // 上限で読まなかった step.recorded の数 + omittedSteps: number; +} + +export interface RunLatest { + phase: ProgressEventRecord | null; + step: StepRecord | null; + last: ProgressEventRecord | null; +} + +// 工程の中身の本文。詳細の読み直しで返さない(決定 10) +const STEP_BODY = ["files", "files_omitted", "commit_range", "decisions", "output"] as const; + +const isStep = (e: ProgressEventRecord): e is StepRecord => e.type === "step.recorded"; + +// 本文を外した写し。外したことは has_details で分かるよう、持っていた項目の名前を残す +export function withoutBody(e: T): T & { body_fields?: string[] } { + if (!isStep(e)) return e; + const payload = { ...e.payload } as Record; + const present = STEP_BODY.filter((k) => payload[k] !== undefined); + for (const k of STEP_BODY) delete payload[k]; + return { ...e, payload, ...(present.length > 0 ? { body_fields: present } : {}) } as T & { body_fields?: string[] }; +} + +// order_key の順の列から、古い step.recorded を上限の外として捨てる +export function capSteps(ordered: ProgressEventRecord[], stepLimit: number): RunEvents { + const total = ordered.filter(isStep).length; + let skip = Math.max(0, total - stepLimit); + const omittedSteps = skip; + const events: ProgressEventRecord[] = []; + for (const e of ordered) { + if (isStep(e) && skip > 0) { + skip -= 1; + continue; + } + events.push(withoutBody(e)); + } + return { events, omittedSteps }; +} + +// order_key の順の列の最後の phase.changed・step.recorded・事象 +export function latestOf(ordered: ProgressEventRecord[]): RunLatest { + const step = ordered.filter(isStep).at(-1); + return { + phase: ordered.filter((e) => e.type === "phase.changed").at(-1) ?? null, + step: step ? withoutBody(step) : null, + last: ordered.at(-1) ?? null, + }; +} + +const byOrderKey = (a: ProgressEventRecord, b: ProgressEventRecord) => (a.order_key < b.order_key ? -1 : a.order_key > b.order_key ? 1 : 0); + +export interface ReleaseEvents { + requested: ReleaseRequestedPayload[]; + presented: ReleasePresentedPayload[]; + progress: ReleaseProgressPayload[]; + settled: ReleaseSettledPayload | null; +} + +const RELEASE_TYPES = ["release.requested", "release.presented", "release.progress", "release.settled"]; +const GATE_TYPES = ["gate.reached", "gate.answered"]; + +const releaseEventsOf = (records: ProgressEventRecord[], jobId?: string): ReleaseEvents => { + const own = (records as Rec[]).filter((e) => jobId === undefined || e.job_id === jobId); + const of = (type: string) => own.filter((e) => e.type === type).map((e) => e.payload as unknown as T); + return { + requested: of("release.requested"), + presented: of("release.presented"), + progress: of("release.progress"), + settled: of("release.settled").at(-1) ?? null, + }; +}; + +const lastGateOf = (records: ProgressEventRecord[]) => { + const e = (records as Rec[]).filter((x) => x.type === "gate.reached").at(-1); + return e ? { at: e.at, jobId: e.job_id, payload: e.payload as unknown as GateReachedPayload } : null; +}; + +const gateAnswerOf = (records: ProgressEventRecord[], approvalRequestId: string) => { + const e = (records as Rec[]).filter((x) => x.type === "gate.answered" && x.payload.approval_request_id === approvalRequestId).at(-1); + return e ? (e.payload as unknown as GateAnsweredPayload) : null; +}; + export interface LoopRunsDetail { laps: LoopLapClosedPayload[]; summaries: Record; @@ -83,6 +197,32 @@ export function memoryProgressReader(store: MemoryProgressEventStore): ProgressR async loopRunsDetail(sprintRunIds) { return detailOf(sprintRunIds.map((id) => [id, store.listRun(id)])); }, + async findReleaseEvents(sprintRunId, jobId) { + return releaseEventsOf(store.listRun(sprintRunId), jobId); + }, + async findLastGate(sprintRunId) { + return lastGateOf(store.listRun(sprintRunId)); + }, + async findGateAnswer(sprintRunId, approvalRequestId) { + return gateAnswerOf(store.listRun(sprintRunId), approvalRequestId); + }, + async listRunEvents(sprintRunId, stepLimit) { + return capSteps(store.listRun(sprintRunId), stepLimit); + }, + async latestOfRun(sprintRunId) { + return latestOf(store.listRun(sprintRunId)); + }, + async getStep(sprintRunId, idempotencyKey) { + const found = store.listRun(sprintRunId).find((e) => e.idempotency_key === idempotencyKey); + return found && isStep(found) ? structuredClone(found) : null; + }, + async listStepsNamed(sprintRunId, step) { + return store + .listRun(sprintRunId) + .filter(isStep) + .filter((e) => e.payload.step === step) + .map(withoutBody); + }, }; } @@ -168,4 +308,82 @@ export class DynamoProgressReader implements ProgressReader { ); return detailOf(perRun); } + + async findReleaseEvents(sprintRunId: string, jobId?: string) { + return releaseEventsOf(await this.listTypes(sprintRunId, RELEASE_TYPES), jobId); + } + + async findLastGate(sprintRunId: string) { + return lastGateOf(await this.listTypes(sprintRunId, ["gate.reached"])); + } + + async findGateAnswer(sprintRunId: string, approvalRequestId: string) { + return gateAnswerOf(await this.listTypes(sprintRunId, GATE_TYPES), approvalRequestId); + } + + // 表に type の索引が無いため、実行の事象を全ページ読んでから手元で古い step.recorded を捨てる。 + // ScanIndexForward=false と Limit で切ると古い gate.reached・usage.recorded を落とすため、その読み方はしない(決定 9) + async listRunEvents(sprintRunId: string, stepLimit: number): Promise { + const out: ProgressEventRecord[] = []; + let start: Record | undefined; + do { + const page = await this.doc.send( + new QueryCommand({ + TableName: this.eventsTable, + KeyConditionExpression: "sprint_run_id = :r", + ExpressionAttributeValues: { ":r": sprintRunId }, + ExclusiveStartKey: start, + ConsistentRead: true, + }), + ); + // 本文はページごとに外し、手元に持つ量を抑える + out.push(...((page.Items ?? []) as ProgressEventRecord[]).map(withoutBody)); + start = page.LastEvaluatedKey; + } while (start); + return capSteps(out.sort(byOrderKey), stepLimit); + } + + // 新しい方から読み、最後の phase.changed と step.recorded がそろったら止める + async latestOfRun(sprintRunId: string): Promise { + const latest: RunLatest = { phase: null, step: null, last: null }; + let start: Record | undefined; + do { + const page = await this.doc.send( + new QueryCommand({ + TableName: this.eventsTable, + KeyConditionExpression: "sprint_run_id = :r", + ExpressionAttributeValues: { ":r": sprintRunId }, + ScanIndexForward: false, + ExclusiveStartKey: start, + ConsistentRead: true, + }), + ); + for (const e of (page.Items ?? []) as ProgressEventRecord[]) { + latest.last ??= e; + if (!latest.phase && e.type === "phase.changed") latest.phase = e; + if (!latest.step && isStep(e)) latest.step = withoutBody(e); + } + start = latest.phase && latest.step ? undefined : page.LastEvaluatedKey; + } while (start); + return latest; + } + + async getStep(sprintRunId: string, idempotencyKey: string): Promise { + const key = await this.doc.send(new GetCommand({ TableName: this.idempotencyTable, Key: { idempotency_key: idempotencyKey }, ConsistentRead: true })); + const orderKey = key.Item?.order_key as string | undefined; + if (!orderKey || key.Item?.sprint_run_id !== sprintRunId) return null; + const out = await this.doc.send( + new GetCommand({ TableName: this.eventsTable, Key: { sprint_run_id: sprintRunId, order_key: orderKey }, ConsistentRead: true }), + ); + const item = out.Item as ProgressEventRecord | undefined; + return item && isStep(item) ? item : null; + } + + async listStepsNamed(sprintRunId: string, step: string): Promise { + return (await this.listTypes(sprintRunId, ["step.recorded"])) + .filter(isStep) + .filter((e) => e.payload.step === step) + .sort(byOrderKey) + .map(withoutBody); + } } diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts index 1fc0a09da..60de82223 100644 --- a/factory/api/src/routes/factory.test.ts +++ b/factory/api/src/routes/factory.test.ts @@ -74,9 +74,11 @@ beforeEach(async () => { credentials: new CredentialService(h.table, secrets), control: h.control, loops: new LoopControl({ table: h.table, control: h.control, reader: memoryProgressReader(h.progress), now: () => h.clock.now }), + organizer: h.organizer, table: h.table, github: h.github, candidateModel: (s: "ranking" | "recent", i: { number: number; title: string }[]) => model.fn(s, i), + reader: memoryProgressReader(h.progress), }; app = createApiApp(() => ({ verifier: tokens.verifier, accounts }), (e) => logs.push(e), () => null, () => factory); h.github.installations.set(9, new Map([[101, "org-a/repo-a"], [102, "org-a/repo-b"]])); @@ -226,3 +228,26 @@ describe("課題の候補(受け入れ条件 9・10)", () => { expect(res.candidates.map((c: { number: number }) => c.number)).toEqual([9, 8]); }); }); + +describe("Factory のオーガナイザーの経路(#2439 の AC 9・11、I66)", () => { + it("一覧と履歴は GET で読め、止まっていない配布を解く操作は 409、承認の依頼へ答える経路は無い", async () => { + const acc = await personal("u-1"); + await register(acc); + const list = await call("GET", `/accounts/${acc}/repositories/101/approval-requests`, "u-1"); + expect(list.status).toBe(200); + expect(await list.json()).toEqual({ organizer: { current_seq: null, wait_reason: null, wait_detail: null }, items: [] }); + const history = await call("GET", `/accounts/${acc}/repositories/101/releases?limit=5`, "u-1"); + expect(await history.json()).toEqual({ items: [] }); + expect((await call("GET", `/accounts/${acc}/repositories/101/releases?limit=0`, "u-1")).status).toBe(400); + expect((await call("POST", `/accounts/${acc}/repositories/101/releases/1/resolve`, "u-1", { choice: "reject" })).status).toBe(404); + expect((await call("POST", `/accounts/${acc}/repositories/101/releases/1/resolve`, "u-1", { choice: "maybe" })).status).toBe(400); + expect([404, 405]).toContain((await call("POST", `/accounts/${acc}/repositories/101/approval-requests`, "u-1", {})).status); + }); + + it("他人のアカウントのリポジトリは 403", async () => { + const acc = await personal("u-1"); + await register(acc); + await personal("u-2"); + expect((await call("GET", `/accounts/${acc}/repositories/101/approval-requests`, "u-2")).status).toBe(403); + }); +}); diff --git a/factory/api/src/routes/factory.ts b/factory/api/src/routes/factory.ts index 0d846440f..71b41254f 100644 --- a/factory/api/src/routes/factory.ts +++ b/factory/api/src/routes/factory.ts @@ -1,25 +1,31 @@ -// 画面向けの経路(#2186・#2347)。取り込み・キーの持ち込み・課題の候補・スプリントの実行・開発ループ。 +// 画面向けの経路(#2186・#2347・#2172)。取り込み・キーの持ち込み・課題の候補・スプリントの実行・開発ループ。 // どの経路も resolveFromRequest でアカウントを解決し、他人のアカウントは #2170 の I18 と同じ 403 import type { Context, Hono } from "hono"; import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; import type { CredentialService } from "../credentials/service.ts"; import { InvalidRequestError } from "../errors.ts"; import type { GitHubPort } from "../github/port.ts"; +import type { OrganizerControl } from "../organizer/control.ts"; +import type { ProgressReader } from "../progress/read.ts"; import type { RepositoryService } from "../repositories/service.ts"; import { chooseCandidates, type CandidateModel } from "../runs/candidates.ts"; import type { RunControl } from "../runs/control.ts"; import type { LoopControl } from "../runs/loops.ts"; import type { RunsTable } from "../runs/table.ts"; import { readJson } from "./body.ts"; +import { registerSprintViews, sprintRunList } from "./sprint-views.ts"; export interface FactoryDeps { repositories: RepositoryService; credentials: CredentialService; control: RunControl; loops: LoopControl; + organizer: OrganizerControl; table: RunsTable; github: GitHubPort; candidateModel: CandidateModel; + // 進捗の記録の読み出し(スプリントの一覧と詳細。#2172) + reader: ProgressReader; } function positiveInt(raw: string | undefined): number { @@ -97,9 +103,12 @@ export function registerFactory(app: Hono, deps: () => FactoryDeps): vo app.get("/accounts/:accountId/sprint-runs", async (c) => { const ctx = await ctxOf(c); const raw = c.req.query("repository_id"); - return c.json({ sprint_runs: await deps().control.listSprintRuns(ctx, raw === undefined ? null : positiveInt(raw)) }); + return c.json({ sprint_runs: await sprintRunList(deps(), ctx, raw === undefined ? null : positiveInt(raw)) }); }); + // スプリントの詳細・工程の中身・バックログ(#2172) + registerSprintViews(app, deps); + app.post("/accounts/:accountId/sprint-runs/:sprintRunId/cancel", async (c) => { const ctx = await ctxOf(c); return c.json(await deps().control.cancelSprintRun(ctx, c.req.param("sprintRunId"))); @@ -140,4 +149,26 @@ export function registerFactory(app: Hono, deps: () => FactoryDeps): vo const ctx = await ctxOf(c); return c.json(await deps().loops.answer(ctx, c.req.param("loopId"), await readJson(c))); }); + + // Factory のオーガナイザー(openapi/organizer.yaml。#2439)。一覧と履歴は読むだけで、承認を確定する経路は持たない(I66) + app.get("/accounts/:accountId/repositories/:repositoryId/approval-requests", async (c) => { + const ctx = await ctxOf(c); + return c.json(await deps().organizer.list(ctx, positiveInt(c.req.param("repositoryId")))); + }); + + app.get("/accounts/:accountId/repositories/:repositoryId/releases", async (c) => { + const ctx = await ctxOf(c); + const raw = c.req.query("limit"); + const limit = raw === undefined ? 20 : Number(raw); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new InvalidRequestError("limit は 1〜100 の整数です"); + return c.json(await deps().organizer.history(ctx, positiveInt(c.req.param("repositoryId")), limit)); + }); + + // 止まった配布を解く。画面の経路は Cognito の利用者の token だけを受ける(承認ゲートではなく失敗の扱い。決定 11) + app.post("/accounts/:accountId/repositories/:repositoryId/releases/:seq/resolve", async (c) => { + const ctx = await ctxOf(c); + const seq = Number(c.req.param("seq")); + if (!Number.isInteger(seq) || seq < 1) throw new InvalidRequestError("まとめた配布の連番の形ではありません"); + return c.json(await deps().organizer.resolve(ctx, positiveInt(c.req.param("repositoryId")), seq, await readJson(c)), 202); + }); } diff --git a/factory/api/src/routes/sprint-views.test.ts b/factory/api/src/routes/sprint-views.test.ts new file mode 100644 index 000000000..241ae60a9 --- /dev/null +++ b/factory/api/src/routes/sprint-views.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ProgressEvent } from "@ndf-factory/contracts"; +import { AccountService } from "../accounts/service.ts"; +import { MemoryAccountStore } from "../accounts/memory-store.ts"; +import { CredentialService, type SecretsPort } from "../credentials/service.ts"; +import { createApiApp } from "../handlers/api.ts"; +import type { GitHubPort } from "../github/port.ts"; +import { appendEvent } from "../progress/append.ts"; +import { memoryProgressReader } from "../progress/read.ts"; +import { RepositoryService } from "../repositories/service.ts"; +import { LoopControl } from "../runs/loops.ts"; +import type { SprintRun } from "../runs/model.ts"; +import { createRunsHarness, type RunsHarness } from "../testing/runs.ts"; +import { createTokenKit, type TokenKit } from "../testing/tokens.ts"; +import type { TestResponse } from "../testing/app.ts"; +import type { FactoryDeps } from "./factory.ts"; + +const noSecrets: SecretsPort = { create: async () => "arn", put: async () => undefined, delete: async () => undefined }; + +let h: RunsHarness; +let tokens: TokenKit; +let app: ReturnType; +let githubCalls: string[]; +let factory: FactoryDeps; + +async function call(path: string, user = "u-1"): Promise { + return (await app.request(path, { headers: { Authorization: await tokens.bearer(user) } })) as TestResponse; +} + +async function personal(user: string): Promise { + return ((await (await call("/me", user)).json()) as { personal_account_id: string }).personal_account_id; +} + +function runOf(accountId: string, patch: Partial = {}): SprintRun { + return { + sprintRunId: "sr-1", + accountId, + repositoryId: 101, + repo: "org-a/repo-a", + state: "running", + issues: [11, 12], + ndfVersion: "10.27.15", + executionMode: "byok", + modelCredentialSecretArn: null, + currentJobId: "job-1", + jobCount: 1, + cancelRequested: false, + failureReason: null, + createdBy: "u-1", + createdAt: "2026-11-04T00:00:00.000Z", + updatedAt: "2026-11-04T00:00:00.000Z", + ...patch, + }; +} + +let seq = 0; +async function emit(sprintRunId: string, type: string, payload: Record, at?: string) { + seq += 1; + const event = { + sprint_run_id: sprintRunId, + idempotency_key: `job-1:${seq}`, + type, + at: at ?? new Date(Date.UTC(2026, 10, 4, 0, 0, seq)).toISOString(), + source: "runner", + job_id: "job-1", + schema_version: 1, + payload, + } as unknown as ProgressEvent; + await appendEvent(h.progress, event); + return event.idempotency_key; +} + +beforeEach(async () => { + h = await createRunsHarness(); + tokens = await createTokenKit(); + seq = 0; + githubCalls = []; + // 偽の GitHub の口の呼び出しを数える(I13) + const github = new Proxy(h.github, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" + ? (...args: unknown[]) => { + githubCalls.push(String(prop)); + return (value as (...a: unknown[]) => unknown).apply(target, args); + } + : value; + }, + }) as GitHubPort; + const store = new MemoryAccountStore(); + let n = 0; + const accounts = new AccountService({ store, newId: () => `00000000-0000-4000-8000-${String(++n).padStart(12, "0")}` }); + const reader = memoryProgressReader(h.progress); + factory = { + repositories: new RepositoryService({ table: h.table, github, proofKey: async () => new Uint8Array(32) }), + credentials: new CredentialService(h.table, noSecrets), + control: h.control, + loops: new LoopControl({ table: h.table, control: h.control, reader, now: () => h.clock.now }), + organizer: h.organizer, + table: h.table, + github, + candidateModel: async () => [], + reader, + }; + app = createApiApp(() => ({ verifier: tokens.verifier, accounts }), () => undefined, () => null, () => factory); +}); + +async function seed() { + const acc = await personal("u-1"); + await h.table.putRegistration({ accountId: acc, repositoryId: 101, fullName: "org-a/repo-a", installationId: 9, registeredBy: "u-1", registeredAt: "2026-11-01T00:00:00Z" }); + h.table.runs.set("sr-1", runOf(acc)); + h.table.runs.set("sr-2", runOf(acc, { sprintRunId: "sr-2", issues: [20], state: "awaiting_approval", createdAt: "2026-11-04T01:00:00.000Z" })); + await emit("sr-1", "phase.changed", { phase: "実装", issues: [11] }); + const step = await emit("sr-1", "step.recorded", { + step: "impl", + exit_code: 0, + seconds: 5, + summary: "済んだ", + issues: [11], + output: { kind: "report", text: "## 作業の報告", truncated: false }, + }); + await emit("sr-2", "phase.changed", { phase: "準備", issues: [] }); + return { acc, step }; +} + +describe("一覧の経路(受け入れ条件 1〜3・18)", () => { + it("既存の 6 項目の名前と型を変えずに、repo・今のフェーズ・今の工程・最後の動きを足す。5 つに無い名前も 200", async () => { + const { acc } = await seed(); + const res = await call(`/accounts/${acc}/sprint-runs`); + expect(res.status).toBe(200); + const { sprint_runs } = await res.json(); + expect(sprint_runs.map((r: { sprint_run_id: string }) => r.sprint_run_id)).toEqual(["sr-2", "sr-1"]); + const row = sprint_runs[1]; + expect(row).toEqual({ + sprint_run_id: "sr-1", + repository_id: 101, + issues: [11, 12], + state: "running", + failure_reason: null, + created_at: "2026-11-04T00:00:00.000Z", + repo: "org-a/repo-a", + current_phase: "実装", + current_step: "impl", + last_event_at: "2026-11-04T00:00:02.000Z", + }); + expect(sprint_runs[0].current_phase).toBe("準備"); + }); +}); + +describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)", () => { + it("詳細は本文を返さず、工程の中身は 1 件ずつ本文を返す。どちらも GitHub の口を呼ばない", async () => { + const { acc, step } = await seed(); + const detail = await call(`/accounts/${acc}/sprint-runs/sr-1`); + expect(detail.status).toBe(200); + const body = await detail.json(); + expect(body).toMatchObject({ sprint_run_id: "sr-1", repo: "org-a/repo-a", current_phase: "実装", ndf_version: "10.27.15", job_count: 1, omitted_steps: 0 }); + expect(body.steps[0]).toMatchObject({ key: step, phase: "実装", issues: [11], has_details: true }); + expect(JSON.stringify(body)).not.toContain("## 作業の報告"); + const one = await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`); + expect(one.status).toBe(200); + expect((await one.json()).output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false }); + await call(`/accounts/${acc}/sprint-runs`); + expect(githubCalls).toEqual([]); + }); + + it("足す前の形の step.recorded(schema_version 1 のまま)も詳細に出て、本文の欄は null", async () => { + const { acc } = await seed(); + const key = await emit("sr-1", "step.recorded", { step: "pr", exit_code: 0, seconds: 1, summary: "作った" }); + const body = await (await call(`/accounts/${acc}/sprint-runs/sr-1`)).json(); + expect(body.steps.map((s: { key: string }) => s.key)).toContain(key); + const one = await (await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(key)}`)).json(); + expect([one.files, one.decisions, one.output]).toEqual([null, null, null]); + }); + + it("読み出しは事象の表と実行の表を書き換えない(I14)", async () => { + const { acc, step } = await seed(); + const before = JSON.stringify([h.progress.events, [...h.table.runs.values()]]); + await call(`/accounts/${acc}/sprint-runs`); + await call(`/accounts/${acc}/sprint-runs/sr-1`); + await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`); + await call(`/accounts/${acc}/repositories/101/backlog`); + expect(JSON.stringify([h.progress.events, [...h.table.runs.values()]])).toBe(before); + }); +}); + +describe("認可(受け入れ条件 11・I12)", () => { + it("他人のアカウント・無いアカウント・属さない実行・無い実行・無い工程・登録の無いリポジトリは、同じ本文の 403", async () => { + const { acc, step } = await seed(); + const other = await personal("u-2"); + h.table.runs.set("sr-other", runOf(other, { sprintRunId: "sr-other" })); + const paths = [ + `/accounts/${acc}/sprint-runs`, + `/accounts/${acc}/sprint-runs/sr-1`, + `/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`, + `/accounts/${acc}/repositories/101/backlog`, + ]; + const missing = "00000000-0000-4000-8000-0000000000ff"; + const cases = [ + ...paths.map((p) => [p, "u-2"] as const), + ...paths.map((p) => [p.replace(acc, missing), "u-1"] as const), + [`/accounts/${acc}/sprint-runs/sr-other`, "u-1"], + [`/accounts/${acc}/sprint-runs/sr-none`, "u-1"], + [`/accounts/${acc}/sprint-runs/sr-other/steps/${encodeURIComponent(step)}`, "u-1"], + [`/accounts/${acc}/sprint-runs/sr-1/steps/job-1%3A999`, "u-1"], + [`/accounts/${acc}/sprint-runs/sr-2/steps/${encodeURIComponent(step)}`, "u-1"], + [`/accounts/${acc}/repositories/999/backlog`, "u-1"], + ] as const; + const bodies = new Set(); + for (const [path, user] of cases) { + const res = await call(path, user); + expect(res.status, path).toBe(403); + bodies.add(await res.text()); + } + expect([...bodies]).toEqual([JSON.stringify({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } })]); + }); +}); + +describe("バックログの経路(受け入れ条件 15・16・I13)", () => { + it("順位の表と、そのリポジトリの棚卸しの記録を新しい順に返し、GitHub は順位の表の読み出しだけを呼ぶ", async () => { + const { acc } = await seed(); + h.github.rankings.set("org-a/repo-a", { milestone: "25 見本", rows: [{ number: 5, title: "先に" }, { number: 3, title: "後に" }] }); + await emit("sr-1", "step.recorded", { step: "refine", exit_code: 0, seconds: 3, summary: "古い棚卸し" }, "2026-11-02T00:00:00.000Z"); + await emit("sr-2", "step.recorded", { step: "refine", exit_code: 1, seconds: 3, summary: "新しい棚卸し" }, "2026-11-03T00:00:00.000Z"); + const res = await call(`/accounts/${acc}/repositories/101/backlog`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.repo).toBe("org-a/repo-a"); + expect(body.ranking).toEqual({ + milestone: "25 見本", + rows: [ + { rank: 1, number: 5, title: "先に" }, + { rank: 2, number: 3, title: "後に" }, + ], + }); + expect(body.refinements.map((r: { summary: string }) => r.summary)).toEqual(["新しい棚卸し", "古い棚卸し"]); + expect(githubCalls).toEqual(["findRankingTable"]); + }); + + it("順位の表が無ければ ranking は null、GitHub の口が失敗すれば 503", async () => { + const { acc } = await seed(); + const none = await (await call(`/accounts/${acc}/repositories/101/backlog`)).json(); + expect(none.ranking).toBeNull(); + expect(none.refinements).toEqual([]); + h.github.findRankingTable = async () => { + throw new Error("GitHub 502"); + }; + expect((await call(`/accounts/${acc}/repositories/101/backlog`)).status).toBe(503); + }); +}); diff --git a/factory/api/src/routes/sprint-views.ts b/factory/api/src/routes/sprint-views.ts new file mode 100644 index 000000000..95407222d --- /dev/null +++ b/factory/api/src/routes/sprint-views.ts @@ -0,0 +1,121 @@ +// スプリントの一覧・詳細・工程の中身・バックログの読み出しの経路(#2172)。読むだけで、事象の表・実行の表・GitHub へ書かない(I14)。 +// アカウントは resolveFromRequest で解決し、他人のアカウント・属さない実行・無い実行・無い工程・登録の無いリポジトリは +// どれも同じ本文の 403(I12)。一覧・詳細・工程の中身は GitHub の口を呼ばず、バックログは順位の表の読み出しだけを呼ぶ(I13) +import type { Context, Hono } from "hono"; +import type { AccountContext } from "../accounts/model.ts"; +import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; +import { ForbiddenError, InvalidRequestError, ServiceUnavailableError } from "../errors.ts"; +import type { RankingTable } from "../github/port.ts"; +import type { SprintRun } from "../runs/model.ts"; +import { DETAIL_STEP_LIMIT, deriveDetail, deriveRefinements, deriveStepDetail, listRowExtras, stepRows } from "../views/derive.ts"; +import type { FactoryDeps } from "./factory.ts"; + +// 一覧の行ごとの読み出しを並べる数の上限 +const LIST_CONCURRENCY = 10; +// バックログで棚卸しの記録を読む実行の数(新しい順) +const BACKLOG_RUNS = 50; + +export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]!); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +} + +function positiveInt(raw: string | undefined): number { + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません"); + return n; +} + +// 保存の失敗は 503(応答に中身を出さない) +async function readOrUnavailable(read: () => Promise): Promise { + try { + return await read(); + } catch { + throw new ServiceUnavailableError("progress_unavailable"); + } +} + +// アカウントの実行だけを返す。無い実行も他人の実行も同じ 403(getRun 1 回の後に返す) +async function ownRun(deps: FactoryDeps, ctx: AccountContext, sprintRunId: string): Promise { + const run = await deps.table.getRun(sprintRunId); + if (!run || run.accountId !== ctx.accountId) throw new ForbiddenError(); + return run; +} + +// 一覧の行(既存の 6 項目に 4 項目を足す)。既存の経路の絞り込みと並びは実行の制御が持つ。repo は一覧で読んだ実行から取り、 +// 行ごとに実行を読み直さない +export async function sprintRunList(deps: FactoryDeps, ctx: AccountContext, repositoryId: number | null) { + const runs = await deps.control.listSprintRuns(ctx, repositoryId); + return mapLimit(runs, LIST_CONCURRENCY, async (run) => { + const latest = await readOrUnavailable(() => deps.reader.latestOfRun(run.sprintRunId)); + return { + sprint_run_id: run.sprintRunId, + repository_id: run.repositoryId, + issues: run.issues, + state: run.state, + failure_reason: run.failureReason, + created_at: run.createdAt, + ...listRowExtras({ repo: run.repo }, latest), + }; + }); +} + +export function registerSprintViews(app: Hono, deps: () => FactoryDeps): void { + const ctxOf = (c: Context) => resolveFromRequest(c, c.req.param("accountId")); + + app.get("/accounts/:accountId/sprint-runs/:sprintRunId", async (c) => { + const ctx = await ctxOf(c); + const d = deps(); + const run = await ownRun(d, ctx, c.req.param("sprintRunId")); + const { events, omittedSteps } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)); + return c.json(deriveDetail(run, events, omittedSteps)); + }); + + app.get("/accounts/:accountId/sprint-runs/:sprintRunId/steps/:stepKey", async (c) => { + const ctx = await ctxOf(c); + const d = deps(); + const run = await ownRun(d, ctx, c.req.param("sprintRunId")); + const key = c.req.param("stepKey"); + const [record, { events }] = await readOrUnavailable(() => Promise.all([d.reader.getStep(run.sprintRunId, key), d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)])); + if (!record) throw new ForbiddenError(); + // 上限の外の古い工程は、その工程だけの列から行を作る(フェーズは payload.phase だけ・費用は結べない) + const row = stepRows(events).find((r) => r.key === key) ?? stepRows([record])[0]!; + return c.json(deriveStepDetail(run, row, record)); + }); + + app.get("/accounts/:accountId/repositories/:repositoryId/backlog", async (c) => { + const ctx = await ctxOf(c); + const d = deps(); + const repositoryId = positiveInt(c.req.param("repositoryId")); + const registration = await d.table.getRegistration(ctx.accountId, repositoryId); + if (!registration) throw new ForbiddenError(); + const runs = (await d.table.listRuns(ctx.accountId, 1000)).filter((r) => r.repositoryId === repositoryId).slice(0, BACKLOG_RUNS); + const [ranking, perRun] = await Promise.all([ + d.github.findRankingTable(registration.installationId, repositoryId, registration.fullName).catch((): never => { + throw new ServiceUnavailableError("github_unavailable"); + }), + readOrUnavailable(() => + mapLimit(runs, LIST_CONCURRENCY, async (r) => ({ sprintRunId: r.sprintRunId, steps: await d.reader.listStepsNamed(r.sprintRunId, "refine") })), + ), + ]); + return c.json({ + repository_id: repositoryId, + repo: registration.fullName, + ranking: rankingOf(ranking), + refinements: deriveRefinements(perRun), + }); + }); +} + +function rankingOf(table: RankingTable | null) { + if (!table) return null; + return { milestone: table.milestone, rows: table.rows.map((r, i) => ({ rank: i + 1, number: r.number, title: r.title })) }; +} diff --git a/factory/api/src/runs/control.test.ts b/factory/api/src/runs/control.test.ts index 6252ca9d4..ec656f7ae 100644 --- a/factory/api/src/runs/control.test.ts +++ b/factory/api/src/runs/control.test.ts @@ -188,7 +188,7 @@ describe("停止の知らせ・再開(受け入れ条件 15・19・20、I8・I await finishJob(jobId, sprintRunId, "gate"); expect((await h.table.getRun(sprintRunId))!).toMatchObject({ state: "awaiting_approval", currentJobId: null }); expect(h.platform.tokens.size).toBe(0); - const { jobId: next } = await h.control.requestResume({ sprintRunId, approvalRequestId: "ar-1" }); + const next = (await h.control.requestResume({ sprintRunId, approvalRequestId: "ar-1" })).jobId!; const instruction = h.platform.taskOf(next)![1].instruction; expect(instruction).toMatchObject({ START: "resume", APPROVAL_REQUEST_ID: "ar-1", NDF_VERSION: "10.26.1" }); expect(instruction.ISSUES).toBeUndefined(); diff --git a/factory/api/src/runs/control.ts b/factory/api/src/runs/control.ts index 853c713c2..edef472e5 100644 --- a/factory/api/src/runs/control.ts +++ b/factory/api/src/runs/control.ts @@ -7,11 +7,13 @@ import { judgeLimits } from "../accounts/limits.ts"; import type { AccountContext } from "../accounts/model.ts"; import { ConflictError, ForbiddenError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts"; import type { GitHubPort } from "../github/port.ts"; +import type { OrganizerWrite, OrgRequest } from "../organizer/model.ts"; import { finishedKey } from "../progress/authorize.ts"; -import type { ProgressReader } from "../progress/read.ts"; +import type { ProgressReader, ReleaseEvents } from "../progress/read.ts"; import { signRunJobToken, type RunJobSigningKey } from "../progress/run-job-token.ts"; import { loopWriteAfterRun, loopWriteOnResume, type RunOutcome } from "./loops.ts"; import { + isReleaseRun, isTerminalJob, isTerminalLoop, isTerminalRun, @@ -20,7 +22,9 @@ import { type FailureReason, type LoopJobInput, type LoopWrite, + type FinishReason, type Registration, + type ReleaseStep, type RunJob, type SprintRun, } from "./model.ts"; @@ -98,6 +102,16 @@ export interface LoopHooks { sweepLoops(): Promise; } +// Factory のオーガナイザーが渡す口(#2439)。循環を避けるため、組み立ての後に差し込む +export interface OrganizerHooks { + newRequestRows(run: SprintRun, events: ReleaseEvents): Promise; + tick(accountId: string, repositoryId: number): Promise; + decide(run: SprintRun, approvalRequestId: string): Promise; + // 配布の実行のまとめた配布が main へマージ済みか(取り消しを受けても止まった配布として残すかを決める。I63) + releaseMerged(run: SprintRun): Promise; + sweep(): Promise; +} + export type ResumeRejectedCode = "not_found" | "not_awaiting_approval" | "sprint_run_busy"; export class ResumeRejectedError extends Error { @@ -112,7 +126,7 @@ export interface StartInput { issues: unknown; } -// 手元のランナーの申告(#2189 の登録と心拍。契約の local-runner-declaration) +// 手元のマシンの申告(#2189 の登録と心拍。契約の local-runner-declaration) export interface LocalRunner { runnerId: string; maxJobs?: number; @@ -140,6 +154,7 @@ export class RunControl { private readonly newId: () => string; private readonly log: (entry: Record) => void; private hooks: LoopHooks | null = null; + private organizer: OrganizerHooks | null = null; constructor(private readonly deps: RunControlDeps) { this.now = deps.now ?? (() => new Date()); @@ -151,6 +166,10 @@ export class RunControl { this.hooks = hooks; } + setOrganizer(organizer: OrganizerHooks): void { + this.organizer = organizer; + } + get config(): RunControlConfig { return this.deps.config; } @@ -303,6 +322,10 @@ export class RunControl { return { ...run, legacy }; } if (result === "loop_changed") return "loop_changed"; + // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62) + if (result === "release_running") { + throw new ConflictError("parallel_unsupported_version", "このリポジトリでは、並行を許さない NDF の版の実行と並べて動かせません"); + } if (result === "issue_in_use") { const held = new Set((await table.listIssueShares(ctx.accountId, registration.repositoryId)).map((s) => s.number)); throw new ConflictError("issue_in_use", "課題は同じリポジトリの別のスプリントの実行が持っています", { numbers: opts.issues.filter((n) => held.has(n)) }); @@ -388,6 +411,11 @@ export class RunControl { }; if (job.start === "new") instruction.ISSUES = run.issues.join(","); if (job.approvalRequestId) instruction.APPROVAL_REQUEST_ID = job.approvalRequestId; + if (job.start === "release") { + instruction.RELEASE_SEQ = String(run.releaseSeq ?? 1); + instruction.RELEASE_STEP = job.releaseStep ?? "present"; + } + if (job.releaseRequestId) instruction.RELEASE_REQUEST_ID = job.releaseRequestId; if (run.executionMode === "byok" && run.modelCredentialSecretArn) instruction.MODEL_CREDENTIAL_SECRET_ARN = run.modelCredentialSecretArn; const loop = job.loopInput; if (loop) { @@ -409,6 +437,42 @@ export class RunControl { return signRunJobToken({ jobId: job.jobId, sprintRunId: run.sprintRunId, repo: run.repo, expiresAt }, await this.deps.signingKey()); } + // Factory のオーガナイザーが配布の実行を起こす(組んだ直後と、起動の前で止まったときの起こし直し。#2439) + launchJob(run: SprintRun, job: RunJob): Promise { + return this.launch(run, job); + } + + // まとめた配布の配布の実行とその最初のジョブ(#2439 の I57・I64)。実行の枠を持たず、使える量の判定にかけない。 + // 形態・キー・始めた利用者は範囲の実行(base)から写す + buildReleaseRun(base: SprintRun, seq: number, ndfVersion: string): { run: SprintRun; job: RunJob } { + const now = this.iso(); + const run: SprintRun = { + sprintRunId: this.newId(), + accountId: base.accountId, + repositoryId: base.repositoryId, + repo: base.repo, + state: "starting", + issues: [], + ndfVersion, + executionMode: base.executionMode, + modelCredentialSecretArn: base.modelCredentialSecretArn, + currentJobId: null, + jobCount: 1, + cancelRequested: false, + failureReason: null, + createdBy: base.createdBy, + createdAt: now, + updatedAt: now, + legacy: false, + purpose: "release", + releaseSeq: seq, + }; + const job = this.newJob(run, 1, "release", null); + job.releaseStep = "present"; + run.currentJobId = job.jobId; + return { run, job }; + } + // 起動。local は渡す口へ積むだけ(#2189 が claimLocalRunJob で受け取る)。起動できなければジョブと実行を failed にする private async launch(run: SprintRun, job: RunJob): Promise { if (job.runnerKind === "local") return; @@ -471,17 +535,40 @@ export class RunControl { if (!run) return; const finished = await this.ensureFinished(run, job, reason); const result = finished.payload.result; - const finishReason = finished.payload.reason ?? null; + const rawReason = finished.payload.reason ?? null; + // release_requested は gate の理由(手動の実行が配布の依頼の答えを待つ。#2439 の I65)で、ジョブの終わりの理由には写さない + const finishReason: FinishReason | null = rawReason === "release_requested" ? null : rawReason; const now = this.iso(); + // 配布の依頼と配布の実行の事象(#2439)。受けた依頼の行は実行の移りと同じトランザクションで書く(I59) + const releaseEvents = this.organizer ? await this.deps.reader.findReleaseEvents(run.sprintRunId, job.jobId) : null; + const requests = releaseEvents && this.organizer && !isReleaseRun(run) ? await this.organizer.newRequestRows(run, releaseEvents) : []; + const lastRequest = releaseEvents?.requested.at(-1)?.request_id ?? null; // 実行と枠を先に移し、ジョブを終わりの状態にするのは最後にする。途中で止まってもジョブは見回りの対象に残り、 // 次の見回りか停止の知らせの再送が同じ結果でやり直す(実行の移し替えは条件つきで、2 度目は何もしない) + // main へマージした後の配布の実行は取り消しを受けても終わらせず、止まった配布として「続きから流す」で仕上げられるようにする(I63) + const keepRelease = run.cancelRequested && isReleaseRun(run) && this.organizer !== null && (await this.organizer.releaseMerged(run)); if (run.currentJobId === job.jobId) { - if (run.cancelRequested) { + if (run.cancelRequested && !keepRelease) { const lw = await this.loopWriteFor(run, job.jobId, { runState: "cancelled", failureReason: null, finishReason }); - if (await table.finishRun(run.sprintRunId, "cancelled", null, now, { finishReason, loop: lw?.write })) await this.afterRunMoved(run, lw?.write, true); + if (await table.finishRun(run.sprintRunId, "cancelled", null, now, { finishReason, loop: lw?.write, requests })) await this.afterRunMoved(run, lw?.write, true); + } else if (isReleaseRun(run)) { + // 配布の実行は、配布済みで done、承認ゲートか判断で承認待ち、それ以外は止まった配布として承認待ち(I65・決定 11) + if (releaseEvents?.settled?.result === "released") { + await table.finishRun(run.sprintRunId, "done", null, now); + } else if (result === "gate" && !keepRelease) { + await table.awaitApproval(run.sprintRunId, job.jobId, now); + } else { + // 取り消しを受けた実行は取り消しの印を外す(続けたジョブの終わりで取り消しにしない) + await table.awaitApproval(run.sprintRunId, job.jobId, now, { awaitingReason: "release_stalled", ...(keepRelease ? { clearCancel: true } : {}) }); + } } else if (result === "gate") { const lw = await this.loopWriteFor(run, job.jobId, { runState: "awaiting_approval", failureReason: null, finishReason }); - await table.awaitApproval(run.sprintRunId, job.jobId, now, { loop: lw?.write }); + const waitsRelease = rawReason === "release_requested" && !run.loopId && lastRequest !== null; + await table.awaitApproval(run.sprintRunId, job.jobId, now, { + loop: lw?.write, + requests, + ...(waitsRelease ? { awaitingReason: "release_requested" as const, releaseRequestId: lastRequest } : {}), + }); } else if (result === "stopped" && finishReason === "usage_limit" && run.executionMode === "local") { // local の利用上限はリセット時刻の後に同じ周回を続ける(I54。ほかの形態は failed として扱う) const resumeAt = this.resumeAtOf(finished); @@ -490,9 +577,15 @@ export class RunControl { } else { const { state, failure } = runEndOf(result, finishReason, job, finished, reason); const lw = await this.loopWriteFor(run, job.jobId, { runState: state, failureReason: failure, finishReason }); - if (await table.finishRun(run.sprintRunId, state, failure, now, { finishReason, loop: lw?.write })) await this.afterRunMoved(run, lw?.write, true); + if (await table.finishRun(run.sprintRunId, state, failure, now, { finishReason, loop: lw?.write, requests })) await this.afterRunMoved(run, lw?.write, true); } } + // 依頼を受けたか配布の実行が動いたら、配布のまとめを進める。落ちてよい(見回りが続ける) + if (this.organizer && (isReleaseRun(run) || requests.length > 0)) { + await this.organizer.tick(run.accountId, run.repositoryId).catch((err: Error) => + this.log({ event: "organizer_tick_failed", sprint_run_id: run.sprintRunId, error: err.name }), + ); + } const updated = await table.updateJob(job.jobId, { state: result, result, finishedAt: now }, ["pending", "running"]); if (!updated) return; if (job.tokenSecretArn) { @@ -560,6 +653,7 @@ export class RunControl { await this.resumeAfterReset(run).catch((err: Error) => this.log({ event: "reset_resume_failed", sprint_run_id: run.sprintRunId, error: err.name })); } await this.hooks?.sweepLoops().catch((err: Error) => this.log({ event: "loop_sweep_failed", error: err.name })); + await this.organizer?.sweep().catch((err: Error) => this.log({ event: "organizer_sweep_failed", error: err.name })); } // リセット待ちの実行に、承認の依頼の識別子の無い resume のジョブを作る(決定 7)。時刻の前は何もしない @@ -589,8 +683,9 @@ export class RunControl { return loopInput(loop, exclude); } - // 再開の要求(#2173)。承認待ちで動くジョブが無いときだけ resume のジョブを作り起動する(I8・I9) - async requestResume(input: { sprintRunId: string; approvalRequestId: string }): Promise<{ jobId: string }> { + // 再開の要求(#2173)。承認待ちで動くジョブが無いときだけ resume のジョブを作り起動する(I8・I9)。 + // 配布の実行は、配布のまとめが答えから再開の手を決める。差し戻しはジョブを起こさず jobId が null(#2439) + async requestResume(input: { sprintRunId: string; approvalRequestId: string }): Promise<{ jobId: string | null }> { const { table, reader } = this.deps; const run = await table.getRun(input.sprintRunId); if (!run) throw new ResumeRejectedError("not_found"); @@ -602,8 +697,17 @@ export class RunControl { throw new ResumeRejectedError("not_awaiting_approval"); } if (run.currentJobId !== null) throw new ResumeRejectedError("sprint_run_busy"); + // 配布の依頼の答えを待つ実行と止まった配布は、承認の依頼の答えでは再開しない(配布のまとめが再開する) + if (run.awaitingReason) throw new ResumeRejectedError("not_awaiting_approval"); const seq = run.jobCount + 1; - const job = this.newJob(run, seq, "resume", input.approvalRequestId); + let releaseStep: ReleaseStep | null = null; + if (isReleaseRun(run)) { + if (!this.organizer) throw new ServiceUnavailableError("sprint_run_unavailable"); + releaseStep = await this.organizer.decide(run, input.approvalRequestId); + if (releaseStep === null) return { jobId: null }; + } + const job = this.newJob(run, seq, releaseStep ? "release" : "resume", input.approvalRequestId); + if (releaseStep) job.releaseStep = releaseStep; job.loopInput = await this.resumeLoopInput(run); // ループは答えで running へ戻る(同じトランザクション) const loop = run.loopId ? await table.getLoop(run.accountId, run.loopId) : null; @@ -616,12 +720,42 @@ export class RunControl { return { jobId: job.jobId }; } + // 手動の実行の配布の答えの再開(#2439)。実行がこの依頼を待つときだけ、条件つきの書き込みで resume のジョブを作る。 + // 待っていなければ not_waiting(取り消し・終わり・別の理由や別の依頼での承認待ち)、ジョブが動いていれば busy + async resumeForRelease(sprintRunId: string, requestId: string): Promise<"resumed" | "busy" | "not_waiting"> { + const { table } = this.deps; + const run = await table.getRun(sprintRunId); + const waits = (r: SprintRun | null) => r !== null && r.state === "awaiting_approval" && r.awaitingReason === "release_requested" && r.releaseRequestId === requestId; + if (!waits(run)) return run && !isTerminalRun(run.state) && run.currentJobId !== null ? "busy" : "not_waiting"; + const job = this.newJob(run!, run!.jobCount + 1, "resume", null); + job.releaseRequestId = requestId; + if (!(await table.addResumeJob(job, this.iso(), { expectAwaiting: { reason: "release_requested", releaseRequestId: requestId } }))) { + return waits(await table.getRun(sprintRunId)) ? "busy" : "not_waiting"; + } + await this.launch(run!, job); + return "resumed"; + } + + // 止まった配布の実行を「続きから流す」(#2439 の F7)。止まった配布の承認待ちのときだけ書く。配布の実行は作り直さない(I57) + // organizer を渡すと、まとめた配布の状態の戻しを同じトランザクションで書く(間に見回りが止まった印を書き戻さない) + async resumeRelease(run: SprintRun, step: ReleaseStep, organizer?: OrganizerWrite): Promise { + const job = this.newJob(run, run.jobCount + 1, "release", null); + job.releaseStep = step; + if (!(await this.deps.table.addResumeJob(job, this.iso(), { expectAwaiting: { reason: "release_stalled" }, ...(organizer ? { organizer } : {}) }))) return false; + await this.launch(run, job); + return true; + } + // 取り消し。動くジョブがあれば止め、停止の後に cancelled へ移す。無ければその場で cancelled にする async cancelSprintRun(ctx: AccountContext, sprintRunId: string): Promise<{ state: "cancelled" | "running" }> { const { table, platform } = this.deps; const run = await table.getRun(sprintRunId); if (!run || run.accountId !== ctx.accountId) throw new NotFoundError("sprint_run_not_found", "スプリントの実行が見つかりません"); if (isTerminalRun(run.state)) throw new ConflictError("already_finished", "このスプリントの実行は終わっています"); + // main へマージした後の配布の実行は取り消さない(取り消すと配布の仕上げも次の配布も進まない。I63) + if (run.currentJobId === null && isReleaseRun(run) && this.organizer && (await this.organizer.releaseMerged(run))) { + throw new ConflictError("release_after_main", "main へマージした配布は取り消せません。止まった配布は続きから流してください"); + } if (run.currentJobId === null) { const lw = await this.loopWriteFor(run, null, { runState: "cancelled", failureReason: null, finishReason: null }); if (await table.finishRun(run.sprintRunId, "cancelled", null, this.iso(), { loop: lw?.write })) { @@ -641,23 +775,14 @@ export class RunControl { return { state: "running" }; } - async listSprintRuns(ctx: AccountContext, repositoryId: number | null) { + // 一覧に載せる実行(絞り込みと並び)。行の形は一覧の経路(routes/sprint-views.ts)が作る + async listSprintRuns(ctx: AccountContext, repositoryId: number | null): Promise { const runs = await this.deps.table.listRuns(ctx.accountId, repositoryId === null ? 50 : 1000); - return runs - .filter((r) => repositoryId === null || r.repositoryId === repositoryId) - .slice(0, 50) - .map((r) => ({ - sprint_run_id: r.sprintRunId, - repository_id: r.repositoryId, - issues: r.issues, - state: r.state, - failure_reason: r.failureReason, - created_at: r.createdAt, - })); + return runs.filter((r) => repositoryId === null || r.repositoryId === repositoryId).slice(0, 50); } // 形態 local の渡す口(#2189)。最も古い pending の local のジョブを running にし、指示と token を 1 回だけ返す。 - // ランナーの申告があれば、そのランナーの枠が同時のジョブの数に達している間は渡さない(I50。申告が無ければ 1) + // 手元のマシンの申告があれば、そのマシンの枠が同時のジョブの数に達している間は渡さない(I50。申告が無ければ 1) async claimLocalRunJob(accountId: string, runner?: LocalRunner): Promise<{ instruction: RunJobInstruction; token: string } | null> { const { table } = this.deps; for (const job of await table.listActiveJobs()) { @@ -684,7 +809,7 @@ export class RunControl { return null; } - // 手元のランナーの終わりか心拍の切れ(#2189)。停止の知らせと同じ処理 + // 手元のマシンのジョブの終わりか心拍の切れ(#2189)。停止の知らせと同じ処理 async reportLocalRunJobEnded(jobId: string): Promise { const job = await this.deps.table.getJob(jobId); if (!job || job.runnerKind !== "local" || isTerminalJob(job.state)) return; diff --git a/factory/api/src/runs/deps.ts b/factory/api/src/runs/deps.ts index f460ec598..750f4118d 100644 --- a/factory/api/src/runs/deps.ts +++ b/factory/api/src/runs/deps.ts @@ -4,7 +4,8 @@ import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-sec import type { JWK } from "jose"; import { CredentialService, AwsSecrets } from "../credentials/service.ts"; import { GitHubApp } from "../github/app.ts"; -import { appendControlEventTo } from "../progress/append.ts"; +import { OrganizerControl } from "../organizer/control.ts"; +import { appendControlEventTo, appendReleaseAnsweredTo } from "../progress/append.ts"; import { DynamoProgressReader } from "../progress/read.ts"; import type { RunJobSigningKey } from "../progress/run-job-token.ts"; import { progressStoreFromEnv } from "../handlers/ingest.ts"; @@ -86,7 +87,7 @@ export function factoryDepsFromEnv(): FactoryDeps & { platform: EcsRunnerPlatfor return { kid: parsed.kid, privateKey: parsed.jwk }; }; const reader = new DynamoProgressReader(requireEnv("PROGRESS_EVENTS_TABLE_NAME"), requireEnv("PROGRESS_IDEMPOTENCY_TABLE_NAME")); - // 自動で起こすもの(周回・リセット時刻の後の再開)のたびに、始めた利用者の今のプラン・形態・席を引き直す(表の名前がある処理だけ) + // 自動で起こすもの(周回・リセット時刻の後の再開)のたびに、始めた利用者の今の料金プラン・形態・席を引き直す(表の名前がある処理だけ) const accountsTable = process.env.ACCOUNTS_TABLE_NAME; const resolveContext = accountsTable ? accountContextResolver(new AccountService({ store: new DynamoAccountStore(accountsTable) })) : undefined; const control = new RunControl({ @@ -103,6 +104,9 @@ export function factoryDepsFromEnv(): FactoryDeps & { platform: EcsRunnerPlatfor // ループの制御は実行の制御の停止の後の処理と見回りから呼ばれる(常駐のプロセスを持たない。#2347 の決定 1) const loops = new LoopControl({ table, control, reader, resolveContext }); control.setLoopHooks(loops.hooks()); + // Factory のオーガナイザーも同じく、停止の後の処理・再開の要求・見回りから呼ばれる(#2439 の決定 2) + const organizer = new OrganizerControl({ table, github, reader, control, appendAnswered: (e) => appendReleaseAnsweredTo(progressStoreFromEnv(), e) }); + control.setOrganizer(organizer); // 候補の証明の鍵は App のクライアントの秘密から用途を分けて作る const proofKey = async () => new Uint8Array(createHash("sha256").update(`ndf-factory:repositories:${await clientSecret()}`).digest()); cached = { @@ -110,9 +114,11 @@ export function factoryDepsFromEnv(): FactoryDeps & { platform: EcsRunnerPlatfor credentials: new CredentialService(table, new AwsSecrets(sm)), control, loops, + organizer, table, github, candidateModel: bedrockCandidateModel(requireEnv("CANDIDATE_MODEL_ID")), + reader, platform, }; return cached; diff --git a/factory/api/src/runs/dynamo-table.ts b/factory/api/src/runs/dynamo-table.ts index 68119f9c4..8a96af074 100644 --- a/factory/api/src/runs/dynamo-table.ts +++ b/factory/api/src/runs/dynamo-table.ts @@ -1,5 +1,5 @@ // 実行の表の本物(DynamoDB の 1 つの表 RUNS_TABLE_NAME)。行の形は #2186・#2347 の設計の「データ構造」。 -// 枠と実行の増減は 1 回のトランザクションで書き、条件で失敗したら書かない(I34)。開発ループ・持ち分・手元のランナーの枠も +// 枠と実行の増減は 1 回のトランザクションで書き、条件で失敗したら書かない(I34)。開発ループ・持ち分・手元のマシンの枠も // 同じトランザクションで書く(#2347 の決定 2・11・12) import { DynamoDBClient, TransactionCanceledException } from "@aws-sdk/client-dynamodb"; import { @@ -12,6 +12,7 @@ import { TransactWriteCommand, UpdateCommand, } from "@aws-sdk/lib-dynamodb"; +import type { OrganizerRoot, OrganizerWrite, OrgLog, OrgRequest, ReleaseAnswer, ReleaseBatch } from "../organizer/model.ts"; import type { CreateRunGuards, CreateRunResult, @@ -26,7 +27,7 @@ import type { SprintRun, } from "./model.ts"; import { isTerminalJob, milestoneHash } from "./model.ts"; -import type { RunsTable, RunTransitionExtras } from "./table.ts"; +import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts"; export const GSI1 = "gsi1"; const ACTIVE_JOBS = "ACTIVE_JOBS"; @@ -46,6 +47,12 @@ const runnerSk = (id: string) => `RUNNER#${id}`; const WAITING_LOOPS = "WAITING_LOOPS"; const AWAITING_RESET = "AWAITING_RESET"; const MIGRATION = { pk: "MIGRATION", sk: "MIGRATED#2347" }; +// Factory のオーガナイザー(#2439)。ORGANIZER_ACTIVE は流れている根と届け終えていない依頼だけが載る gsi1 の疎な鍵 +const orgSk = (r: number) => `ORG#${r}`; +const orgReqSk = (r: number, id: string) => `ORGREQ#${r}#${id}`; +const orgBatchSk = (r: number, seq: number) => `ORGBATCH#${r}#${String(seq).padStart(6, "0")}`; +const orgLogSk = (log: OrgLog) => `ORGLOG#${log.repositoryId}#${log.at}#${log.kind}#${Math.random().toString(16).slice(2, 10).padEnd(8, "0")}`; +const ORGANIZER_ACTIVE = "ORGANIZER_ACTIVE"; const SLOT_RETRIES = 3; // トランザクションの何番目の項目が条件で落ちたか @@ -171,8 +178,13 @@ export class DynamoRunsTable implements RunsTable { async getSlots(accountId: string, repositoryId: number): Promise { const account = await this.get<{ count: number }>(accountPk(accountId), "ACTIVE"); - const repository = await this.get<{ count: number; legacyCount?: number }>(accountPk(accountId), repoSlotSk(repositoryId)); - return { account: account?.count ?? 0, repository: repository?.count ?? 0, legacy: repository?.legacyCount ?? 0 }; + const repository = await this.get<{ count: number; legacyCount?: number; legacySeq?: number }>(accountPk(accountId), repoSlotSk(repositoryId)); + return { + account: account?.count ?? 0, + repository: repository?.count ?? 0, + legacy: repository?.legacyCount ?? 0, + ...(repository?.legacySeq !== undefined ? { legacySeq: repository.legacySeq } : {}), + }; } private slotPut(pk: string, sk: string, expected: number) { @@ -201,8 +213,8 @@ export class DynamoRunsTable implements RunsTable { async createRun(run: SprintRun, job: RunJob, expected: Slots, guards?: CreateRunGuards): Promise { const repoSlot = this.slotPut(accountPk(run.accountId), repoSlotSk(run.repositoryId), expected.repository); if (guards?.legacy) { - // 古い版の実行は legacyCount を数える(I48) - repoSlot.Update.UpdateExpression += ", legacyCount = if_not_exists(legacyCount, :lz) + :lone"; + // 古い版の実行は legacyCount を数え、数えに入れた回数 legacySeq を 1 増やす(I48・#2439 の I62) + repoSlot.Update.UpdateExpression += ", legacyCount = if_not_exists(legacyCount, :lz) + :lone, legacySeq = if_not_exists(legacySeq, :lz) + :lone"; Object.assign(repoSlot.Update.ExpressionAttributeValues, { ":lz": 0, ":lone": 1 }); } else if (guards) { // 新しい版の実行は、古い版の実行がリポジトリに無いときだけ書く @@ -243,12 +255,25 @@ export class DynamoRunsTable implements RunsTable { if (run.loopId) { items.push({ Put: { TableName: this.tableName, Item: { pk: accountPk(run.accountId), sk: loopRunSk(run.loopId, run.sprintRunId), sprintRunId: run.sprintRunId } } }); } + // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62。オーガナイザーの根を読むだけ) + const organizerAt = items.length; + if (guards?.legacy) { + items.push({ + ConditionCheck: { + TableName: this.tableName, + Key: { pk: accountPk(run.accountId), sk: orgSk(run.repositoryId) }, + ConditionExpression: "attribute_not_exists(currentSeq) OR attribute_type(currentSeq, :nul)", + ExpressionAttributeValues: { ":nul": "NULL" }, + }, + }); + } try { await this.doc.send(new TransactWriteCommand({ TransactItems: items as never })); return "created"; } catch (err) { const failed = failedIndexes(err); if (failed.length === 0 && !isConditionFailure(err)) throw err; + if (guards?.legacy && failed.includes(organizerAt)) return "release_running"; if (failed.some((i) => i >= loopStart)) return "loop_changed"; if (failed.some((i) => i >= milestoneStart)) return "issue_in_loop"; if (failed.some((i) => i >= issueStart)) return "issue_in_use"; @@ -379,23 +404,35 @@ export class DynamoRunsTable implements RunsTable { ); } - addResumeJob(job: RunJob, now: string, opts: { from?: "awaiting_approval" | "awaiting_reset"; loop?: LoopWrite } = {}) { + addResumeJob(job: RunJob, now: string, opts: ResumeOptions = {}) { const from = opts.from ?? "awaiting_approval"; + // 承認待ちの理由(#2439)。照らす理由が無ければ理由が空のときだけ書く + const values: Record = { ":starting": "starting", ":awaiting": from, ":j": job.jobId, ":seq": job.seq, ":now": now, ":null": "NULL", ":nul": null }; + let reasonCond = "(attribute_not_exists(awaitingReason) OR attribute_type(awaitingReason, :null))"; + if (opts.expectAwaiting) { + reasonCond = "awaitingReason = :ar"; + values[":ar"] = opts.expectAwaiting.reason; + if (opts.expectAwaiting.releaseRequestId !== undefined) { + reasonCond += " AND releaseRequestId = :rr"; + values[":rr"] = opts.expectAwaiting.releaseRequestId; + } + } const items: Record[] = [ { Update: { TableName: this.tableName, Key: { pk: runPk(job.sprintRunId), sk: "RUN" }, - UpdateExpression: "SET #s = :starting, currentJobId = :j, jobCount = :seq, updatedAt = :now, resumeAt = :nul", - ConditionExpression: "#s = :awaiting AND attribute_type(currentJobId, :null)", + UpdateExpression: "SET #s = :starting, currentJobId = :j, jobCount = :seq, updatedAt = :now, resumeAt = :nul, awaitingReason = :nul, releaseRequestId = :nul", + ConditionExpression: `#s = :awaiting AND attribute_type(currentJobId, :null) AND ${reasonCond}`, ExpressionAttributeNames: { "#s": "state" }, - ExpressionAttributeValues: { ":starting": "starting", ":awaiting": from, ":j": job.jobId, ":seq": job.seq, ":now": now, ":null": "NULL", ":nul": null }, + ExpressionAttributeValues: values, }, }, { Put: { TableName: this.tableName, Item: this.jobItem(job), ConditionExpression: "attribute_not_exists(pk)" } }, ]; if (from === "awaiting_reset") items.push({ Delete: { TableName: this.tableName, Key: { pk: runPk(job.sprintRunId), sk: "RESET" } } }); if (opts.loop) items.push(...this.loopItems(opts.loop)); + if (opts.organizer) items.push(...this.organizerItems(opts.organizer)); return this.attempt(() => this.doc.send(new TransactWriteCommand({ TransactItems: items as never }))); } @@ -409,7 +446,9 @@ export class DynamoRunsTable implements RunsTable { Update: { TableName: this.tableName, Key: { pk: runPk(sprintRunId), sk: "RUN" }, - UpdateExpression: `SET #s = :awaiting, currentJobId = :null, updatedAt = :now${extras.finishReason !== undefined ? ", finishReason = :fr" : ""}`, + UpdateExpression: `SET #s = :awaiting, currentJobId = :null, updatedAt = :now, awaitingReason = :ar, releaseRequestId = :rr${ + extras.finishReason !== undefined ? ", finishReason = :fr" : "" + }${extras.clearCancel ? ", cancelRequested = :false" : ""}`, ConditionExpression: "currentJobId = :j AND NOT #s IN (:t1, :t2, :t3, :t4)", ExpressionAttributeNames: { "#s": "state" }, ExpressionAttributeValues: { @@ -417,12 +456,16 @@ export class DynamoRunsTable implements RunsTable { ":null": null, ":now": now, ":j": jobId, + ":ar": extras.awaitingReason ?? null, + ":rr": extras.releaseRequestId ?? null, ...this.terminalValues(), ...(extras.finishReason !== undefined ? { ":fr": extras.finishReason } : {}), + ...(extras.clearCancel ? { ":false": false } : {}), }, }, }, ...(extras.loop ? this.loopItems(extras.loop) : []), + ...this.requestItems(extras.requests), ]; return this.attempt(() => this.doc.send(new TransactWriteCommand({ TransactItems: items as never }))); } @@ -489,7 +532,7 @@ export class DynamoRunsTable implements RunsTable { Update: { TableName: this.tableName, Key: { pk: runPk(sprintRunId), sk: "RUN" }, - UpdateExpression: `SET #s = :state, failureReason = :reason, currentJobId = :null, updatedAt = :now, resumeAt = :null${ + UpdateExpression: `SET #s = :state, failureReason = :reason, currentJobId = :null, updatedAt = :now, resumeAt = :null, awaitingReason = :null, releaseRequestId = :null${ extras.finishReason !== undefined ? ", finishReason = :fr" : "" }`, ConditionExpression: `NOT #s IN (:t1, :t2, :t3, :t4) AND ${legacyCond}`, @@ -505,8 +548,8 @@ export class DynamoRunsTable implements RunsTable { }, }, }, - decrement("ACTIVE"), - decrement(repoSlotSk(run.repositoryId), run.legacy === true), + // 配布の実行は枠を持たない(#2439 の I64) + ...(run.purpose === "release" ? [] : [decrement("ACTIVE"), decrement(repoSlotSk(run.repositoryId), run.legacy === true)]), ...shares.map((x) => ({ Delete: { TableName: this.tableName, @@ -517,6 +560,7 @@ export class DynamoRunsTable implements RunsTable { })), ...(run.state === "awaiting_reset" ? [{ Delete: { TableName: this.tableName, Key: { pk: runPk(sprintRunId), sk: "RESET" } } }] : []), ...(extras.loop ? this.loopItems(extras.loop) : []), + ...this.requestItems(extras.requests), ]; try { await this.doc.send(new TransactWriteCommand({ TransactItems: items as never })); @@ -527,7 +571,8 @@ export class DynamoRunsTable implements RunsTable { // 実行の行の条件で落ち、読み直して終わりの状態なら 2 度目の移しで何もしない const now2 = await this.getRun(sprintRunId); if (!now2 || TERMINAL.includes(now2.state)) return false; - if (failed.some((i) => i >= 3 && i < 3 + shares.length) || (now2.legacy !== run.legacy)) continue; + const shareStart = run.purpose === "release" ? 1 : 3; + if (failed.some((i) => i >= shareStart && i < shareStart + shares.length) || now2.legacy !== run.legacy) continue; return false; } } @@ -572,7 +617,7 @@ export class DynamoRunsTable implements RunsTable { }); // 終わりの状態へ移すときは見回りの GSI から外す const leaving = patch.state !== undefined && isTerminalJob(patch.state); - // 手元のランナーが受け取ったジョブは、ランナーの枠を同じ書き込みで減らす(I50) + // 手元のマシンが受け取ったジョブは、そのマシンの枠を同じ書き込みで減らす(I50) const current = leaving ? await this.getJob(jobId) : null; if (current?.runnerId && current.state === "running") return this.finishLocalJob(current, patch, from); let condition = "attribute_exists(pk)"; @@ -838,7 +883,7 @@ export class DynamoRunsTable implements RunsTable { Update: { TableName: this.tableName, Key: { pk: accountPk(run.accountId), sk: repoSlotSk(run.repositoryId) }, - UpdateExpression: "SET legacyCount = if_not_exists(legacyCount, :zero) + :one", + UpdateExpression: "SET legacyCount = if_not_exists(legacyCount, :zero) + :one, legacySeq = if_not_exists(legacySeq, :zero) + :one", ExpressionAttributeValues: { ":zero": 0, ":one": 1 }, }, }, @@ -854,4 +899,191 @@ export class DynamoRunsTable implements RunsTable { ), ); } + + // ---- Factory のオーガナイザー(#2439) ---- + + private rootItem(root: OrganizerRoot): Item { + const sk = orgSk(root.repositoryId); + return { pk: accountPk(root.accountId), sk, ...root, ...(root.currentSeq !== null ? { gsi1pk: ORGANIZER_ACTIVE, gsi1sk: `${root.accountId}#${sk}` } : {}) }; + } + + // 受けた依頼の行。在る行は書かない(呼ぶ側が読んで無い行だけを渡す。I59) + private requestItems(requests: OrgRequest[] | undefined): Record[] { + return (requests ?? []).map((req) => { + const sk = orgReqSk(req.repositoryId, req.requestId); + return { + Put: { + TableName: this.tableName, + Item: { pk: accountPk(req.accountId), sk, ...req, ...(req.deliveredAt === null ? { gsi1pk: ORGANIZER_ACTIVE, gsi1sk: `${req.accountId}#${sk}` } : {}) }, + ConditionExpression: "attribute_not_exists(pk)", + }, + }; + }); + } + + private logItem(log: OrgLog): Record { + return { Put: { TableName: this.tableName, Item: { pk: accountPk(log.accountId), sk: orgLogSk(log), ...log }, ConditionExpression: "attribute_not_exists(sk)" } }; + } + + // 根(と渡されたまとめた配布・記録)の条件つきの書き込み(I56・I67) + private organizerItems(write: OrganizerWrite, extraRootCond?: string): Record[] { + const rootValues: Record = {}; + const rootNames: Record = {}; + let rootCond = "attribute_not_exists(pk)"; + if (write.expectVersion !== null) { + rootCond = "#ver = :v"; + rootNames["#ver"] = "version"; + rootValues[":v"] = write.expectVersion; + } + if (extraRootCond) { + rootCond = `(${rootCond}) AND ${extraRootCond}`; + rootValues[":nul"] = "NULL"; + } + const items: Record[] = [ + { + Put: { + TableName: this.tableName, + Item: this.rootItem(write.root), + ConditionExpression: rootCond, + ...(Object.keys(rootNames).length > 0 ? { ExpressionAttributeNames: rootNames } : {}), + ...(Object.keys(rootValues).length > 0 ? { ExpressionAttributeValues: rootValues } : {}), + }, + }, + ]; + if (write.batch) { + const batchItem = { pk: accountPk(write.batch.accountId), sk: orgBatchSk(write.batch.repositoryId, write.batch.seq), ...write.batch }; + if (write.expectBatchState) { + const values: Record = {}; + const keys = write.expectBatchState.map((s, i) => { + values[`:b${i}`] = s; + return `:b${i}`; + }); + items.push({ + Put: { + TableName: this.tableName, + Item: batchItem, + ConditionExpression: `#bs IN (${keys.join(", ")})`, + ExpressionAttributeNames: { "#bs": "state" }, + ExpressionAttributeValues: values, + }, + }); + } else { + items.push({ Put: { TableName: this.tableName, Item: batchItem, ConditionExpression: "attribute_not_exists(pk)" } }); + } + } + if (write.log) items.push(this.logItem(write.log)); + return items; + } + + getOrganizer(accountId: string, repositoryId: number) { + return this.get(accountPk(accountId), orgSk(repositoryId)); + } + + writeOrganizer(write: OrganizerWrite) { + return this.attempt(() => this.doc.send(new TransactWriteCommand({ TransactItems: this.organizerItems(write) as never }))); + } + + composeRelease(input: ComposeRelease) { + const { write, run, job } = input; + const runItem = { pk: runPk(run.sprintRunId), sk: "RUN", gsi1pk: accountPk(run.accountId), gsi1sk: `RUN#${run.createdAt}`, ...run }; + const items: Record[] = [ + // 根は流れている配布が無いときだけ(I56) + ...this.organizerItems(write, "(attribute_not_exists(currentSeq) OR attribute_type(currentSeq, :nul))"), + { Put: { TableName: this.tableName, Item: runItem, ConditionExpression: "attribute_not_exists(pk)" } }, + { Put: { TableName: this.tableName, Item: this.jobItem(job), ConditionExpression: "attribute_not_exists(pk)" } }, + // 照らした後に古い版の実行が数えに入っていない(I62) + { + ConditionCheck: { + TableName: this.tableName, + Key: { pk: accountPk(run.accountId), sk: repoSlotSk(run.repositoryId) }, + ConditionExpression: input.legacySeq === null ? "attribute_not_exists(legacySeq)" : "legacySeq = :ls", + ...(input.legacySeq === null ? {} : { ExpressionAttributeValues: { ":ls": input.legacySeq } }), + }, + }, + ]; + return this.attempt(() => this.doc.send(new TransactWriteCommand({ TransactItems: items as never }))); + } + + async listOrgRequests(accountId: string, repositoryId: number): Promise { + const rows = await this.queryPrefix(accountPk(accountId), `ORGREQ#${repositoryId}#`); + return rows.sort((a, b) => (a.requestedAt < b.requestedAt ? -1 : a.requestedAt > b.requestedAt ? 1 : 0)); + } + + answerRequest(accountId: string, repositoryId: number, requestId: string, answer: ReleaseAnswer) { + return this.attempt(() => + this.doc.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { pk: accountPk(accountId), sk: orgReqSk(repositoryId, requestId) }, + UpdateExpression: "SET #ans = :a", + ConditionExpression: "attribute_exists(pk) AND (attribute_not_exists(#ans) OR attribute_type(#ans, :nul))", + ExpressionAttributeNames: { "#ans": "answer" }, + ExpressionAttributeValues: { ":a": answer, ":nul": "NULL" }, + }), + ), + ); + } + + async markDelivered(accountId: string, repositoryId: number, requestId: string, at: string) { + await this.attempt(() => + this.doc.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { pk: accountPk(accountId), sk: orgReqSk(repositoryId, requestId) }, + UpdateExpression: "SET deliveredAt = :at REMOVE gsi1pk, gsi1sk", + ConditionExpression: "attribute_exists(pk) AND (attribute_not_exists(deliveredAt) OR attribute_type(deliveredAt, :nul))", + ExpressionAttributeValues: { ":at": at, ":nul": "NULL" }, + }), + ), + ); + } + + getBatch(accountId: string, repositoryId: number, seq: number) { + return this.get(accountPk(accountId), orgBatchSk(repositoryId, seq)); + } + + async listBatches(accountId: string, repositoryId: number, limit: number): Promise { + const out = await this.doc.send( + new QueryCommand({ + TableName: this.tableName, + KeyConditionExpression: "pk = :pk AND begins_with(sk, :p)", + ExpressionAttributeValues: { ":pk": accountPk(accountId), ":p": `ORGBATCH#${repositoryId}#` }, + ScanIndexForward: false, + Limit: limit, + ConsistentRead: true, + }), + ); + return (out.Items ?? []).map((i) => strip(i)!); + } + + async appendOrgLog(log: OrgLog) { + await this.doc.send(new TransactWriteCommand({ TransactItems: [this.logItem(log)] as never })); + } + + listOrgLogs(accountId: string, repositoryId: number) { + return this.queryPrefix(accountPk(accountId), `ORGLOG#${repositoryId}#`); + } + + async listOrganizerActive(): Promise<{ accountId: string; repositoryId: number }[]> { + const out = new Map(); + let start: Record | undefined; + do { + const page = await this.doc.send( + new QueryCommand({ + TableName: this.tableName, + IndexName: GSI1, + KeyConditionExpression: "gsi1pk = :pk", + ExpressionAttributeValues: { ":pk": ORGANIZER_ACTIVE }, + ExclusiveStartKey: start, + }), + ); + for (const i of page.Items ?? []) { + const accountId = i.accountId as string; + const repositoryId = i.repositoryId as number; + out.set(`${accountId}#${repositoryId}`, { accountId, repositoryId }); + } + start = page.LastEvaluatedKey; + } while (start); + return [...out.values()]; + } } diff --git a/factory/api/src/runs/loops.test.ts b/factory/api/src/runs/loops.test.ts index 3dca7bf7e..7dbf744da 100644 --- a/factory/api/src/runs/loops.test.ts +++ b/factory/api/src/runs/loops.test.ts @@ -1,4 +1,4 @@ -// #2347: 開発ループ・並行する実行の取り合い・利用上限の後の再開・手元のランナーの枠(設計の「テスト設計」) +// #2347: 開発ループ・並行する実行の取り合い・利用上限の後の再開・手元のマシンの枠(設計の「テスト設計」) import { readdirSync, readFileSync } from "node:fs"; import { beforeEach, describe, expect, it } from "vitest"; import { validateRunJobInstruction, type RunJobInstruction } from "@ndf-factory/contracts"; @@ -441,7 +441,7 @@ describe("版(受け入れ条件 6、I48)と移行", () => { describe("リポジトリの同時の数(受け入れ条件 8、I49)", () => { beforeEach(() => setup()); - it("プランの値 3 で宣言 1 のリポジトリは 2 本目が concurrent_repo_runs で断られる", async () => { + it("料金プランの値 3 で宣言 1 のリポジトリは 2 本目が concurrent_repo_runs で断られる", async () => { const pro = accountContext({ plan: "pro" }); await h.table.setMaxConcurrentRuns("acc-1", REPO, 1); await manual([4], REPO, pro); @@ -450,7 +450,7 @@ describe("リポジトリの同時の数(受け入れ条件 8、I49)", () => }); }); -describe("手元のランナーの同時のジョブの数(受け入れ条件 9、I50)", () => { +describe("手元のマシンの同時のジョブの数(受け入れ条件 9、I50)", () => { beforeEach(() => setup()); it("max_jobs 1 のランナーへ 2 本目を渡さず、1 本目が終わると渡す", async () => { diff --git a/factory/api/src/runs/loops.ts b/factory/api/src/runs/loops.ts index 3df02f806..9c5711f5a 100644 --- a/factory/api/src/runs/loops.ts +++ b/factory/api/src/runs/loops.ts @@ -262,7 +262,7 @@ export class LoopControl { if (ok) return; continue; } - // 保存した写しを判定に使わない(プランの変更・席の外し・形態の変更を周回ごとに照らす) + // 保存した写しを判定に使わない(料金プランの変更・席の外し・形態の変更を周回ごとに照らす) if (this.deps.resolveContext) { const fresh = await this.deps.resolveContext(loop.accountId, loop.createdBy); if (!fresh) { diff --git a/factory/api/src/runs/memory-table.ts b/factory/api/src/runs/memory-table.ts index be1d72371..f17bbcb23 100644 --- a/factory/api/src/runs/memory-table.ts +++ b/factory/api/src/runs/memory-table.ts @@ -1,4 +1,5 @@ // テストで使う偽の実行の表。確かめと書き込みを 1 つの同期の処理で行い、条件つきの書き込みとトランザクションを再現する +import type { OrganizerRoot, OrganizerWrite, OrgLog, OrgRequest, ReleaseAnswer, ReleaseBatch } from "../organizer/model.ts"; import { isTerminalJob, isTerminalLoop, @@ -15,7 +16,7 @@ import { type Slots, type SprintRun, } from "./model.ts"; -import type { RunsTable, RunTransitionExtras } from "./table.ts"; +import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts"; const regKey = (a: string, r: number) => `${a}#${r}`; const shareKey = (a: string, r: number, k: string | number) => `${a}#${r}#${k}`; @@ -35,6 +36,15 @@ export class MemoryRunsTable implements RunsTable { readonly issueShares = new Map(); // # → running の local のジョブの数 readonly runnerSlots = new Map(); + // # → 古い版の実行を数えに入れた回数(#2439 の I62) + readonly legacySeqs = new Map(); + // Factory のオーガナイザー(#2439)。#(#)→ 行 + readonly organizers = new Map(); + readonly orgRequests = new Map(); + readonly batches = new Map(); + readonly orgLogs: OrgLog[] = []; + // テストで、次の composeRelease のトランザクションを落とす + failNextCompose = false; migrated = false; private async tick(): Promise { @@ -101,10 +111,12 @@ export class MemoryRunsTable implements RunsTable { await this.tick(); const key = regKey(accountId, repositoryId); const legacy = this.legacySlots.get(key) ?? 0; + const legacySeq = this.legacySeqs.get(key); return { account: this.accountSlots.get(accountId) ?? 0, repository: this.repositorySlots.get(key) ?? 0, ...(legacy !== 0 ? { legacy } : {}), + ...(legacySeq !== undefined ? { legacySeq } : {}), }; } @@ -138,6 +150,8 @@ export class MemoryRunsTable implements RunsTable { if ((this.repositorySlots.get(repoKey) ?? 0) !== expected.repository) return "slots"; if (guards) { if (!guards.legacy && (this.legacySlots.get(repoKey) ?? 0) !== 0) return "legacy"; + // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62) + if (guards.legacy && (this.organizers.get(repoKey)?.currentSeq ?? null) !== null) return "release_running"; if (guards.issues.some((n) => this.issueShares.has(shareKey(run.accountId, run.repositoryId, n)))) return "issue_in_use"; if (guards.milestoneChecks.some((t) => this.milestoneShares.has(shareKey(run.accountId, run.repositoryId, t)))) return "issue_in_loop"; if (guards.loop && !this.loopFits(guards.loop)) return "loop_changed"; @@ -145,7 +159,10 @@ export class MemoryRunsTable implements RunsTable { this.accountSlots.set(run.accountId, expected.account + 1); this.repositorySlots.set(repoKey, expected.repository + 1); if (guards) { - if (guards.legacy) this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) + 1); + if (guards.legacy) { + this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) + 1); + this.legacySeqs.set(repoKey, (this.legacySeqs.get(repoKey) ?? 0) + 1); + } for (const n of guards.issues) this.issueShares.set(shareKey(run.accountId, run.repositoryId, n), run.sprintRunId); if (guards.loop) this.applyLoop(guards.loop); } @@ -180,14 +197,23 @@ export class MemoryRunsTable implements RunsTable { if (r && r.state === "starting") Object.assign(r, { state: "running", updatedAt: now }); } - async addResumeJob(job: RunJob, now: string, opts: { from?: "awaiting_approval" | "awaiting_reset"; loop?: LoopWrite } = {}) { + async addResumeJob(job: RunJob, now: string, opts: ResumeOptions = {}) { await this.tick(); const r = this.runs.get(job.sprintRunId); if (!r || r.state !== (opts.from ?? "awaiting_approval") || r.currentJobId !== null) return false; + const expect = opts.expectAwaiting; + if (expect) { + if (r.awaitingReason !== expect.reason) return false; + if (expect.releaseRequestId !== undefined && r.releaseRequestId !== expect.releaseRequestId) return false; + } else if ((r.awaitingReason ?? null) !== null) { + return false; + } if (opts.loop && !this.loopFits(opts.loop)) return false; - Object.assign(r, { state: "starting", currentJobId: job.jobId, jobCount: job.seq, updatedAt: now, resumeAt: null }); + if (opts.organizer && !this.organizerFits(opts.organizer)) return false; + Object.assign(r, { state: "starting", currentJobId: job.jobId, jobCount: job.seq, updatedAt: now, resumeAt: null, awaitingReason: null, releaseRequestId: null }); this.jobs.set(job.jobId, structuredClone(job)); if (opts.loop) this.applyLoop(opts.loop); + if (opts.organizer) this.applyOrganizer(opts.organizer); return true; } @@ -196,9 +222,17 @@ export class MemoryRunsTable implements RunsTable { const r = this.runs.get(sprintRunId); if (!r || isTerminalRun(r.state) || r.currentJobId !== jobId) return false; if (extras.loop && !this.loopFits(extras.loop)) return false; - Object.assign(r, { state: "awaiting_approval", currentJobId: null, updatedAt: now }); + Object.assign(r, { + state: "awaiting_approval", + currentJobId: null, + updatedAt: now, + awaitingReason: extras.awaitingReason ?? null, + releaseRequestId: extras.releaseRequestId ?? null, + }); + if (extras.clearCancel) r.cancelRequested = false; if (extras.finishReason !== undefined) r.finishReason = extras.finishReason; if (extras.loop) this.applyLoop(extras.loop); + this.putRequests(extras.requests); return true; } @@ -225,14 +259,18 @@ export class MemoryRunsTable implements RunsTable { const r = this.runs.get(sprintRunId); if (!r || isTerminalRun(r.state)) return false; if (extras.loop && !this.loopFits(extras.loop)) return false; - Object.assign(r, { state, failureReason: reason, currentJobId: null, updatedAt: now, resumeAt: null }); + Object.assign(r, { state, failureReason: reason, currentJobId: null, updatedAt: now, resumeAt: null, awaitingReason: null, releaseRequestId: null }); if (extras.finishReason !== undefined) r.finishReason = extras.finishReason; const repoKey = regKey(r.accountId, r.repositoryId); - this.accountSlots.set(r.accountId, (this.accountSlots.get(r.accountId) ?? 0) - 1); - this.repositorySlots.set(repoKey, (this.repositorySlots.get(repoKey) ?? 0) - 1); - if (r.legacy === true) this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) - 1); + // 配布の実行は枠を持たない(#2439 の I64) + if (r.purpose !== "release") { + this.accountSlots.set(r.accountId, (this.accountSlots.get(r.accountId) ?? 0) - 1); + this.repositorySlots.set(repoKey, (this.repositorySlots.get(repoKey) ?? 0) - 1); + if (r.legacy === true) this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) - 1); + } for (const [key, owner] of this.issueShares) if (owner === sprintRunId) this.issueShares.delete(key); if (extras.loop) this.applyLoop(extras.loop); + this.putRequests(extras.requests); return true; } @@ -356,7 +394,123 @@ export class MemoryRunsTable implements RunsTable { r.legacy = true; const repoKey = regKey(r.accountId, r.repositoryId); this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) + 1); + this.legacySeqs.set(repoKey, (this.legacySeqs.get(repoKey) ?? 0) + 1); for (const n of issues) this.issueShares.set(shareKey(r.accountId, r.repositoryId, n), r.sprintRunId); return true; } + + // ---- Factory のオーガナイザー(#2439) ---- + + private putRequests(requests: OrgRequest[] | undefined): void { + for (const req of requests ?? []) { + const key = shareKey(req.accountId, req.repositoryId, req.requestId); + if (!this.orgRequests.has(key)) this.orgRequests.set(key, structuredClone(req)); + } + } + + private organizerFits(write: OrganizerWrite): boolean { + const key = regKey(write.root.accountId, write.root.repositoryId); + const current = this.organizers.get(key); + if (write.expectVersion === null ? current !== undefined : current?.version !== write.expectVersion) return false; + if (write.expectBatchState && write.batch) { + const b = this.batches.get(shareKey(write.batch.accountId, write.batch.repositoryId, write.batch.seq)); + if (!b || !write.expectBatchState.includes(b.state)) return false; + } + return true; + } + + private applyOrganizer(write: OrganizerWrite): void { + this.organizers.set(regKey(write.root.accountId, write.root.repositoryId), structuredClone(write.root)); + if (write.batch) this.batches.set(shareKey(write.batch.accountId, write.batch.repositoryId, write.batch.seq), structuredClone(write.batch)); + if (write.log) this.orgLogs.push(structuredClone(write.log)); + } + + async getOrganizer(accountId: string, repositoryId: number) { + await this.tick(); + return structuredClone(this.organizers.get(regKey(accountId, repositoryId)) ?? null); + } + + async writeOrganizer(write: OrganizerWrite) { + await this.tick(); + if (!this.organizerFits(write)) return false; + this.applyOrganizer(write); + return true; + } + + async composeRelease(input: ComposeRelease) { + await this.tick(); + if (this.failNextCompose) { + this.failNextCompose = false; + throw new Error("TransactionCanceled"); + } + const { write, run, job } = input; + const key = regKey(write.root.accountId, write.root.repositoryId); + if (!this.organizerFits(write)) return false; + const current = this.organizers.get(key); + if (current && current.currentSeq !== null) return false; + if ((this.legacySeqs.get(key) ?? null) !== input.legacySeq) return false; + if (this.batches.has(shareKey(write.batch.accountId, write.batch.repositoryId, write.batch.seq))) return false; + this.applyOrganizer(write); + this.runs.set(run.sprintRunId, structuredClone(run)); + this.jobs.set(job.jobId, structuredClone(job)); + return true; + } + + async listOrgRequests(accountId: string, repositoryId: number) { + await this.tick(); + return [...this.orgRequests.values()] + .filter((r) => r.accountId === accountId && r.repositoryId === repositoryId) + .sort((a, b) => (a.requestedAt < b.requestedAt ? -1 : a.requestedAt > b.requestedAt ? 1 : 0)) + .map((r) => structuredClone(r)); + } + + async answerRequest(accountId: string, repositoryId: number, requestId: string, answer: ReleaseAnswer) { + await this.tick(); + const r = this.orgRequests.get(shareKey(accountId, repositoryId, requestId)); + if (!r || r.answer !== null) return false; + r.answer = structuredClone(answer); + return true; + } + + async markDelivered(accountId: string, repositoryId: number, requestId: string, at: string) { + await this.tick(); + const r = this.orgRequests.get(shareKey(accountId, repositoryId, requestId)); + if (r && r.deliveredAt === null) r.deliveredAt = at; + } + + async getBatch(accountId: string, repositoryId: number, seq: number) { + await this.tick(); + return structuredClone(this.batches.get(shareKey(accountId, repositoryId, seq)) ?? null); + } + + async listBatches(accountId: string, repositoryId: number, limit: number) { + await this.tick(); + return [...this.batches.values()] + .filter((b) => b.accountId === accountId && b.repositoryId === repositoryId) + .sort((a, b) => b.seq - a.seq) + .slice(0, limit) + .map((b) => structuredClone(b)); + } + + async appendOrgLog(log: OrgLog) { + await this.tick(); + this.orgLogs.push(structuredClone(log)); + } + + async listOrgLogs(accountId: string, repositoryId: number) { + await this.tick(); + return this.orgLogs.filter((l) => l.accountId === accountId && l.repositoryId === repositoryId).map((l) => structuredClone(l)); + } + + async listOrganizerActive() { + await this.tick(); + const out = new Map(); + for (const root of this.organizers.values()) { + if (root.currentSeq !== null) out.set(regKey(root.accountId, root.repositoryId), { accountId: root.accountId, repositoryId: root.repositoryId }); + } + for (const req of this.orgRequests.values()) { + if (req.deliveredAt === null) out.set(regKey(req.accountId, req.repositoryId), { accountId: req.accountId, repositoryId: req.repositoryId }); + } + return [...out.values()]; + } } diff --git a/factory/api/src/runs/model.ts b/factory/api/src/runs/model.ts index bdaaea146..33a79a0b8 100644 --- a/factory/api/src/runs/model.ts +++ b/factory/api/src/runs/model.ts @@ -22,7 +22,7 @@ export interface Registration { installationId: number; registeredBy: string; registeredAt: string; - // 同時の実行の数の宣言。空は「宣言しない(プランの値)」(I49) + // 同時の実行の数の宣言。空は「宣言しない(料金プランの値)」(I49) maxConcurrentRuns?: number | null; } @@ -62,8 +62,24 @@ export interface SprintRun { finishReason?: FinishReason | null; // awaiting_reset の間だけ。リセット時刻 resumeAt?: string | null; + // 実行の目的(#2439)。release は Factory のオーガナイザーが起こす配布の実行で、実行の枠を持たない(I64)。空は sprint + purpose?: RunPurpose | null; + // 配布の実行のまとめた配布の連番。purpose が空なら空 + releaseSeq?: number | null; + // awaiting_approval の理由(I65)。空は承認の依頼の待ち + awaitingReason?: AwaitingReason | null; + // 手動の実行が待つ配布の依頼の id。awaitingReason が release_requested のときだけ + releaseRequestId?: string | null; } +export type RunPurpose = "release"; +// release_requested は配布の依頼の答えを待つ手動の実行、release_stalled はジョブが失敗して止まった配布の実行 +export type AwaitingReason = "release_requested" | "release_stalled"; +// 配布の実行のジョブの手(#2439) +export const RELEASE_STEPS = ["present", "represent", "confirm:1", "confirm:2", "continue"] as const; +export type ReleaseStep = (typeof RELEASE_STEPS)[number]; +export const isReleaseRun = (run: Pick) => run.purpose === "release"; + export type FinishReason = "section_limit" | "usage_limit"; // ループの実行のジョブの指示へ載せる値。ジョブを作る時点で写す(指示の組み立てを表の読みから切り離す) @@ -80,12 +96,16 @@ export interface RunJob { sprintRunId: string; accountId: string; seq: number; - start: "new" | "resume" | "loop"; + start: "new" | "resume" | "loop" | "release"; approvalRequestId: string | null; // ループの実行だけ(#2347) loopInput?: LoopJobInput | null; - // local で受け取った手元のランナー。手元のランナーの枠を減らす先(I50) + // local で受け取った手元のマシン。手元のマシンの枠を減らす先(I50) runnerId?: string | null; + // 配布の実行のジョブの手(#2439)。ほかのジョブは空 + releaseStep?: ReleaseStep | null; + // 手動の実行の配布の答えの再開で、答えの出た依頼の id(#2439) + releaseRequestId?: string | null; state: JobState; runnerKind: "ecs" | "local"; taskArn: string | null; @@ -104,6 +124,8 @@ export interface Slots { account: number; repository: number; legacy?: number; + // 古い版の実行を legacy へ数えに入れた回数(#2439 の I62)。無いのは「まだ数えに入れていない」 + legacySeq?: number; } // ---- 開発ループ(#2347) ---- @@ -193,7 +215,8 @@ export interface CreateRunGuards { loop?: LoopWrite; } -export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed"; +// release_running は、古い版の実行の開始で、リポジトリのまとめた配布が流れていた(#2439 の I62) +export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed" | "release_running"; // マイルストーンの持ち分の行の鍵に使う、題の sha256 の先頭 32 字 export const milestoneHash = (title: string) => createHash("sha256").update(title, "utf8").digest("hex").slice(0, 32); diff --git a/factory/api/src/runs/runner-api.test.ts b/factory/api/src/runs/runner-api.test.ts index 747e07751..59f670cc0 100644 --- a/factory/api/src/runs/runner-api.test.ts +++ b/factory/api/src/runs/runner-api.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { handleRunControlEvent } from "../handlers/run-control.ts"; +import { appendEvent } from "../progress/append.ts"; import { accountContext, createRunsHarness, type RunsHarness } from "../testing/runs.ts"; import { createRunnerApiApp } from "./runner-api.ts"; @@ -12,7 +13,7 @@ beforeEach(async () => { h.github.installations.set(9, new Map([[101, "org-a/repo-a"]])); h.github.openIssues.set("org-a/repo-a", [{ number: 1, title: "a" }]); await h.table.putRegistration({ accountId: "acc-1", repositoryId: 101, fullName: "org-a/repo-a", installationId: 9, registeredBy: "u", registeredAt: "t" }); - app = createRunnerApiApp(() => ({ verifier: h.tokens.verify, control: h.control, table: h.table, github: h.github, platform: h.platform })); + app = createRunnerApiApp(() => ({ verifier: h.tokens.verify, control: h.control, table: h.table, github: h.github, platform: h.platform, organizer: h.organizer })); const { sprint_run_id } = await h.control.startSprintRun(accountContext({ executionMode: "managed" }), { repositoryId: 101, issues: [1] }); const jobId = (await h.table.getRun(sprint_run_id))!.currentJobId!; const instruction = h.platform.taskOf(jobId)![1].instruction; @@ -55,6 +56,52 @@ describe("ランナー向けの API(決定 7・8、受け入れ条件 30)", }); }); +describe("Factory のオーガナイザーの経路(#2439)", () => { + const get = (path: string, token: string) => app.request(path, { headers: { Authorization: `Bearer ${token}` } }); + + it("答えの経路はそのレーンの答えと配布済みの範囲を返し、まとめた配布の経路は配布の実行のジョブだけが読める", async () => { + const answers = await get("/run-jobs/organizer/answers", run.token); + expect(answers.status).toBe(200); + expect(await answers.json()).toEqual({ answers: [], ranges: [] }); + expect((await get("/run-jobs/organizer/batch", run.token)).status).toBe(403); + + // 実行が配布の依頼を出して答えを待つと、配布の実行が起き、その token でまとめた配布を読める + await appendEvent(h.progress, { + sprint_run_id: run.sprintRunId, + idempotency_key: "run-x-r1", + type: "release.requested", + at: h.clock.now.toISOString(), + source: "runner", + job_id: run.jobId, + schema_version: 1, + payload: { request_id: "run-x-r1", lane: `run-${run.sprintRunId}`, prs: [5], issues: [1], ndf_version: "10.27.16", requested_at: h.clock.now.toISOString() }, + }); + await appendEvent(h.progress, { + sprint_run_id: run.sprintRunId, + idempotency_key: `${run.jobId}:finished`, + type: "job.finished", + at: h.clock.now.toISOString(), + source: "runner", + job_id: run.jobId, + schema_version: 1, + payload: { result: "gate", summary: "s", reason: "release_requested" }, + }); + await h.control.onTaskStateChange(run.jobId, "STOPPED"); + const release = [...h.table.runs.values()].find((r) => r.purpose === "release")!; + const instruction = h.platform.taskOf(release.currentJobId!)![1].instruction; + const token = h.platform.tokens.get(instruction.INGEST_TOKEN_SECRET_ARN)!; + const batch = await get("/run-jobs/organizer/batch", token); + expect(batch.status).toBe(200); + expect(await batch.json()).toEqual({ + seq: 1, + step: "present", + lanes: [{ lane: `run-${run.sprintRunId}`, requests: [{ id: "run-x-r1", prs: [5] }], issues: [1] }], + presentations: [], + choices: ["continue", "reject"], + }); + }); +}); + describe("停止の知らせと見回りの handler", () => { it("ECS の知らせの startedBy でジョブを引き、見回りの起動で sweep する", async () => { await handleRunControlEvent(h.control, { "detail-type": "ECS Task State Change", detail: { startedBy: run.jobId, lastStatus: "RUNNING" } }); diff --git a/factory/api/src/runs/runner-api.ts b/factory/api/src/runs/runner-api.ts index e3adda91b..587312309 100644 --- a/factory/api/src/runs/runner-api.ts +++ b/factory/api/src/runs/runner-api.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { ApiError } from "../errors.ts"; import type { GitHubPort } from "../github/port.ts"; +import type { OrganizerControl } from "../organizer/control.ts"; import { IngestForbiddenError, IngestUnauthorizedError, IngestUnavailableError } from "../progress/errors.ts"; import { RunJobTokenError, type RunJobTokenVerifier, type VerifiedRunJob } from "../progress/run-job-token.ts"; import type { RunControl } from "./control.ts"; @@ -15,6 +16,7 @@ export interface RunnerApiDeps { table: RunsTable; github: GitHubPort; platform: RunnerPlatform; + organizer?: OrganizerControl; } type Env = { Variables: { job: VerifiedRunJob } }; @@ -72,5 +74,30 @@ export function createRunnerApiApp(deps: () => RunnerApiDeps): Hono { return c.json({ url: url.url, expires_at: url.expiresAt }); }); + // 配布の依頼の答えと配布済みの範囲(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へ書く + app.get("/run-jobs/organizer/answers", async (c) => { + const d = deps(); + const claims = c.get("job"); + const { run } = await d.control.activeJobFor(claims.jobId, claims.sprintRunId).catch(() => { + throw new IngestForbiddenError(); + }); + if (!d.organizer) throw new IngestUnavailableError(); + return c.json(await d.organizer.answersFor(run)); + }); + + // 配布の実行のまとめた配布の中身(batch.json)。配布の実行のジョブだけが読める + app.get("/run-jobs/organizer/batch", async (c) => { + const d = deps(); + const claims = c.get("job"); + const { run, job } = await d.control.activeJobFor(claims.jobId, claims.sprintRunId).catch(() => { + throw new IngestForbiddenError(); + }); + if (run.purpose !== "release") throw new IngestForbiddenError(); + if (!d.organizer) throw new IngestUnavailableError(); + const batch = await d.organizer.batchFor(run, job.releaseStep ?? null); + if (!batch) throw new ApiError(404, "release_not_found", "まとめた配布が見つかりません"); + return c.json(batch); + }); + return app; } diff --git a/factory/api/src/runs/table.ts b/factory/api/src/runs/table.ts index bec2793fb..231906837 100644 --- a/factory/api/src/runs/table.ts +++ b/factory/api/src/runs/table.ts @@ -1,5 +1,7 @@ // 実行の表(RUNS_TABLE_NAME)の口。条件つきの書き込みを操作の単位で持ち、同時の 2 本でも不変条件が崩れない形にする +import type { OrganizerRoot, OrganizerWrite, OrgLog, OrgRequest, ReleaseAnswer, ReleaseBatch } from "../organizer/model.ts"; import type { + AwaitingReason, CreateRunGuards, CreateRunResult, CredentialRow, @@ -18,6 +20,30 @@ import type { export interface RunTransitionExtras { finishReason?: FinishReason | null; loop?: LoopWrite; + // 受けた配布の依頼の行(#2439 の I59)。無い行だけを渡す。実行の移りと同じトランザクションで書く + requests?: OrgRequest[]; + // 承認待ちの理由と、手動の実行が待つ配布の依頼(I65。awaitApproval だけが読む) + awaitingReason?: AwaitingReason | null; + releaseRequestId?: string | null; + // 取り消しの印を外す(main へマージした後に取り消しを受けた配布の実行を、止まった配布として残すとき。awaitApproval だけが読む) + clearCancel?: boolean; +} + +// 再開のジョブを作る条件(#2439)。expectAwaiting を省くと、承認待ちの理由が空のときだけ書く +export interface ResumeOptions { + from?: "awaiting_approval" | "awaiting_reset"; + loop?: LoopWrite; + expectAwaiting?: { reason: AwaitingReason; releaseRequestId?: string }; + // まとめた配布の根と配布の書き込み(止まった配布を続けるとき。状態の戻しとジョブの作成を同じトランザクションで書く) + organizer?: OrganizerWrite; +} + +// まとめた配布と配布の実行を 1 回で書く(I57)。legacySeq はリポジトリの枠の legacySeq を照らした値(無ければ null。I62) +export interface ComposeRelease { + write: OrganizerWrite & { batch: ReleaseBatch; log: OrgLog }; + run: SprintRun; + job: RunJob; + legacySeq: number | null; } export interface RunsTable { @@ -50,7 +76,7 @@ export interface RunsTable { // 終わりの状態でない実行を running にする(starting のときだけ) markRunRunning(sprintRunId: string, now: string): Promise; // 承認待ち(from が awaiting_reset ならリセット待ち)の実行に resume のジョブを足す。動くジョブが無いときだけ書く(I9) - addResumeJob(job: RunJob, now: string, opts?: { from?: "awaiting_approval" | "awaiting_reset"; loop?: LoopWrite }): Promise; + addResumeJob(job: RunJob, now: string, opts?: ResumeOptions): Promise; // 動くジョブを外し、実行を承認待ちへ移す(終わりの状態でないときだけ) awaitApproval(sprintRunId: string, jobId: string, now: string, extras?: RunTransitionExtras): Promise; // 動くジョブを外し、実行をリセット待ちへ移す(local の利用上限。#2347 の決定 7) @@ -58,16 +84,16 @@ export interface RunsTable { // リセット時刻が before 以前のリセット待ちの実行(見回りが引く) listResetRuns(before: string): Promise; // 終わりの状態へ移し、2 つの枠を 1 ずつ減らす。課題の持ち分を消し、古い版の実行なら legacyCount も減らす。 - // 既に終わりの状態なら false(I34・I47・I48) + // 配布の実行は枠を持たないため減らさない(I64)。既に終わりの状態なら false(I34・I47・I48) finishRun(sprintRunId: string, state: RunState, reason: FailureReason | null, now: string, extras?: RunTransitionExtras): Promise; getJob(jobId: string): Promise; // 終わりの状態でないジョブ(見回りが引く) listActiveJobs(): Promise; - // ジョブを書き換える。from を渡すと、今の状態がそのどれかのときだけ書く。手元のランナーが受け取ったジョブを - // 終わりの状態へ移すときは、そのランナーの枠も同じ書き込みで減らす(I50) + // ジョブを書き換える。from を渡すと、今の状態がそのどれかのときだけ書く。手元のマシンが受け取ったジョブを + // 終わりの状態へ移すときは、そのマシンの枠も同じ書き込みで減らす(I50) updateJob(jobId: string, patch: Partial, from?: RunJob["state"][]): Promise; - // local の pending のジョブを running にし、手元のランナーの枠を 1 増やす。枠が maxJobs 以上なら false(I50) + // local の pending のジョブを running にし、手元のマシンの枠を 1 増やす。枠が maxJobs 以上なら false(I50) claimLocalJob(jobId: string, runnerId: string, maxJobs: number, now: string): Promise; // ---- 開発ループ(#2347) ---- @@ -96,4 +122,24 @@ export interface RunsTable { listUnbackfilledRuns(): Promise; // 実行を古い版として legacyCount と課題の持ち分へ写す。実行が終わりの状態か legacy が空でなければ false backfillRun(run: SprintRun, issues: number[]): Promise; + + // ---- Factory のオーガナイザー(#2439) ---- + getOrganizer(accountId: string, repositoryId: number): Promise; + // 根(と、渡せばまとめた配布・記録)を条件つきで書く(I56・I67)。条件で落ちれば false + writeOrganizer(write: OrganizerWrite): Promise; + // まとめた配布・配布の実行とジョブ・根・記録を 1 回で書く(I57・I62)。条件で落ちれば false + composeRelease(input: ComposeRelease): Promise; + listOrgRequests(accountId: string, repositoryId: number): Promise; + // 答えがまだ無いときだけ書く(I58)。書いたら true + answerRequest(accountId: string, repositoryId: number, requestId: string, answer: ReleaseAnswer): Promise; + // 答えを届け終えた(見回りの索引から外す) + markDelivered(accountId: string, repositoryId: number, requestId: string, at: string): Promise; + getBatch(accountId: string, repositoryId: number, seq: number): Promise; + // 新しい順 + listBatches(accountId: string, repositoryId: number, limit: number): Promise; + appendOrgLog(log: OrgLog): Promise; + // 時刻の古い順 + listOrgLogs(accountId: string, repositoryId: number): Promise; + // 流れているまとめた配布を持つ根か、答えを届け終えていない依頼のあるリポジトリ(見回りが引く) + listOrganizerActive(): Promise<{ accountId: string; repositoryId: number }[]>; } diff --git a/factory/api/src/testing/runs.ts b/factory/api/src/testing/runs.ts index 0fd5ce501..93d295627 100644 --- a/factory/api/src/testing/runs.ts +++ b/factory/api/src/testing/runs.ts @@ -6,11 +6,14 @@ import type { GitHubPort, InstallationToken, IssueSummary, + PullByHead, + PullHead, RankingTable, ReachableRepository, RepositoryVisibility, } from "../github/port.ts"; -import { appendControlEventTo } from "../progress/append.ts"; +import { OrganizerControl } from "../organizer/control.ts"; +import { appendControlEventTo, appendReleaseAnsweredTo } from "../progress/append.ts"; import { MemoryProgressEventStore } from "../progress/memory-store.ts"; import { memoryProgressReader } from "../progress/read.ts"; import { RunControl, type RunControlConfig } from "../runs/control.ts"; @@ -78,6 +81,24 @@ export class FakeGitHub implements GitHubPort { if (!this.installations.get(installationId)?.has(repositoryId)) return null; return this.visibilities.get(repositoryId) ?? "private"; } + + // Factory のオーガナイザー(#2439)。<リポジトリの名前>#<枝> → 先頭、名前 → Pull Request + readonly heads = new Map(); + readonly pulls = new Map(); + readonly headReads: string[] = []; + + async branchHead(_installationId: number, _repositoryId: number, fullName: string, branch: string) { + this.headReads.push(`${fullName}#${branch}`); + return this.heads.get(`${fullName}#${branch}`) ?? null; + } + async listOpenPullHeads(_installationId: number, _repositoryId: number, fullName: string, base: string): Promise { + return (this.pulls.get(fullName) ?? []).filter((p) => p.state === "open" && p.base === base).map((p) => ({ head: p.head, url: p.url })); + } + async findPullByHead(_installationId: number, _repositoryId: number, fullName: string, head: string, base: string): Promise { + const found = (this.pulls.get(fullName) ?? []).filter((p) => p.head === head && p.base === base); + const pick = found.find((p) => p.state === "merged") ?? found.find((p) => p.state === "open") ?? found[0]; + return pick ? { state: pick.state, url: pick.url } : null; + } } export class FakePlatform implements RunnerPlatform { @@ -141,6 +162,7 @@ export interface RunsHarness { progress: MemoryProgressEventStore; tokens: RunJobTokenKit; control: RunControl; + organizer: OrganizerControl; clock: { now: Date }; logs: Record[]; } @@ -168,5 +190,15 @@ export async function createRunsHarness(config: RunControlConfig = TEST_CONFIG): newId: () => `id${String(++id).padStart(4, "0")}`, log: (e) => logs.push(e), }); - return { table, github, platform, progress, tokens, control, clock, logs }; + const organizer = new OrganizerControl({ + table, + github, + reader: memoryProgressReader(progress), + control, + appendAnswered: (e) => appendReleaseAnsweredTo(progress, e), + now: () => clock.now, + log: (e) => logs.push(e), + }); + control.setOrganizer(organizer); + return { table, github, platform, progress, tokens, control, organizer, clock, logs }; } diff --git a/factory/api/src/views/derive.test.ts b/factory/api/src/views/derive.test.ts new file mode 100644 index 000000000..f1d6fd9db --- /dev/null +++ b/factory/api/src/views/derive.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; +import type { ProgressEvent } from "@ndf-factory/contracts"; +import { toRecord } from "../progress/append.ts"; +import { capSteps, latestOf, type StepRecord } from "../progress/read.ts"; +import type { ProgressEventRecord } from "../progress/store.ts"; +import type { SprintRun } from "../runs/model.ts"; +import { deriveDetail, deriveRefinements, deriveStepDetail, gatesOf, listRowExtras, stepRows } from "./derive.ts"; + +const SHA = (c: string) => c.repeat(40); + +function runOf(patch: Partial = {}): SprintRun { + return { + sprintRunId: "sr-1", + accountId: "acc-1", + repositoryId: 101, + repo: "org-a/repo-a", + state: "running", + issues: [11, 12], + ndfVersion: "10.27.15", + executionMode: "byok", + modelCredentialSecretArn: null, + currentJobId: "job-1", + jobCount: 1, + cancelRequested: false, + failureReason: null, + createdBy: "user-1", + createdAt: "2026-11-04T00:00:00.000Z", + updatedAt: "2026-11-04T00:00:00.000Z", + ...patch, + }; +} + +// 事象の列を作る。秒ずつ進む時刻と連番の鍵を振る +function events(sprintRunId = "sr-1") { + const out: ProgressEventRecord[] = []; + let n = 0; + const add = (type: string, payload: Record, opts: { job?: string; at?: string; source?: string } = {}) => { + n += 1; + const at = opts.at ?? new Date(Date.UTC(2026, 10, 4, 0, 0, n)).toISOString(); + const job = opts.job ?? "job-1"; + const event = { + sprint_run_id: sprintRunId, + idempotency_key: `${job}:${n}`, + type, + at, + source: opts.source ?? "runner", + job_id: job, + schema_version: 1, + payload, + } as unknown as ProgressEvent; + const record = toRecord(event); + out.push(record); + return record; + }; + const step = (step: string, patch: Record = {}, opts: { job?: string; at?: string } = {}) => + add("step.recorded", { step, exit_code: 0, seconds: 10, summary: `${step} の要約`, ...patch }, opts) as StepRecord; + return { out, add, step }; +} + +describe("一覧の行(受け入れ条件 1〜3・I4・I5)", () => { + it("最後の phase.changed のフェーズ・最後の工程・最後の事象の時刻を足す", () => { + for (const last of ["設計", "検査", "仕上げ"]) { + const e = events(); + e.add("phase.changed", { phase: "実装", issues: [] }); + e.add("phase.changed", { phase: last, issues: [] }); + e.step("impl"); + const extras = listRowExtras(runOf(), latestOf(e.out)); + expect(extras).toEqual({ repo: "org-a/repo-a", current_phase: last, current_step: "impl", last_event_at: e.out.at(-1)!.at }); + } + }); + + it("phase.changed が 0 件なら null、5 つに無い名前はそのまま返す", () => { + expect(listRowExtras(runOf(), latestOf([]))).toEqual({ repo: "org-a/repo-a", current_phase: null, current_step: null, last_event_at: null }); + const e = events(); + e.add("phase.changed", { phase: "準備", issues: [] }); + expect(listRowExtras(runOf(), latestOf(e.out)).current_phase).toBe("準備"); + }); +}); + +describe("詳細(受け入れ条件 5・6・8・9・I6〜I10)", () => { + it("課題 #A・#B・番号なしの工程が、それぞれの欄に order_key の順で並び、2 つの課題を持つ工程は 2 つの欄に 1 回ずつ", () => { + const e = events(); + const a1 = e.step("impl", { issues: [11] }); + const b1 = e.step("impl", { issues: [12] }); + const all = e.step("check"); + const both = e.step("pr", { issues: [11, 12] }); + const a2 = e.step("merge", { issues: [11] }); + const empty = e.step("sync", { issues: [] }); + const d = deriveDetail(runOf(), e.out, 0); + expect(d.columns.map((c) => c.issue)).toEqual([11, 12, null]); + expect(d.columns[0]!.step_keys).toEqual([a1, both, a2].map((x) => x.idempotency_key)); + expect(d.columns[1]!.step_keys).toEqual([b1, both].map((x) => x.idempotency_key)); + expect(d.columns[2]!.step_keys).toEqual([all, empty].map((x) => x.idempotency_key)); + }); + + it("欄の Pull Request は最後の pr.updated の状態と、チェックの名前ごとの最後の結論を持ち、pr.updated が無ければ unknown", () => { + const e = events(); + e.step("pr", { issues: [11], pull_request: { repo: "org-a/repo-a", number: 7 } }); + e.step("pr", { issues: [12], pull_request: { repo: "org-a/repo-a", number: 8 } }); + e.add("pr.updated", { repo: "Org-A/Repo-A", number: 7, state: "open" }, { source: "github", job: "x" }); + e.add("ci.updated", { repo: "org-a/repo-a", pr_number: 7, name: "factory-check", conclusion: "failure" }, { source: "github" }); + e.add("ci.updated", { repo: "org-a/repo-a", pr_number: 7, name: "factory-check", conclusion: "success" }, { source: "github" }); + e.add("ci.updated", { repo: "org-a/repo-a", pr_number: 7, name: "lint", conclusion: null }, { source: "github" }); + e.add("pr.updated", { repo: "org-a/repo-a", number: 7, state: "merged" }, { source: "github" }); + const d = deriveDetail(runOf(), e.out, 0); + expect(d.columns[0]!.pull_requests).toEqual([ + { + repo: "org-a/repo-a", + number: 7, + url: "https://github.com/org-a/repo-a/pull/7", + state: "merged", + checks: [ + { name: "factory-check", conclusion: "success" }, + { name: "lint", conclusion: null }, + ], + }, + ]); + expect(d.columns[1]!.pull_requests[0]).toMatchObject({ number: 8, state: "unknown", checks: [] }); + }); + + it("承認の欄はゲート 1・ゲート 2・判断の 3 つで、到達・承認・差し戻し・判断の答えで状態が移る", () => { + const e = events(); + expect(gatesOf(e.out)).toEqual({ gate1: { state: "not_reached", at: null }, gate2: { state: "not_reached", at: null }, decision: { state: "none", at: null } }); + // 到達の前の答えは状態を変えない + e.add("gate.answered", { approval_request_id: "a0", kind: "gate1", decision: "approved", answered_by_account_id: "acc-1" }, { source: "api" }); + expect(gatesOf(e.out).gate1.state).toBe("not_reached"); + e.add("gate.reached", { kind: "gate1", pr_url: null, material: "資料" }); + expect(gatesOf(e.out).gate1.state).toBe("waiting"); + e.add("gate.answered", { approval_request_id: "a1", kind: "gate1", decision: "rejected", answered_by_account_id: "acc-1" }, { source: "api" }); + expect(gatesOf(e.out).gate1.state).toBe("rejected"); + e.add("gate.reached", { kind: "gate1", pr_url: null, material: "直した資料" }); + e.add("gate.answered", { approval_request_id: "a2", kind: "gate1", decision: "approved", answered_by_account_id: "acc-1" }, { source: "api" }); + e.add("gate.reached", { kind: "decision", pr_url: null, material: "問い" }); + const mid = gatesOf(e.out); + expect([mid.gate1.state, mid.gate2.state, mid.decision.state]).toEqual(["approved", "not_reached", "waiting"]); + e.add("gate.answered", { approval_request_id: "a3", kind: "decision", answered_by_account_id: "acc-1" }, { source: "api" }); + const after = gatesOf(e.out); + expect(Object.keys(after)).toEqual(["gate1", "gate2", "decision"]); + expect([after.gate1.state, after.decision.state]).toEqual(["approved", "answered"]); + }); + + it("仕上げの欄は振り返りと棚卸しの行を常に持ち、記録の前は null、後は終了コードと秒", () => { + const before = deriveDetail(runOf(), [], 0); + expect(before.phases.map((p) => p.name)).toEqual(["設計", "実装", "検査", "取り込み", "仕上げ"]); + expect(before.phases[4]!.required).toEqual([ + { label: "振り返り", step: "retro", record: null }, + { label: "棚卸し", step: "refine", record: null }, + ]); + expect(before.current_phase).toBeNull(); + const e = events(); + e.add("phase.changed", { phase: "仕上げ", issues: [] }); + const retro = e.step("retro", { exit_code: 0, seconds: 42 }); + const d = deriveDetail(runOf(), e.out, 0); + expect(d.phases[4]!.required[0]!.record).toEqual({ key: retro.idempotency_key, exit_code: 0, seconds: 42 }); + expect(d.phases[4]!.required[1]!.record).toBeNull(); + expect(d.phases[4]!.step_keys).toEqual([retro.idempotency_key]); + }); + + it("phase の無い工程は同じ実行ジョブの直前の phase.changed のフェーズに入り、5 つに無い名前は後ろの欄になる(I5・I7)", () => { + const e = events(); + e.add("phase.changed", { phase: "実装", issues: [11] }, { job: "job-1" }); + e.add("phase.changed", { phase: "準備", issues: [] }, { job: "job-2" }); + const own = e.step("impl", {}, { job: "job-1" }); + const tagged = e.step("check", { phase: "検査" }, { job: "job-2" }); + const other = e.step("prep", {}, { job: "job-2" }); + const d = deriveDetail(runOf(), e.out, 0); + const byKey = new Map(d.steps.map((s) => [s.key, s.phase])); + expect([byKey.get(own.idempotency_key), byKey.get(tagged.idempotency_key), byKey.get(other.idempotency_key)]).toEqual(["実装", "検査", "準備"]); + expect(d.phases.map((p) => p.name)).toEqual(["設計", "実装", "検査", "取り込み", "仕上げ", "準備"]); + expect(d.current_phase).toBe("準備"); + }); + + it("工程の費用は同じ job_id・step・at の usage.recorded の和で、別の at は足さず、無ければ null(I8)", () => { + const e = events(); + const at = "2026-11-04T01:00:00.000Z"; + const s = e.step("impl", {}, { at }); + e.add("usage.recorded", { model: "opus", input_tokens: 10, output_tokens: 2, cost_usd: 0.5, step: "impl" }, { at }); + e.add("usage.recorded", { model: "haiku", input_tokens: 5, output_tokens: 1, cost_usd: 0.25, step: "impl" }, { at }); + e.add("usage.recorded", { model: "opus", input_tokens: 1, output_tokens: 1, cost_usd: 9, step: "impl" }, { at: "2026-11-04T02:00:00.000Z" }); + e.add("usage.recorded", { model: "opus", input_tokens: 1, output_tokens: 1, cost_usd: 1, step: "conductor" }); + const lonely = e.step("pr"); + const d = deriveDetail(runOf(), e.out, 0); + const cost = new Map(d.steps.map((r) => [r.key, r.cost_usd])); + expect(cost.get(s.idempotency_key)).toBe(0.75); + expect(cost.get(lonely.idempotency_key)).toBeNull(); + expect(d.usage).toEqual({ input_tokens: 17, output_tokens: 5, cost_usd: 10.75 }); + }); + + it("足す前の形の step.recorded だけの列でも詳細を導き、has_details は偽", () => { + const e = events(); + e.step("impl"); + const d = deriveDetail(runOf(), e.out, 0); + expect(d.steps[0]).toMatchObject({ phase: null, issues: [], has_details: false, cost_usd: null, pull_request: null }); + expect(d.columns.at(-1)!.step_keys).toHaveLength(1); + }); +}); + +describe("工程の中身(受け入れ条件 7)", () => { + it("触ったファイルのリンク・差分のリンク・決めたこと・出力を返し、Pull Request があれば差分はその files", () => { + const e = events(); + const range = { from: SHA("a"), to: SHA("b") }; + const s = e.step("impl", { + files: [{ path: "factory/a b.ts", added: 3, deleted: 1 }], + commit_range: range, + decisions: [{ question: "どちら", answer: "A", by: "ai" }], + output: { kind: "report", text: "## 作業の報告", truncated: false }, + }); + const { events: stripped } = capSteps(e.out, 2000); + const row = stepRows(stripped)[0]!; + expect(row.has_details).toBe(true); + const detail = deriveStepDetail(runOf(), row, s); + expect(detail.files).toEqual([{ path: "factory/a b.ts", added: 3, deleted: 1, url: `https://github.com/org-a/repo-a/blob/${SHA("b")}/factory/a%20b.ts` }]); + expect(detail.diff_url).toBe(`https://github.com/org-a/repo-a/compare/${SHA("a")}...${SHA("b")}`); + expect(detail.decisions).toEqual([{ question: "どちら", answer: "A", by: "ai" }]); + expect(detail.output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false }); + const withPr = e.step("pr", { pull_request: { repo: "org-a/repo-a", number: 7 } }); + expect(deriveStepDetail(runOf(), stepRows([withPr])[0]!, withPr).diff_url).toBe("https://github.com/org-a/repo-a/pull/7/files"); + }); + + it("足した項目を持たない工程は、それぞれ null(空の配列にしない)", () => { + const e = events(); + const s = e.step("impl"); + const detail = deriveStepDetail(runOf(), stepRows([s])[0]!, s); + expect([detail.files, detail.files_omitted, detail.commit_range, detail.decisions, detail.output, detail.diff_url]).toEqual([null, null, null, null, null, null]); + }); +}); + +describe("詳細の上限(I11)", () => { + it("step.recorded 2,001 件では最も古い 1 件を省き、ほかの種類はすべて残し、ゲートと使用量は全件から導いた値と同じ", () => { + const e = events(); + e.add("gate.reached", { kind: "gate1", pr_url: null, material: "x" }); + e.add("usage.recorded", { model: "opus", input_tokens: 1, output_tokens: 1, cost_usd: 1, step: "conductor" }); + const first = e.step("impl"); + for (let i = 0; i < 2000; i += 1) e.step("impl"); + const capped = capSteps(e.out, 2000); + expect(capped.omittedSteps).toBe(1); + expect(capped.events.some((x) => x.idempotency_key === first.idempotency_key)).toBe(false); + const d = deriveDetail(runOf(), capped.events, capped.omittedSteps); + const whole = deriveDetail(runOf(), e.out, 0); + expect(d.omitted_steps).toBe(1); + expect(d.steps).toHaveLength(2000); + expect(d.gates).toEqual(whole.gates); + expect(d.usage).toEqual(whole.usage); + }); +}); + +describe("棚卸しの記録(受け入れ条件 15)", () => { + it("実行をまたいで at の新しい順に並べる", () => { + const a = events("sr-a"); + const old = a.step("refine", {}, { at: "2026-11-01T00:00:00.000Z" }); + const b = events("sr-b"); + const recent = b.step("refine", { pull_request: { repo: "org-a/repo-a", number: 3 } }, { at: "2026-11-03T00:00:00.000Z" }); + const list = deriveRefinements([ + { sprintRunId: "sr-a", steps: [old] }, + { sprintRunId: "sr-b", steps: [recent] }, + ]); + expect(list.map((r) => r.sprint_run_id)).toEqual(["sr-b", "sr-a"]); + expect(list[0]!.pull_request).toEqual({ repo: "org-a/repo-a", number: 3, url: "https://github.com/org-a/repo-a/pull/3" }); + }); +}); diff --git a/factory/api/src/views/derive.ts b/factory/api/src/views/derive.ts new file mode 100644 index 000000000..ad998ccbe --- /dev/null +++ b/factory/api/src/views/derive.ts @@ -0,0 +1,320 @@ +// スプリントの一覧・詳細・工程の中身・棚卸しの記録の派生(#2172)。進捗の事象の列とスプリントの実行の行から作る純粋な関数で、 +// 時刻も外の世界も読まず、どちらの集約も書き換えない。リンクは GitHub を呼ばずに文字列で作る(I13) +import { CLOSING_STEPS, PHASE_NAMES } from "@ndf-factory/contracts/phases"; +import type { StepDetailBody } from "@ndf-factory/contracts/views"; +import type { CiUpdatedPayload, GateAnsweredPayload, GateReachedPayload, PrUpdatedPayload, StepRecordedPayload, UsageRecordedPayload } from "@ndf-factory/contracts"; +import type { RunLatest, StepRecord } from "../progress/read.ts"; +import type { ProgressEventRecord } from "../progress/store.ts"; +import type { SprintRun } from "../runs/model.ts"; + +// 詳細で読む step.recorded の上限(過去の 11 スプリントの最大 233 件の約 8.6 倍。決定 9) +export const DETAIL_STEP_LIMIT = 2000; + +const GITHUB = "https://github.com"; + +export interface PullRequestLink { + repo: string; + number: number; + url: string; +} + +export interface StepRow { + key: string; + at: string; + job_id: string | null; + step: string; + // payload.phase、無ければ同じ実行ジョブの直前の phase.changed の phase。どちらも無ければ null(I7) + phase: string | null; + issues: number[]; + exit_code: number; + seconds: number; + // 同じ job_id・step・at の usage.recorded の cost_usd の和。無ければ null(I8) + cost_usd: number | null; + summary: string; + pull_request: PullRequestLink | null; + // 工程の中身の本文(触ったファイル・コミットの範囲・決めたこと・出力)を持つか + has_details: boolean; +} + +export interface RequiredStep { + label: string; + step: string; + record: { key: string; exit_code: number; seconds: number } | null; +} + +export interface PhaseView { + name: string; + step_keys: string[]; + required: RequiredStep[]; +} + +export interface PullRequestView extends PullRequestLink { + state: "open" | "closed" | "merged" | "unknown"; + checks: { name: string; conclusion: string | null }[]; +} + +export interface IssueColumn { + // null はスプリント全体の欄 + issue: number | null; + step_keys: string[]; + pull_requests: PullRequestView[]; +} + +export type GateState = "not_reached" | "waiting" | "approved" | "rejected"; +export type DecisionState = "none" | "waiting" | "answered"; + +export interface GatesView { + gate1: { state: GateState; at: string | null }; + gate2: { state: GateState; at: string | null }; + decision: { state: DecisionState; at: string | null }; +} + +export interface SprintRunSummaryView { + sprint_run_id: string; + repository_id: number; + issues: number[]; + state: string; + failure_reason: string | null; + created_at: string; +} + +export interface SprintRunDetail extends SprintRunSummaryView { + repo: string; + ndf_version: string; + execution_mode: string; + job_count: number; + current_phase: string | null; + phases: PhaseView[]; + columns: IssueColumn[]; + steps: StepRow[]; + gates: GatesView; + delegated_decisions: number; + usage: { input_tokens: number; output_tokens: number; cost_usd: number }; + omitted_steps: number; +} + +export type StepDetail = StepRow & StepDetailBody; + +export interface RefinementRecord { + sprint_run_id: string; + key: string; + at: string; + exit_code: number; + summary: string; + pull_request: PullRequestLink | null; +} + +type Rec = ProgressEventRecord & { body_fields?: string[] }; + +const round6 = (n: number) => Math.round(n * 1e6) / 1e6; +const prKey = (repo: string, number: number) => `${repo.toLowerCase()}#${number}`; +const prLink = (ref: { repo: string; number: number } | undefined): PullRequestLink | null => + ref ? { repo: ref.repo, number: ref.number, url: `${GITHUB}/${ref.repo}/pull/${ref.number}` } : null; + +const isStep = (e: Rec): e is StepRecord & Rec => e.type === "step.recorded"; +const BODY_FIELDS = ["files", "commit_range", "decisions", "output"] as const; +const payloadOf = (e: ProgressEventRecord) => e.payload as unknown as T; + +export function summaryOf(run: SprintRun): SprintRunSummaryView { + return { + sprint_run_id: run.sprintRunId, + repository_id: run.repositoryId, + issues: run.issues, + state: run.state, + failure_reason: run.failureReason, + created_at: run.createdAt, + }; +} + +// 一覧の行に足す 4 項目(I4)。phase.changed が 0 件なら current_phase は null(画面は「始まる前」) +export function listRowExtras(run: Pick, latest: RunLatest) { + return { + repo: run.repo, + current_phase: latest.phase ? payloadOf<{ phase: string }>(latest.phase).phase : null, + current_step: latest.step ? latest.step.payload.step : null, + last_event_at: latest.last ? latest.last.at : null, + }; +} + +// 工程の記録の行(order_key の順)。events は order_key の順の列 +export function stepRows(events: Rec[]): StepRow[] { + const cost = new Map(); + for (const e of events) { + if (e.type !== "usage.recorded") continue; + const u = payloadOf(e); + const k = `${e.job_id}\n${u.step}\n${e.at}`; + cost.set(k, (cost.get(k) ?? 0) + u.cost_usd); + } + const phaseOfJob = new Map(); + const rows: StepRow[] = []; + for (const e of events) { + if (e.type === "phase.changed") phaseOfJob.set(e.job_id, payloadOf<{ phase: string }>(e).phase); + if (!isStep(e)) continue; + const p: StepRecordedPayload = e.payload; + const c = cost.get(`${e.job_id}\n${p.step}\n${e.at}`); + rows.push({ + key: e.idempotency_key, + at: e.at, + job_id: e.job_id, + step: p.step, + phase: p.phase ?? phaseOfJob.get(e.job_id) ?? null, + issues: p.issues ?? [], + exit_code: p.exit_code, + seconds: p.seconds, + cost_usd: c === undefined ? null : round6(c), + summary: p.summary, + pull_request: prLink(p.pull_request), + // 読み出しの口は本文を外して持っていた項目の名前を body_fields に残す。外す前の列(テスト)は本文そのものを見る + has_details: e.body_fields !== undefined || BODY_FIELDS.some((k) => p[k] !== undefined), + }); + } + return rows; +} + +// 5 つの名前の順に 5 つ、その後に現れた順に 5 つに無い名前。仕上げは振り返りと棚卸しの行を常に持つ(I10) +function phasesOf(rows: StepRow[], events: Rec[]): PhaseView[] { + const others: string[] = []; + const seen = (name: string | null) => { + if (name !== null && !(PHASE_NAMES as readonly string[]).includes(name) && !others.includes(name)) others.push(name); + }; + for (const e of events) if (e.type === "phase.changed") seen(payloadOf<{ phase: string }>(e).phase); + for (const r of rows) seen(r.phase); + return [...PHASE_NAMES, ...others].map((name) => ({ + name, + step_keys: rows.filter((r) => r.phase === name).map((r) => r.key), + required: + name === "仕上げ" + ? CLOSING_STEPS.map(({ label, step }) => { + const last = rows.filter((r) => r.step === step).at(-1); + return { label, step, record: last ? { key: last.key, exit_code: last.exit_code, seconds: last.seconds } : null }; + }) + : [], + })); +} + +// 課題の欄。実行の課題の順に、続いて実行に無い課題を現れた順に、最後にスプリント全体の欄(I6) +function columnsOf(run: SprintRun, rows: StepRow[], events: Rec[]): IssueColumn[] { + const issues = [...run.issues]; + for (const r of rows) for (const n of r.issues) if (!issues.includes(n)) issues.push(n); + const prState = new Map(); + const checks = new Map>(); + for (const e of events) { + if (e.type === "pr.updated") { + const p = payloadOf(e); + prState.set(prKey(p.repo, p.number), p.state); + } + if (e.type === "ci.updated") { + const p = payloadOf(e); + const k = prKey(p.repo, p.pr_number); + if (!checks.has(k)) checks.set(k, new Map()); + checks.get(k)!.set(p.name, p.conclusion); + } + } + const column = (issue: number | null): IssueColumn => { + const own = rows.filter((r) => (issue === null ? r.issues.length === 0 : r.issues.includes(issue))); + const prs = new Map(); + for (const r of own) { + if (!r.pull_request) continue; + const k = prKey(r.pull_request.repo, r.pull_request.number); + if (prs.has(k)) continue; + prs.set(k, { + ...r.pull_request, + state: prState.get(k) ?? "unknown", + checks: [...(checks.get(k) ?? new Map()).entries()].map(([name, conclusion]) => ({ name, conclusion })), + }); + } + return { issue, step_keys: own.map((r) => r.key), pull_requests: [...prs.values()] }; + }; + return [...issues.map(column), column(null)]; +} + +// 承認の欄(I9)。その種類の最後の gate.reached と、それより後の同じ種類の gate.answered から決める +export function gatesOf(events: Rec[]): GatesView { + const gates: GatesView = { + gate1: { state: "not_reached", at: null }, + gate2: { state: "not_reached", at: null }, + decision: { state: "none", at: null }, + }; + for (const e of events) { + if (e.type === "gate.reached") { + const { kind } = payloadOf(e); + gates[kind] = { state: "waiting", at: e.at }; + } + if (e.type === "gate.answered") { + const p = payloadOf(e); + if (gates[p.kind].state !== "waiting") continue; + if (p.kind === "decision") gates.decision = { state: "answered", at: e.at }; + else gates[p.kind] = { state: p.decision === "rejected" ? "rejected" : "approved", at: e.at }; + } + } + return gates; +} + +function usageOf(events: Rec[]) { + let input = 0; + let output = 0; + let cost = 0; + for (const e of events) { + if (e.type !== "usage.recorded") continue; + const u = payloadOf(e); + input += u.input_tokens; + output += u.output_tokens; + cost += u.cost_usd; + } + return { input_tokens: input, output_tokens: output, cost_usd: round6(cost) }; +} + +// スプリントの詳細。events は order_key の順で、本文を外したもの +export function deriveDetail(run: SprintRun, events: Rec[], omittedSteps: number): SprintRunDetail { + const rows = stepRows(events); + const lastPhase = events.filter((e) => e.type === "phase.changed").at(-1); + return { + ...summaryOf(run), + repo: run.repo, + ndf_version: run.ndfVersion, + execution_mode: run.executionMode, + job_count: run.jobCount, + current_phase: lastPhase ? payloadOf<{ phase: string }>(lastPhase).phase : null, + phases: phasesOf(rows, events), + columns: columnsOf(run, rows, events), + steps: rows, + gates: gatesOf(events), + delegated_decisions: events.filter((e) => e.type === "decision.delegated").length, + usage: usageOf(events), + omitted_steps: omittedSteps, + }; +} + +const blobUrl = (repo: string, sha: string, path: string) => `${GITHUB}/${repo}/blob/${sha}/${path.split("/").map(encodeURIComponent).join("/")}`; + +// 工程の中身。row は同じ工程の行(events から導いたもの)、record は本文を含む step.recorded +export function deriveStepDetail(run: Pick, row: StepRow, record: StepRecord): StepDetail { + const p = record.payload; + const range = p.commit_range ?? null; + const diff = row.pull_request ? `${row.pull_request.url}/files` : range ? `${GITHUB}/${run.repo}/compare/${range.from}...${range.to}` : null; + return { + ...row, + files: p.files ? p.files.map((f) => ({ ...f, url: range ? blobUrl(run.repo, range.to, f.path) : null })) : null, + files_omitted: p.files_omitted ?? null, + commit_range: range, + decisions: p.decisions ?? null, + output: p.output ?? null, + diff_url: diff, + }; +} + +// そのリポジトリの実行の棚卸しの工程(step が refine)を at の新しい順に +export function deriveRefinements(perRun: { sprintRunId: string; steps: StepRecord[] }[]): RefinementRecord[] { + return perRun + .flatMap(({ sprintRunId, steps }) => + steps.map((e) => ({ + sprint_run_id: sprintRunId, + key: e.idempotency_key, + at: e.at, + exit_code: e.payload.exit_code, + summary: e.payload.summary, + pull_request: prLink(e.payload.pull_request), + })), + ) + .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); +} diff --git a/factory/contracts/openapi/accounts.yaml b/factory/contracts/openapi/accounts.yaml index da7b28aa5..45624838a 100644 --- a/factory/contracts/openapi/accounts.yaml +++ b/factory/contracts/openapi/accounts.yaml @@ -70,7 +70,7 @@ paths: get: tags: [accounts] operationId: getAccount - summary: アカウントのプラン・実行の形態・使える量・席 + summary: アカウントの料金プラン・実行の形態・使える量・席 description: 個人のアカウントの持ち主か、チームの席を持つ利用者だけが読める。 responses: "200": @@ -271,7 +271,7 @@ components: enum: [standard, premium] ExecutionMode: type: string - description: local(手元のランナー)・byok(キーの持ち込み)・managed(サービスの Bedrock) + description: local(手元のマシン)・byok(キーの持ち込み)・managed(サービスの Bedrock) enum: [local, byok, managed] Idp: type: string diff --git a/factory/contracts/openapi/organizer.yaml b/factory/contracts/openapi/organizer.yaml new file mode 100644 index 000000000..4c9eae1e7 --- /dev/null +++ b/factory/contracts/openapi/organizer.yaml @@ -0,0 +1,267 @@ +openapi: 3.1.0 +info: + title: Neural Development Factory のオーガナイザーの API + version: 0.1.0 + description: >- + 同じリポジトリで並行する実行の承認と問いの一覧、まとめた配布の履歴、止まった配布を解く操作(#2439)。 + どの経路も Cognito の access token を Authorization: Bearer で要る。アカウントのリポジトリでなければ 403、登録が無ければ 404。 + 一覧と履歴は読むだけで、承認の依頼の答えを確定する経路はこのファイルに無い(I66)。 + contact: + name: devbasex/ai-plugins + url: https://github.com/devbasex/ai-plugins + license: + name: MIT + url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE +servers: + - url: https://api.example.com + description: API の入口(値はインフラが FACTORY_API_URL で渡す) +tags: + - name: organizer + description: Factory のオーガナイザー +security: + - cognito: [] +paths: + /accounts/{accountId}/repositories/{repositoryId}/approval-requests: + get: + tags: [organizer] + operationId: listRepositoryApprovalRequests + summary: リポジトリの承認と問いの一覧 + description: >- + 同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 + 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。 + parameters: + - $ref: "#/components/parameters/AccountId" + - $ref: "#/components/parameters/RepositoryId" + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/ApprovalRequestList" + "403": + $ref: "#/components/responses/E403" + "404": + $ref: "#/components/responses/E404" + /accounts/{accountId}/repositories/{repositoryId}/releases: + get: + tags: [organizer] + operationId: listRepositoryReleases + summary: まとめた配布の履歴 + description: 新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。 + parameters: + - $ref: "#/components/parameters/AccountId" + - $ref: "#/components/parameters/RepositoryId" + - name: limit + in: query + required: false + schema: { type: integer, minimum: 1, maximum: 100 } + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/ReleaseHistory" + "400": + $ref: "#/components/responses/E400" + "403": + $ref: "#/components/responses/E403" + "404": + $ref: "#/components/responses/E404" + /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve: + post: + tags: [organizer] + operationId: resolveStalledRelease + summary: 止まった配布を解く + description: >- + 配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 + 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。 + parameters: + - $ref: "#/components/parameters/AccountId" + - $ref: "#/components/parameters/RepositoryId" + - name: seq + in: path + required: true + schema: { type: integer, minimum: 1 } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveRequest" + responses: + "202": + description: 受けた + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveResult" + "400": + $ref: "#/components/responses/E400" + "403": + $ref: "#/components/responses/E403" + "404": + $ref: "#/components/responses/E404" + "409": + $ref: "#/components/responses/E409" +components: + securitySchemes: + cognito: + type: http + scheme: bearer + description: Cognito の access token + parameters: + AccountId: + name: accountId + in: path + required: true + schema: + type: string + RepositoryId: + name: repositoryId + in: path + required: true + schema: + type: string + responses: + E400: + description: 要求の形が違う + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + E403: + description: 権限が無い(他人のアカウントと同じ本文) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + E404: + description: 見つからない + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + E409: + description: 状態が合わない + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: { type: string } + message: { type: string } + details: { type: object } + OrganizerSummary: + type: object + required: [current_seq, wait_reason, wait_detail] + properties: + current_seq: { type: ["integer", "null"], description: 流れているまとめた配布の連番。null は流れていない } + wait_reason: { type: ["string", "null"], enum: [external_release_open, legacy_run, null] } + wait_detail: { type: ["string", "null"], description: 待ちの相手(外の Pull Request の URL・古い版の実行の数) } + ReleaseLane: + type: object + required: [lane, requested, prs] + properties: + lane: { type: string } + requested: { type: boolean } + prs: { type: array, items: { type: integer } } + ReleaseSummary: + type: object + required: [seq, k, sha, lanes, added_prs, choices] + properties: + seq: { type: integer } + k: { type: ["integer", "null"], description: 最後の提示の番号。提示の前に止まった配布は null } + sha: { type: ["string", "null"] } + lanes: { type: array, items: { $ref: "#/components/schemas/ReleaseLane" } } + added_prs: { type: array, items: { type: integer } } + choices: { type: array, items: { type: string, enum: [continue, reject] } } + ApprovalRequestItem: + type: object + required: [kind, created_at] + properties: + kind: { type: string, enum: [gate1, gate2, decision, loop_pending, release_stalled] } + sprint_run_id: { type: string } + loop_id: { type: string } + approval_request_id: { type: string } + pr_url: { type: ["string", "null"] } + number: { type: integer, description: ループの人の判断待ちの課題の番号 } + reason: { type: string } + created_at: { type: string, format: date-time } + release: { $ref: "#/components/schemas/ReleaseSummary" } + ApprovalRequestList: + type: object + required: [organizer, items] + properties: + organizer: { $ref: "#/components/schemas/OrganizerSummary" } + items: { type: array, items: { $ref: "#/components/schemas/ApprovalRequestItem" } } + Presentation: + type: object + required: [k, sha, version, prs, lanes, added_prs, at] + properties: + k: { type: integer } + sha: { type: string } + version: { type: string } + prs: { type: array, items: { type: integer } } + lanes: + type: array + items: + type: object + required: [lane, requested] + properties: + lane: { type: string } + requested: { type: boolean } + added_prs: { type: array, items: { type: integer } } + at: { type: string, format: date-time } + ReleaseResult: + type: object + required: [version, prod_pr_url, sha, prs] + properties: + version: { type: ["string", "null"] } + prod_pr_url: { type: ["string", "null"] } + sha: { type: ["string", "null"] } + prs: { type: array, items: { type: integer } } + ReleaseLogEntry: + type: object + required: [at, kind, detail] + properties: + at: { type: string, format: date-time } + kind: { type: string, enum: [waited, composed, presented, represented, confirmed, stalled, resolved, released, rejected] } + detail: { type: object } + ReleaseBatch: + type: object + required: [seq, state, release_run_id, presentations, confirmed_k, result, log] + properties: + seq: { type: integer } + state: { type: string, enum: [composed, presented, represented, confirmed, stalled, released, rejected] } + release_run_id: { type: string } + presentations: { type: array, items: { $ref: "#/components/schemas/Presentation" } } + confirmed_k: { type: ["integer", "null"] } + result: { oneOf: [{ $ref: "#/components/schemas/ReleaseResult" }, { type: "null" }] } + log: { type: array, items: { $ref: "#/components/schemas/ReleaseLogEntry" } } + ReleaseHistory: + type: object + required: [items] + properties: + items: { type: array, items: { $ref: "#/components/schemas/ReleaseBatch" } } + ResolveRequest: + type: object + required: [choice] + properties: + choice: { type: string, enum: [continue, reject] } + body: { type: string, maxLength: 4000 } + ResolveResult: + type: object + required: [seq, state] + properties: + seq: { type: integer } + state: { type: string, enum: [composed, represented, confirmed, rejected] } diff --git a/factory/contracts/openapi/runs.yaml b/factory/contracts/openapi/runs.yaml index fe4d73b77..9aa7ff265 100644 --- a/factory/contracts/openapi/runs.yaml +++ b/factory/contracts/openapi/runs.yaml @@ -293,7 +293,7 @@ paths: tags: [sprint-runs] operationId: listSprintRuns summary: スプリントの実行の一覧 - description: 新しい順に 50 件。repository_id で絞れる。 + description: 新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。 parameters: - name: accountId in: path @@ -315,12 +315,115 @@ paths: $ref: "#/components/schemas/SprintRunList" "403": $ref: "#/components/responses/E403" + "503": + $ref: "#/components/responses/E503" + /accounts/{accountId}/sprint-runs/{sprintRunId}: + get: + tags: [sprint-runs] + operationId: getSprintRunDetail + summary: スプリントの詳細 + description: >- + フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 + 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の経路で 1 件ずつ読む。 + step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 + 無い実行・アカウントに属さない実行も、他人のアカウントと同じ本文の 403。 + parameters: + - name: accountId + in: path + required: true + schema: + type: string + - name: sprintRunId + in: path + required: true + schema: + type: string + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/SprintRunDetail" + "403": + $ref: "#/components/responses/E403" + "503": + $ref: "#/components/responses/E503" + /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}: + get: + tags: [sprint-runs] + operationId: getStepDetail + summary: 工程の中身 + description: >- + 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 + stepKey は事象の idempotency_key(URL の符号化をする)。記録の無い項目は null。 + 無い工程・別の実行の工程も、他人のアカウントと同じ本文の 403。 + parameters: + - name: accountId + in: path + required: true + schema: + type: string + - name: sprintRunId + in: path + required: true + schema: + type: string + - name: stepKey + in: path + required: true + schema: + type: string + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/StepDetail" + "403": + $ref: "#/components/responses/E403" + "503": + $ref: "#/components/responses/E503" + /accounts/{accountId}/repositories/{repositoryId}/backlog: + get: + tags: [repositories] + operationId: getBacklog + summary: バックログ + description: >- + リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 + 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリも、他人のアカウントと同じ本文の 403。 + parameters: + - name: accountId + in: path + required: true + schema: + type: string + - name: repositoryId + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Backlog" + "400": + $ref: "#/components/responses/E400" + "403": + $ref: "#/components/responses/E403" + "503": + $ref: "#/components/responses/E503" /accounts/{accountId}/sprint-runs/{sprintRunId}/cancel: post: tags: [sprint-runs] operationId: cancelSprintRun summary: スプリントの実行を取り消す - description: 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。 + description: 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。 parameters: - name: accountId in: path @@ -431,7 +534,7 @@ paths: tags: [repositories] operationId: updateRepository summary: リポジトリの同時の実行の数を宣言する - description: プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。 + description: 料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。 parameters: - name: accountId in: path @@ -487,6 +590,50 @@ paths: $ref: "#/components/responses/E404" "409": $ref: "#/components/responses/E409" + /run-jobs/organizer/answers: + get: + tags: [run-jobs] + operationId: getRunJobOrganizerAnswers + summary: 配布の依頼の答えと配布済みの範囲 + description: >- + 実行ジョブの token で認証し、そのジョブの実行のレーン(ループならループのすべての実行)の配布の依頼の答えと、 + リポジトリの配布済みの範囲を返す(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へそのまま書く。 + security: + - runJob: [] + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizerAnswers" + "401": + $ref: "#/components/responses/E401" + "403": + $ref: "#/components/responses/E403" + /run-jobs/organizer/batch: + get: + tags: [run-jobs] + operationId: getRunJobOrganizerBatch + summary: 配布の実行のまとめた配布の中身 + description: >- + 配布の実行のジョブの token だけが読める(ほかの実行は 403)。レーンの一覧と依頼、提示、選択肢と、 + このジョブの手(step)を返す。ランナーは NDF の置き場の batch.json へそのまま書く(#2439)。 + security: + - runJob: [] + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizerBatch" + "401": + $ref: "#/components/responses/E401" + "403": + $ref: "#/components/responses/E403" + "404": + $ref: "#/components/responses/E404" components: securitySchemes: cognito: @@ -680,6 +827,235 @@ components: state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] } failure_reason: { type: ["string", "null"] } created_at: { type: string, format: date-time } + repo: { type: string, description: owner/name } + current_phase: + type: ["string", "null"] + description: 最後の phase.changed のフェーズの名前。null は始まる前。5 つの名前に無い値もそのまま返す + current_step: { type: ["string", "null"], description: 最後の step.recorded のステップ } + last_event_at: { type: ["string", "null"], format: date-time, description: 最後の事象の時刻 } + PullRequestLink: + type: object + required: [repo, number, url] + properties: + repo: { type: string } + number: { type: integer } + url: { type: string, format: uri } + StepRow: + type: object + required: [key, at, job_id, step, phase, issues, exit_code, seconds, cost_usd, summary, pull_request, has_details] + properties: + key: { type: string, description: 事象の idempotency_key } + at: { type: string, format: date-time } + job_id: { type: ["string", "null"] } + step: { type: string } + phase: { type: ["string", "null"], description: 工程のフェーズ。無ければ同じ実行ジョブの直前の phase.changed から導く } + issues: { type: array, items: { type: integer }, description: 空はスプリント全体の工程 } + exit_code: { type: integer } + seconds: { type: number } + cost_usd: { type: ["number", "null"], description: 同じ実行ジョブ・ステップ・時刻の usage.recorded の和。結べなければ null } + summary: { type: string } + pull_request: + oneOf: + - $ref: "#/components/schemas/PullRequestLink" + - type: "null" + has_details: { type: boolean, description: 工程の中身の本文を持つか } + RequiredStep: + type: object + required: [label, step, record] + properties: + label: { type: string } + step: { type: string } + record: + oneOf: + - type: object + required: [key, exit_code, seconds] + properties: + key: { type: string } + exit_code: { type: integer } + seconds: { type: number } + - type: "null" + GateView: + type: object + required: [state, at] + properties: + state: { type: string, enum: [not_reached, waiting, approved, rejected] } + at: { type: ["string", "null"], format: date-time } + SprintRunDetail: + type: object + required: + - sprint_run_id + - repository_id + - issues + - state + - failure_reason + - created_at + - repo + - ndf_version + - execution_mode + - job_count + - current_phase + - phases + - columns + - steps + - gates + - delegated_decisions + - usage + - omitted_steps + properties: + sprint_run_id: { type: string } + repository_id: { type: integer } + issues: { type: array, items: { type: integer } } + state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] } + failure_reason: { type: ["string", "null"] } + created_at: { type: string, format: date-time } + repo: { type: string } + ndf_version: { type: string } + execution_mode: { type: string, enum: [local, byok, managed] } + job_count: { type: integer } + current_phase: { type: ["string", "null"] } + phases: + type: array + description: 5 つの名前(設計・実装・検査・取り込み・仕上げ)の順に、続いて現れた順に 5 つに無い名前。仕上げだけが required(振り返り・棚卸し)を持つ + items: + type: object + required: [name, step_keys, required] + properties: + name: { type: string } + step_keys: { type: array, items: { type: string } } + required: { type: array, items: { $ref: "#/components/schemas/RequiredStep" } } + columns: + type: array + description: 実行の課題の順に課題の欄、最後にスプリント全体の欄(issue が null) + items: + type: object + required: [issue, step_keys, pull_requests] + properties: + issue: { type: ["integer", "null"] } + step_keys: { type: array, items: { type: string } } + pull_requests: + type: array + items: + type: object + required: [repo, number, url, state, checks] + properties: + repo: { type: string } + number: { type: integer } + url: { type: string, format: uri } + state: { type: string, enum: [open, closed, merged, unknown] } + checks: + type: array + items: + type: object + required: [name, conclusion] + properties: + name: { type: string } + conclusion: { type: ["string", "null"], description: null は実行中 } + steps: { type: array, items: { $ref: "#/components/schemas/StepRow" } } + gates: + type: object + required: [gate1, gate2, decision] + properties: + gate1: { $ref: "#/components/schemas/GateView" } + gate2: { $ref: "#/components/schemas/GateView" } + decision: + type: object + required: [state, at] + properties: + state: { type: string, enum: [none, waiting, answered] } + at: { type: ["string", "null"], format: date-time } + delegated_decisions: { type: integer, description: AI が代わりに判断した問い合わせの数 } + usage: + type: object + required: [input_tokens, output_tokens, cost_usd] + properties: + input_tokens: { type: integer } + output_tokens: { type: integer } + cost_usd: { type: number } + omitted_steps: { type: integer, description: 上限で省いた古い工程の数 } + StepDetail: + allOf: + - $ref: "#/components/schemas/StepRow" + - type: object + required: [files, files_omitted, commit_range, decisions, output, diff_url] + properties: + files: + type: ["array", "null"] + description: null は記録なし、空の配列は触らなかった + items: + type: object + required: [path, added, deleted, url] + properties: + path: { type: string } + added: { type: ["integer", "null"], description: null はバイナリ } + deleted: { type: ["integer", "null"], description: null はバイナリ } + url: { type: ["string", "null"], format: uri, description: コミットの範囲が無ければ null } + files_omitted: { type: ["integer", "null"] } + commit_range: + oneOf: + - type: object + required: [from, to] + properties: + from: { type: string } + to: { type: string } + - type: "null" + decisions: + type: ["array", "null"] + items: + type: object + required: [question, answer, by] + properties: + question: { type: string } + answer: { type: string } + by: { type: string, enum: [ai, user] } + output: + oneOf: + - type: object + required: [kind, text, truncated] + properties: + kind: { type: string, enum: [report, log] } + text: { type: string } + truncated: { type: boolean } + - type: "null" + diff_url: { type: ["string", "null"], format: uri } + Backlog: + type: object + required: [repository_id, repo, ranking, refinements] + properties: + repository_id: { type: integer } + repo: { type: string } + ranking: + description: null は順位の表が無い + oneOf: + - type: object + required: [milestone, rows] + properties: + milestone: { type: string } + rows: + type: array + items: + type: object + required: [rank, number, title] + properties: + rank: { type: integer, minimum: 1 } + number: { type: integer } + title: { type: string } + - type: "null" + refinements: + type: array + description: 棚卸しの工程(step が refine)の記録を新しい順に + items: + type: object + required: [sprint_run_id, key, at, exit_code, summary, pull_request] + properties: + sprint_run_id: { type: string } + key: { type: string } + at: { type: string, format: date-time } + exit_code: { type: integer } + summary: { type: string } + pull_request: + oneOf: + - $ref: "#/components/schemas/PullRequestLink" + - type: "null" CancelResult: type: object required: [state] @@ -714,3 +1090,51 @@ components: properties: url: { type: string, format: uri } expires_at: { type: string, format: date-time } + OrganizerAnswer: + type: object + required: [request_id, result, version, release, reason, at] + properties: + request_id: { type: string } + result: { type: string, enum: [released, rejected] } + version: { type: ["string", "null"] } + release: { type: ["string", "null"], description: 本番の Pull Request の URL } + reason: { type: ["string", "null"] } + at: { type: string, format: date-time } + OrganizerRange: + type: object + required: [seq, sha, version, prs, lanes] + properties: + seq: { type: integer } + sha: { type: ["string", "null"] } + version: { type: ["string", "null"] } + prs: { type: array, items: { type: integer } } + lanes: { type: array, items: { type: string } } + OrganizerAnswers: + type: object + required: [answers, ranges] + properties: + answers: { type: array, items: { $ref: "#/components/schemas/OrganizerAnswer" } } + ranges: { type: array, items: { $ref: "#/components/schemas/OrganizerRange" } } + OrganizerBatchLane: + type: object + required: [lane, requests, issues] + properties: + lane: { type: string } + requests: + type: array + items: + type: object + required: [id, prs] + properties: + id: { type: string } + prs: { type: array, items: { type: integer } } + issues: { type: array, items: { type: integer } } + OrganizerBatch: + type: object + required: [seq, step, lanes, presentations, choices] + properties: + seq: { type: integer } + step: { type: string, enum: [present, represent, "confirm:1", "confirm:2", continue] } + lanes: { type: array, items: { $ref: "#/components/schemas/OrganizerBatchLane" } } + presentations: { type: array, items: { type: object } } + choices: { type: array, items: { type: string, enum: [continue, reject] } } diff --git a/factory/contracts/package.json b/factory/contracts/package.json index d4f052f41..99b1d6f47 100644 --- a/factory/contracts/package.json +++ b/factory/contracts/package.json @@ -3,6 +3,11 @@ "private": true, "type": "module", "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./phases": "./src/phases.ts", + "./views": "./src/views.ts" + }, "scripts": { "generate": "node scripts/generate.ts", "typecheck": "tsc --noEmit", diff --git a/factory/contracts/schemas/local-runner-declaration.schema.json b/factory/contracts/schemas/local-runner-declaration.schema.json index c1759a7e9..97984be08 100644 --- a/factory/contracts/schemas/local-runner-declaration.schema.json +++ b/factory/contracts/schemas/local-runner-declaration.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/devbasex/ai-plugins/blob/develop/factory/contracts/schemas/local-runner-declaration.schema.json", "title": "LocalRunnerDeclaration", - "description": "手元のランナーが登録と心拍で申告する、同時に動かす実行ジョブの数(#2347)。登録と心拍の本文はこの形を含む", + "description": "手元のマシンが登録と心拍で申告する、同時に動かす実行ジョブの数(#2347)。登録と心拍の本文はこの形を含む", "type": "object", "additionalProperties": false, "required": ["runner_id"], diff --git a/factory/contracts/schemas/progress-event.schema.json b/factory/contracts/schemas/progress-event.schema.json index 2d595114a..88a7ef854 100644 --- a/factory/contracts/schemas/progress-event.schema.json +++ b/factory/contracts/schemas/progress-event.schema.json @@ -19,7 +19,12 @@ { "$ref": "#/$defs/IssueRequestResolvedEvent" }, { "$ref": "#/$defs/SectionStartedEvent" }, { "$ref": "#/$defs/LoopLapClosedEvent" }, - { "$ref": "#/$defs/LoopStoppedEvent" } + { "$ref": "#/$defs/LoopStoppedEvent" }, + { "$ref": "#/$defs/ReleaseRequestedEvent" }, + { "$ref": "#/$defs/ReleasePresentedEvent" }, + { "$ref": "#/$defs/ReleaseProgressEvent" }, + { "$ref": "#/$defs/ReleaseSettledEvent" }, + { "$ref": "#/$defs/ReleaseAnsweredEvent" } ], "$defs": { "Identifier": { @@ -80,7 +85,7 @@ "required": ["ndf_version", "start"], "properties": { "ndf_version": { "type": "string", "minLength": 1 }, - "start": { "type": "string", "enum": ["new", "resume", "loop"] } + "start": { "type": "string", "enum": ["new", "resume", "loop", "release"] } } }, "PhaseChangedPayload": { @@ -88,7 +93,7 @@ "additionalProperties": false, "required": ["phase", "issues"], "properties": { - "phase": { "description": "フェーズの名前。集合は #2172 が決めるまで列挙にしない", "type": "string", "minLength": 1 }, + "phase": { "description": "フェーズの名前。5 つの名前(設計・実装・検査・取り込み・仕上げ)は src/phases.ts が持ち、ここでは列挙で縛らない", "type": "string", "minLength": 1 }, "issues": { "type": "array", "items": { "type": "integer", "minimum": 1 } } } }, @@ -111,7 +116,74 @@ "exit_code": { "type": "integer" }, "seconds": { "type": "number", "minimum": 0 }, "summary": { "type": "string" }, - "pull_request": { "$ref": "#/$defs/PullRequestRef" } + "pull_request": { "$ref": "#/$defs/PullRequestRef" }, + "phase": { + "description": "その工程が属するフェーズの名前。省いたときは同じ実行ジョブの直前の phase.changed から導く", + "type": "string", + "minLength": 1 + }, + "issues": { + "description": "その工程が受け持つ課題。省いたときも空のときもスプリント全体の工程", + "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "maxItems": 50 + }, + "files": { + "description": "その工程の時間の窓のコミットが触ったファイル。省いたときは記録なし、空の配列は触らなかった", + "type": "array", + "items": { "$ref": "#/$defs/StepFile" }, + "maxItems": 100 + }, + "files_omitted": { "description": "上限で files から外した数", "type": "integer", "minimum": 0 }, + "commit_range": { "$ref": "#/$defs/CommitRange" }, + "decisions": { + "description": "その工程の間に決めたこと。省いたときは記録なし、空の配列は決めたことが無かった", + "type": "array", + "items": { "$ref": "#/$defs/StepDecision" }, + "maxItems": 20 + }, + "output": { "$ref": "#/$defs/StepOutput" } + } + }, + "StepFile": { + "type": "object", + "additionalProperties": false, + "required": ["path", "added", "deleted"], + "properties": { + "path": { "type": "string", "minLength": 1, "maxLength": 512 }, + "added": { "description": "null はバイナリ(行数が無い)", "type": ["integer", "null"], "minimum": 0 }, + "deleted": { "description": "null はバイナリ(行数が無い)", "type": ["integer", "null"], "minimum": 0 } + } + }, + "CommitRange": { + "description": "時間の窓のコミットの最初の親と最後のコミット", + "type": "object", + "additionalProperties": false, + "required": ["from", "to"], + "properties": { + "from": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "to": { "type": "string", "pattern": "^[0-9a-f]{40}$" } + } + }, + "StepDecision": { + "type": "object", + "additionalProperties": false, + "required": ["question", "answer", "by"], + "properties": { + "question": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "answer": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "by": { "type": "string", "enum": ["ai", "user"] } + } + }, + "StepOutput": { + "description": "作業の報告(report)かログ(log)。text は UTF-8 で 32,768 バイト以下で、truncated は上限で切ったこと", + "type": "object", + "additionalProperties": false, + "required": ["kind", "text", "truncated"], + "properties": { + "kind": { "type": "string", "enum": ["report", "log"] }, + "text": { "type": "string", "maxLength": 32768 }, + "truncated": { "type": "boolean" } } }, "AttentionPayload": { @@ -131,7 +203,14 @@ "kind": { "$ref": "#/$defs/GateKind" }, "pr_url": { "type": ["string", "null"], "format": "uri" }, "material": { "description": "承認資料の本文", "type": "string" }, - "loop": { "$ref": "#/$defs/LoopRef" } + "loop": { "$ref": "#/$defs/LoopRef" }, + "choices": { + "description": "答えの選択肢の制限。配布の実行が main へのマージの後に判断で止まったときは continue だけを載せる(#2439 の I63)", + "type": "array", + "items": { "type": "string", "enum": ["continue", "reject"] }, + "minItems": 1, + "uniqueItems": true + } } }, "LoopRef": { @@ -249,9 +328,9 @@ "result": { "type": "string", "enum": ["gate", "stopped", "done", "failed"] }, "summary": { "type": "string" }, "reason": { - "description": "stopped の理由。section_limit は区間の上限、usage_limit は local の利用上限", + "description": "stopped の理由。section_limit は区間の上限、usage_limit は local の利用上限。release_requested は gate の理由で、手動の実行が配布の依頼を出して答えを待つ(#2439)", "type": "string", - "enum": ["section_limit", "usage_limit"] + "enum": ["section_limit", "usage_limit", "release_requested"] }, "resets_at": { "description": "usage_limit のときだけ。利用上限のリセット時刻(読めなければ null)", @@ -336,6 +415,107 @@ "then": { "required": ["issue"], "not": { "required": ["reason"] } }, "else": { "required": ["reason"], "not": { "required": ["issue"] } } }, + "ReleaseLaneName": { + "description": "並列のレーンの名前(Factory では loop-<ループの ID> / run-<実行の ID>。#2439)", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,140}$" + }, + "ReleaseRequestId": { + "description": "NDF が作る配布の依頼の id(<レーンの名前>-r<乱数>)", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,160}$" + }, + "ReleaseCommit": { + "type": "string", + "pattern": "^[0-9a-f]{7,64}$" + }, + "ReleaseVersion": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "NumberList": { + "type": "array", + "items": { "type": "integer", "minimum": 1 } + }, + "ReleaseRequestedPayload": { + "description": "実行が本番の配布の代わりに出した配布の依頼(#2439)。NDF の置き場の requests/ の写し", + "type": "object", + "additionalProperties": false, + "required": ["request_id", "lane", "prs", "issues", "ndf_version", "requested_at"], + "properties": { + "request_id": { "$ref": "#/$defs/ReleaseRequestId" }, + "lane": { "$ref": "#/$defs/ReleaseLaneName" }, + "prs": { "$ref": "#/$defs/NumberList" }, + "issues": { "$ref": "#/$defs/NumberList" }, + "ndf_version": { "type": "string", "minLength": 1, "maxLength": 200 }, + "requested_at": { "type": "string", "format": "date-time" } + } + }, + "ReleasePresentedLane": { + "type": "object", + "additionalProperties": false, + "required": ["lane", "requested"], + "properties": { + "lane": { "$ref": "#/$defs/ReleaseLaneName" }, + "requested": { "description": "そのレーンが配布の依頼を出したか", "type": "boolean" } + } + }, + "ReleasePresentedPayload": { + "description": "配布の実行が承認資料をそろえた提示(#2439)。auto は MVV 判定が承認ゲート 2 を通したとき真", + "type": "object", + "additionalProperties": false, + "required": ["seq", "k", "sha", "version", "prs", "lanes", "added_prs", "auto"], + "properties": { + "seq": { "type": "integer", "minimum": 1 }, + "k": { "description": "提示の番号。出し直しは 2", "type": "integer", "enum": [1, 2] }, + "sha": { "$ref": "#/$defs/ReleaseCommit" }, + "version": { "$ref": "#/$defs/ReleaseVersion" }, + "prs": { "$ref": "#/$defs/NumberList" }, + "lanes": { "type": "array", "items": { "$ref": "#/$defs/ReleasePresentedLane" } }, + "added_prs": { "$ref": "#/$defs/NumberList" }, + "auto": { "type": "boolean" } + } + }, + "ReleaseProgressPayload": { + "description": "配布の実行の本番の配布が届いた段(#2439)", + "type": "object", + "additionalProperties": false, + "required": ["seq", "stage"], + "properties": { + "seq": { "type": "integer", "minimum": 1 }, + "stage": { "type": "string", "enum": ["release_branch", "develop_merged", "prod_pr_opened", "main_merged", "tagged"] } + } + }, + "ReleaseSettledPayload": { + "description": "配布の実行の結果(#2439)", + "type": "object", + "additionalProperties": false, + "required": ["seq", "result", "prs", "lanes"], + "properties": { + "seq": { "type": "integer", "minimum": 1 }, + "result": { "type": "string", "enum": ["released", "failed"] }, + "version": { "$ref": "#/$defs/ReleaseVersion" }, + "prod_pr_url": { "type": "string", "format": "uri" }, + "sha": { "$ref": "#/$defs/ReleaseCommit" }, + "prs": { "$ref": "#/$defs/NumberList" }, + "lanes": { "type": "array", "items": { "$ref": "#/$defs/ReleaseLaneName" } }, + "reason": { "type": "string", "maxLength": 4000 } + } + }, + "ReleaseAnsweredPayload": { + "description": "Factory のオーガナイザーが範囲の実行へ返した配布の答え(#2439)", + "type": "object", + "additionalProperties": false, + "required": ["seq", "result"], + "properties": { + "seq": { "type": "integer", "minimum": 1 }, + "request_id": { "$ref": "#/$defs/ReleaseRequestId" }, + "result": { "type": "string", "enum": ["released", "rejected"] }, + "version": { "$ref": "#/$defs/ReleaseVersion" }, + "prod_pr_url": { "type": "string", "format": "uri" } + } + }, "JobStartedEvent": { "allOf": [{ "$ref": "#/$defs/EventCommon" }], "type": "object", @@ -495,6 +675,56 @@ "payload": { "$ref": "#/$defs/LoopStoppedPayload" } }, "unevaluatedProperties": false + }, + "ReleaseRequestedEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.requested" }, + "payload": { "$ref": "#/$defs/ReleaseRequestedPayload" } + }, + "unevaluatedProperties": false + }, + "ReleasePresentedEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.presented" }, + "payload": { "$ref": "#/$defs/ReleasePresentedPayload" } + }, + "unevaluatedProperties": false + }, + "ReleaseProgressEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.progress" }, + "payload": { "$ref": "#/$defs/ReleaseProgressPayload" } + }, + "unevaluatedProperties": false + }, + "ReleaseSettledEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.settled" }, + "payload": { "$ref": "#/$defs/ReleaseSettledPayload" } + }, + "unevaluatedProperties": false + }, + "ReleaseAnsweredEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.answered" }, + "payload": { "$ref": "#/$defs/ReleaseAnsweredPayload" } + }, + "unevaluatedProperties": false } } } diff --git a/factory/contracts/schemas/run-job-instruction.schema.json b/factory/contracts/schemas/run-job-instruction.schema.json index 78d47b5fb..435eddaf0 100644 --- a/factory/contracts/schemas/run-job-instruction.schema.json +++ b/factory/contracts/schemas/run-job-instruction.schema.json @@ -20,7 +20,7 @@ "JOB_ID": { "description": "冪等の鍵の頭", "type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$" }, "REPO": { "description": "取り込むリポジトリ(owner/name)", "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, "NDF_VERSION": { "description": "スプリントの開始で固定した NDF の版", "type": "string", "minLength": 1 }, - "START": { "description": "new は課題から始め、resume は保存した状態から続け、loop は開発ループの周回を新しく始める", "type": "string", "enum": ["new", "resume", "loop"] }, + "START": { "description": "new は課題から始め、resume は保存した状態から続け、loop は開発ループの周回を新しく始め、release は配布の実行の手(RELEASE_STEP)を流す", "type": "string", "enum": ["new", "resume", "loop", "release"] }, "ISSUES": { "description": "スプリントへ入れる課題の番号(カンマ区切り)", "type": "string", "pattern": "^[1-9][0-9]*(,[1-9][0-9]*)*$" }, "APPROVAL_REQUEST_ID": { "description": "答えの出た承認の依頼の識別子。答えの本文は載せない。無い resume は答えを書かずに続ける", "type": "string", "minLength": 1 }, "LOOP_NAME": { "description": "NDF の開発ループの名前。loop と、ループの実行の resume に載せる", "type": "string", "pattern": "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" }, @@ -28,12 +28,23 @@ "LOOP_BUDGET": { "description": "宣言した予算だけを並べる。無ければ NDF の既定", "type": "string", "minLength": 1, "pattern": "^(laps=\\d+(\\.\\d+)?)?(,?usd=\\d+(\\.\\d+)?)?(,?hours=\\d+(\\.\\d+)?)?$" }, "EXCLUDE_ISSUES": { "description": "同じリポジトリの別の実行が持つ課題の番号(カンマ区切り)。ループの選定から外す", "type": "string", "pattern": "^[1-9][0-9]*(,[1-9][0-9]*)*$" }, "LOOP_ANSWERS": { "description": "人の判断待ちへの答え(<番号>=approved|rejected、承認なら # を続けてよい)を , で結ぶ", "type": "string", "pattern": "^[1-9][0-9]*=(approved|rejected)(#[1-9][0-9]*)?(,[1-9][0-9]*=(approved|rejected)(#[1-9][0-9]*)?)*$" }, + "RELEASE_SEQ": { "description": "配布の実行のまとめた配布の連番(#2439)", "type": "string", "pattern": "^[1-9][0-9]*$" }, + "RELEASE_STEP": { "description": "配布の実行の手。present / represent は提示を作り、confirm: は確定した提示 k の本番の配布を流し、continue は止まったプランを続ける", "type": "string", "enum": ["present", "represent", "confirm:1", "confirm:2", "continue"] }, + "RELEASE_REQUEST_ID": { "description": "手動の実行の再開で、答えの出た配布の依頼の id", "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,160}$" }, "INGEST_URL": { "description": "進捗の受け口の runner の経路(POST /ingest/events)の URL", "type": "string", "format": "uri" }, "INGEST_TOKEN_SECRET_ARN": { "description": "このジョブだけに効く実行ジョブの token の置き場", "type": "string", "pattern": "^arn:aws[a-z-]*:secretsmanager:" }, "EXECUTION_MODE": { "description": "スプリントの開始でアカウントから写した実行の形態", "type": "string", "enum": ["local", "byok", "managed"] }, "MODEL_CREDENTIAL_SECRET_ARN": { "description": "利用者の Anthropic API キーか、引き受けるロールの ARN の置き場", "type": "string", "pattern": "^arn:aws[a-z-]*:secretsmanager:" } }, "allOf": [ + { + "if": { "properties": { "START": { "const": "release" } }, "required": ["START"] }, + "then": { + "required": ["RELEASE_SEQ", "RELEASE_STEP"], + "not": { "anyOf": [{ "required": ["ISSUES"] }, { "required": ["LOOP_NAME"] }, { "required": ["LOOP_MILESTONES"] }] } + }, + "else": { "not": { "anyOf": [{ "required": ["RELEASE_SEQ"] }, { "required": ["RELEASE_STEP"] }] } } + }, { "if": { "properties": { "START": { "const": "new" } }, "required": ["START"] }, "then": { "required": ["ISSUES"] } diff --git a/factory/contracts/scripts/generate.ts b/factory/contracts/scripts/generate.ts index 7ac735cf1..075581fc7 100644 --- a/factory/contracts/scripts/generate.ts +++ b/factory/contracts/scripts/generate.ts @@ -23,6 +23,8 @@ export function generateFromSchema(schema: JSONSchema, name: string): Promise-r<乱数>) + */ +export type ReleaseRequestId = string; +/** + * 並列のレーンの名前(Factory では loop-<ループの ID> / run-<実行の ID>。#2439) + */ +export type ReleaseLaneName = string; +export type NumberList = number[]; +export type ReleasePresentedEvent = EventCommon & { + type: "release.presented"; + payload: ReleasePresentedPayload; +}; +export type ReleaseCommit = string; +export type ReleaseVersion = string; +export type ReleaseProgressEvent = EventCommon & { + type: "release.progress"; + payload: ReleaseProgressPayload; +}; +export type ReleaseSettledEvent = EventCommon & { + type: "release.settled"; + payload: ReleaseSettledPayload; +}; +export type ReleaseAnsweredEvent = EventCommon & { + type: "release.answered"; + payload: ReleaseAnsweredPayload; +}; /** * どの種類にも共通の列 @@ -141,11 +177,11 @@ export interface EventCommon { } export interface JobStartedPayload { ndf_version: string; - start: "new" | "resume" | "loop"; + start: "new" | "resume" | "loop" | "release"; } export interface PhaseChangedPayload { /** - * フェーズの名前。集合は #2172 が決めるまで列挙にしない + * フェーズの名前。5 つの名前(設計・実装・検査・取り込み・仕上げ)は src/phases.ts が持ち、ここでは列挙で縛らない */ phase: string; issues: number[]; @@ -156,6 +192,34 @@ export interface StepRecordedPayload { seconds: number; summary: string; pull_request?: PullRequestRef; + /** + * その工程が属するフェーズの名前。省いたときは同じ実行ジョブの直前の phase.changed から導く + */ + phase?: string; + /** + * その工程が受け持つ課題。省いたときも空のときもスプリント全体の工程 + * + * @maxItems 50 + */ + issues?: number[]; + /** + * その工程の時間の窓のコミットが触ったファイル。省いたときは記録なし、空の配列は触らなかった + * + * @maxItems 100 + */ + files?: StepFile[]; + /** + * 上限で files から外した数 + */ + files_omitted?: number; + commit_range?: CommitRange; + /** + * その工程の間に決めたこと。省いたときは記録なし、空の配列は決めたことが無かった + * + * @maxItems 20 + */ + decisions?: StepDecision[]; + output?: StepOutput; } /** * このステップが作った・更新した Pull Request @@ -164,6 +228,37 @@ export interface PullRequestRef { repo: RepoFullName; number: PullRequestNumber; } +export interface StepFile { + path: string; + /** + * null はバイナリ(行数が無い) + */ + added: number | null; + /** + * null はバイナリ(行数が無い) + */ + deleted: number | null; +} +/** + * 時間の窓のコミットの最初の親と最後のコミット + */ +export interface CommitRange { + from: string; + to: string; +} +export interface StepDecision { + question: string; + answer: string; + by: "ai" | "user"; +} +/** + * 作業の報告(report)かログ(log)。text は UTF-8 で 32,768 バイト以下で、truncated は上限で切ったこと + */ +export interface StepOutput { + kind: "report" | "log"; + text: string; + truncated: boolean; +} export interface AttentionPayload { reason: string; summary: string; @@ -176,6 +271,12 @@ export interface GateReachedPayload { */ material: string; loop?: LoopRef; + /** + * 答えの選択肢の制限。配布の実行が main へのマージの後に判断で止まったときは continue だけを載せる(#2439 の I63) + * + * @minItems 1 + */ + choices?: ["continue" | "reject", ...("continue" | "reject")[]]; } /** * 開発ループの実行のとき、ループの名前と周回の番号(#2347) @@ -204,9 +305,9 @@ export interface JobFinishedPayload { result: "gate" | "stopped" | "done" | "failed"; summary: string; /** - * stopped の理由。section_limit は区間の上限、usage_limit は local の利用上限 + * stopped の理由。section_limit は区間の上限、usage_limit は local の利用上限。release_requested は gate の理由で、手動の実行が配布の依頼を出して答えを待つ(#2439) */ - reason?: "section_limit" | "usage_limit"; + reason?: "section_limit" | "usage_limit" | "release_requested"; /** * usage_limit のときだけ。利用上限のリセット時刻(読めなければ null) */ @@ -329,3 +430,67 @@ export interface LoopStoppedPayload { detail: string; pending: LoopPending[]; } +/** + * 実行が本番の配布の代わりに出した配布の依頼(#2439)。NDF の置き場の requests/ の写し + */ +export interface ReleaseRequestedPayload { + request_id: ReleaseRequestId; + lane: ReleaseLaneName; + prs: NumberList; + issues: NumberList; + ndf_version: string; + requested_at: string; +} +/** + * 配布の実行が承認資料をそろえた提示(#2439)。auto は MVV 判定が承認ゲート 2 を通したとき真 + */ +export interface ReleasePresentedPayload { + seq: number; + /** + * 提示の番号。出し直しは 2 + */ + k: 1 | 2; + sha: ReleaseCommit; + version: ReleaseVersion; + prs: NumberList; + lanes: ReleasePresentedLane[]; + added_prs: NumberList; + auto: boolean; +} +export interface ReleasePresentedLane { + lane: ReleaseLaneName; + /** + * そのレーンが配布の依頼を出したか + */ + requested: boolean; +} +/** + * 配布の実行の本番の配布が届いた段(#2439) + */ +export interface ReleaseProgressPayload { + seq: number; + stage: "release_branch" | "develop_merged" | "prod_pr_opened" | "main_merged" | "tagged"; +} +/** + * 配布の実行の結果(#2439) + */ +export interface ReleaseSettledPayload { + seq: number; + result: "released" | "failed"; + version?: ReleaseVersion; + prod_pr_url?: string; + sha?: ReleaseCommit; + prs: NumberList; + lanes: ReleaseLaneName[]; + reason?: string; +} +/** + * Factory のオーガナイザーが範囲の実行へ返した配布の答え(#2439) + */ +export interface ReleaseAnsweredPayload { + seq: number; + request_id?: ReleaseRequestId; + result: "released" | "rejected"; + version?: ReleaseVersion; + prod_pr_url?: string; +} diff --git a/factory/contracts/src/generated/run-job-instruction.ts b/factory/contracts/src/generated/run-job-instruction.ts index 5b89e046d..9f3323b82 100644 --- a/factory/contracts/src/generated/run-job-instruction.ts +++ b/factory/contracts/src/generated/run-job-instruction.ts @@ -21,9 +21,9 @@ export type RunJobInstruction = { */ NDF_VERSION: string; /** - * new は課題から始め、resume は保存した状態から続け、loop は開発ループの周回を新しく始める + * new は課題から始め、resume は保存した状態から続け、loop は開発ループの周回を新しく始め、release は配布の実行の手(RELEASE_STEP)を流す */ - START: "new" | "resume" | "loop"; + START: "new" | "resume" | "loop" | "release"; /** * スプリントへ入れる課題の番号(カンマ区切り) */ @@ -52,6 +52,18 @@ export type RunJobInstruction = { * 人の判断待ちへの答え(<番号>=approved|rejected、承認なら # を続けてよい)を , で結ぶ */ LOOP_ANSWERS?: string; + /** + * 配布の実行のまとめた配布の連番(#2439) + */ + RELEASE_SEQ?: string; + /** + * 配布の実行の手。present / represent は提示を作り、confirm: は確定した提示 k の本番の配布を流し、continue は止まったプランを続ける + */ + RELEASE_STEP?: "present" | "represent" | "confirm:1" | "confirm:2" | "continue"; + /** + * 手動の実行の再開で、答えの出た配布の依頼の id + */ + RELEASE_REQUEST_ID?: string; /** * 進捗の受け口の runner の経路(POST /ingest/events)の URL */ diff --git a/factory/contracts/src/index.ts b/factory/contracts/src/index.ts index be7f51f3b..34313d8f2 100644 --- a/factory/contracts/src/index.ts +++ b/factory/contracts/src/index.ts @@ -15,7 +15,7 @@ export type * from "./generated/ingest-response.ts"; export type * from "./generated/local-runner-declaration.ts"; export { progressEventSchema, runJobInstructionSchema, ingestResponseSchema, localRunnerDeclarationSchema }; -// 進捗の事象の 16 種類と、定義の中の名前($defs) +// 進捗の事象の 21 種類と、定義の中の名前($defs) export const EVENT_DEFINITIONS = { "job.started": "JobStartedEvent", "phase.changed": "PhaseChangedEvent", @@ -33,6 +33,11 @@ export const EVENT_DEFINITIONS = { "section.started": "SectionStartedEvent", "loop.lap_closed": "LoopLapClosedEvent", "loop.stopped": "LoopStoppedEvent", + "release.requested": "ReleaseRequestedEvent", + "release.presented": "ReleasePresentedEvent", + "release.progress": "ReleaseProgressEvent", + "release.settled": "ReleaseSettledEvent", + "release.answered": "ReleaseAnsweredEvent", } as const satisfies Record; export type EventType = keyof typeof EVENT_DEFINITIONS; @@ -94,9 +99,23 @@ export function isIngestResponse(value: unknown): boolean { return responseValidator(value) as boolean; } -// 手元のランナーの申告(登録と心拍の本文の一部)を定義で確かめる +// 手元のマシンの申告(登録と心拍の本文の一部)を定義で確かめる export function validateLocalRunnerDeclaration(value: unknown): ContractResult { return declarationValidator(value) ? { ok: true, value: value as LocalRunnerDeclaration } : { ok: false, violation: firstViolation(declarationValidator) }; } + +export { PHASE_NAMES, CLOSING_STEPS, isPhaseName, type PhaseName } from "./phases.ts"; + +// 実行のレーンの名前。ループはループ 1 つ、手動の実行は実行 1 つがレーンになる。#2436 のレーン名の形(小文字・32 字まで)に +// 収めるため、ループの名前と実行の識別子を切り詰める。ランナーの laneName と API の laneOf が同じ名前を出すための唯一の式 +export const organizerLaneName = (sprintRunId: string, loopName: string | null | undefined): string => + loopName ? `loop-${loopName.toLowerCase().slice(0, 27)}` : `run-${sprintRunId.toLowerCase().slice(0, 16)}`; + +// 配布の実行の事象の冪等の鍵(#2439)。実行の識別子を頭にする(冪等の鍵の表はサービス全体で 1 つのため、リポジトリごとの +// 連番だけを鍵にしない)。ランナーの送信と API の受け口の照合が同じ式を使うための唯一の定義 +export const releasePresentedKey = (sprintRunId: string, k: number) => `${sprintRunId}:presented:${k}`; +export const releaseProgressKey = (sprintRunId: string, stage: string) => `${sprintRunId}:progress:${stage}`; +// 結果は種類ごとに鍵を分ける。同じ実行が failed を送った後に再開して released を送れるように(鍵が 1 つだと重複として捨てられる) +export const releaseSettledKey = (sprintRunId: string, result: string) => `${sprintRunId}:settled:${result}`; diff --git a/factory/contracts/src/phases.ts b/factory/contracts/src/phases.ts new file mode 100644 index 000000000..7823b4c08 --- /dev/null +++ b/factory/contracts/src/phases.ts @@ -0,0 +1,16 @@ +// フェーズの名前(#2172)。phase.changed と step.recorded の phase が運ぶ 5 つの名前の正はここだけにある。 +// 定義(schemas/)では文字列のままにし、列挙で縛らない(NDF の版がフェーズを足しても事象を 400 にしない)。 +// 画面も読むため、この入口(@ndf-factory/contracts/phases)は依存を持たない +export const PHASE_NAMES = ["設計", "実装", "検査", "取り込み", "仕上げ"] as const; + +export type PhaseName = (typeof PHASE_NAMES)[number]; + +export function isPhaseName(value: unknown): value is PhaseName { + return typeof value === "string" && (PHASE_NAMES as readonly string[]).includes(value); +} + +// 仕上げのフェーズに常に置く工程(振り返りと棚卸し)。step は step.recorded の step の値 +export const CLOSING_STEPS = [ + { label: "振り返り", step: "retro" }, + { label: "棚卸し", step: "refine" }, +] as const; diff --git a/factory/contracts/src/views.ts b/factory/contracts/src/views.ts new file mode 100644 index 000000000..ba7c1c28e --- /dev/null +++ b/factory/contracts/src/views.ts @@ -0,0 +1,12 @@ +// 画面の応答の形のうち、API と画面の両方が書く形(#2172)。正はここだけにある。 +// 画面も読むため、この入口(@ndf-factory/contracts/views)は依存を持たない + +// 工程の中身(GET .../steps/{key})の本体。工程の行(StepRow)に足す項目 +export interface StepDetailBody { + files: { path: string; added: number | null; deleted: number | null; url: string | null }[] | null; + files_omitted: number | null; + commit_range: { from: string; to: string } | null; + decisions: { question: string; answer: string; by: "ai" | "user" }[] | null; + output: { kind: "report" | "log"; text: string; truncated: boolean } | null; + diff_url: string | null; +} diff --git a/factory/contracts/tests/definitions.test.ts b/factory/contracts/tests/definitions.test.ts index 2d27f8657..646cb2d23 100644 --- a/factory/contracts/tests/definitions.test.ts +++ b/factory/contracts/tests/definitions.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { EVENT_TYPES, + PHASE_NAMES, + isPhaseName, isIngestResponse, progressEventSchema, runJobInstructionSchema, @@ -27,6 +29,9 @@ const SPEC_INSTRUCTION_INPUTS = [ "LOOP_BUDGET", "EXCLUDE_ISSUES", "LOOP_ANSWERS", + "RELEASE_SEQ", + "RELEASE_STEP", + "RELEASE_REQUEST_ID", ]; // 同じ確定仕様の「進捗の事象の種類と受け口」の種類(13 種類)と、区間と開発ループの 3 種類(#2347) @@ -47,6 +52,11 @@ const SPEC_EVENT_TYPES = [ "section.started", "loop.lap_closed", "loop.stopped", + "release.requested", + "release.presented", + "release.progress", + "release.settled", + "release.answered", ]; // 同じ確定仕様の「進捗の正本」の列から、受け口が作る order_key・pr_ref を除いたもの @@ -83,6 +93,35 @@ const PAYLOADS: Record> = { pending: [{ number: 2348, reason: "MVV の外の疑い", lap: 3 }], }, "loop.stopped": { loop: "lfactory2", condition: "budget_usd", detail: '{"values":{"usd":60}}', pending: [] }, + "release.requested": { + request_id: "loop-l1-r3f9a1c2e4b7d", + lane: "loop-l1", + prs: [2440], + issues: [2439], + ndf_version: "10.27.16", + requested_at: "2026-11-02T12:00:00Z", + }, + "release.presented": { + seq: 1, + k: 1, + sha: "0123abcd", + version: "10.28.0", + prs: [2440], + lanes: [{ lane: "loop-l1", requested: true }], + added_prs: [], + auto: false, + }, + "release.progress": { seq: 1, stage: "main_merged" }, + "release.settled": { + seq: 1, + result: "released", + version: "10.28.0", + prod_pr_url: "https://github.com/o/r/pull/9", + sha: "0123abcd", + prs: [2440], + lanes: ["loop-l1"], + }, + "release.answered": { seq: 1, request_id: "loop-l1-r3f9a1c2e4b7d", result: "released", version: "10.28.0" }, }; function event(type: string, patch: Record = {}) { @@ -166,6 +205,16 @@ describe("実行ジョブの指示の定義(受け入れ条件 1)", () => { ).toBe(true); }); + it("START が release は RELEASE_SEQ と RELEASE_STEP を要り、ほかの START は配布の手を許さない(#2439)", () => { + const release = { START: "release", ISSUES: undefined, RELEASE_SEQ: "3", RELEASE_STEP: "confirm:1" }; + expect(validateRunJobInstruction(instruction(release)).ok).toBe(true); + expect(validateRunJobInstruction(instruction({ ...release, RELEASE_STEP: undefined })).ok).toBe(false); + expect(validateRunJobInstruction(instruction({ ...release, RELEASE_STEP: "confirm:3" })).ok).toBe(false); + expect(validateRunJobInstruction(instruction({ ...release, ISSUES: "1" })).ok).toBe(false); + expect(validateRunJobInstruction(instruction({ RELEASE_SEQ: "3", RELEASE_STEP: "present" })).ok).toBe(false); + expect(validateRunJobInstruction(instruction({ START: "resume", ISSUES: undefined, RELEASE_REQUEST_ID: "run-sr1-rabc" })).ok).toBe(true); + }); + it("知らない入力・番号でない ISSUES は落ちる", () => { expect(validateRunJobInstruction({ ...instruction(), EXTRA: "x" }).ok).toBe(false); expect(validateRunJobInstruction(instruction({ ISSUES: "2171,abc" })).ok).toBe(false); @@ -173,7 +222,7 @@ describe("実行ジョブの指示の定義(受け入れ条件 1)", () => { }); describe("進捗の事象の定義(受け入れ条件 1・11)", () => { - it("種類が確定仕様の 13 種類と #2347 の 3 種類に 1 対 1 に対応する", () => { + it("種類が確定仕様の 13 種類と #2347 の 3 種類と #2439 の 5 種類に 1 対 1 に対応する", () => { expect([...EVENT_TYPES].sort()).toEqual([...SPEC_EVENT_TYPES].sort()); expect(progressEventSchema.oneOf).toHaveLength(SPEC_EVENT_TYPES.length); }); @@ -233,6 +282,42 @@ describe("進捗の事象の定義(受け入れ条件 1・11)", () => { expect(step({ repo: "o/r", number: 0 })).toBe(false); }); + it("step.recorded の工程の中身(#2172)はどれも任意で、足す前の形も足した形も通り、上限と形の違いで落ちる", () => { + const step = (patch: Record) => + validateProgressEvent(event("step.recorded", { payload: { ...PAYLOADS["step.recorded"], ...patch } })).ok; + const sha = "a".repeat(40); + const full = { + phase: "実装", + issues: [2172], + files: [{ path: "factory/a.ts", added: 3, deleted: 1 }, { path: "img.png", added: null, deleted: null }], + files_omitted: 0, + commit_range: { from: sha, to: "b".repeat(40) }, + decisions: [{ question: "どちらにする", answer: "A", by: "ai" }], + output: { kind: "report", text: "## 作業の報告", truncated: false }, + }; + expect(step({})).toBe(true); + expect(step(full)).toBe(true); + // 列挙で縛らない(NDF の版がフェーズを足しても残る) + expect(step({ phase: "準備" })).toBe(true); + expect(step({ issues: [], files: [], decisions: [] })).toBe(true); + expect(step({ phase: "" })).toBe(false); + expect(step({ issues: Array.from({ length: 51 }, (_, i) => i + 1) })).toBe(false); + expect(step({ files: Array.from({ length: 101 }, (_, i) => ({ path: `f${i}`, added: 0, deleted: 0 })) })).toBe(false); + expect(step({ files: [{ path: "x".repeat(513), added: 0, deleted: 0 }] })).toBe(false); + expect(step({ commit_range: { from: "abc", to: sha } })).toBe(false); + expect(step({ decisions: [{ question: "q", answer: "a", by: "someone" }] })).toBe(false); + expect(step({ decisions: Array.from({ length: 21 }, () => ({ question: "q", answer: "a", by: "ai" })) })).toBe(false); + expect(step({ output: { kind: "diff", text: "", truncated: false } })).toBe(false); + expect(step({ output: { kind: "log", text: "x".repeat(32_769), truncated: true } })).toBe(false); + }); + + it("フェーズの名前は 5 つで、isPhaseName は列挙の外を偽にする(#2172)", () => { + expect(PHASE_NAMES).toEqual(["設計", "実装", "検査", "取り込み", "仕上げ"]); + expect(PHASE_NAMES.every(isPhaseName)).toBe(true); + expect(isPhaseName("配布(本番)")).toBe(false); + expect(isPhaseName(null)).toBe(false); + }); + it("issue.requested は起票先・題・本文の 3 つで、題は 256 字まで・本文は空にできない(#2216)", () => { const requested = (payload: Record) => validateProgressEvent(event("issue.requested", { payload })).ok; const base = PAYLOADS["issue.requested"]; @@ -301,7 +386,7 @@ describe("進捗の事象の定義(受け入れ条件 1・11)", () => { }); }); -describe("手元のランナーの申告の定義(#2347)", () => { +describe("手元のマシンの申告の定義(#2347)", () => { it("runner_id は 32 字の英数字で、max_jobs は 1〜16 の整数・任意", () => { const id = "a".repeat(32); expect(validateLocalRunnerDeclaration({ runner_id: id }).ok).toBe(true); diff --git a/factory/package-lock.json b/factory/package-lock.json index 9c8006040..9f99ff596 100644 --- a/factory/package-lock.json +++ b/factory/package-lock.json @@ -7449,6 +7449,7 @@ "web": { "name": "@ndf-factory/web", "dependencies": { + "@ndf-factory/contracts": "*", "i18next": "^26.7.1", "oidc-client-ts": "^3.5.0", "react": "^19.3.1", diff --git a/factory/runner/src/capacity.ts b/factory/runner/src/capacity.ts index a2c3391c0..4a51ce517 100644 --- a/factory/runner/src/capacity.ts +++ b/factory/runner/src/capacity.ts @@ -1,5 +1,5 @@ // 同時のジョブの数の目安(決定 13)。2026-09-25 の実測(8 コア・30 GB で全体テストの同時実行 6 本が上限)から、 -// 1 本あたり 4/3 コア・5 GiB とする。手元のランナー(#2189)が登録と心拍の前に呼び、申告が目安を超えれば警告する(申告は変えない) +// 1 本あたり 4/3 コア・5 GiB とする。手元のマシン(#2189)が登録と心拍の前に呼び、申告が目安を超えれば警告する(申告は変えない) import { cpus, totalmem } from "node:os"; const GIB = 1024 ** 3; diff --git a/factory/runner/src/main.ts b/factory/runner/src/main.ts index 6ae82c910..8ef7fce65 100644 --- a/factory/runner/src/main.ts +++ b/factory/runner/src/main.ts @@ -3,6 +3,7 @@ import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { validateRunJobInstruction, type GateReachedEvent, type JobFinishedEvent, type ProgressEvent, type RunJobInstruction } from "@ndf-factory/contracts"; import { advancedSince, answersToWrite, currentLapOf, lapClosedEvents, loopStoppedEvent, markOf, type LoopFile } from "./loop-file.ts"; +import { openRequests, releaseAnswerDirective, releaseBodies, writeAnswers, type OrganizerAnswers, type OrganizerFiles } from "./organizer.ts"; import type { SectionEnd } from "./sections.ts"; import { createSender, type SendResult } from "./send.ts"; import { common, finishedResult, truncateGate, type JobIdentity, type ReportResult } from "./translate.ts"; @@ -21,6 +22,8 @@ export interface JobRuntime { saveState(url: string): Promise; // 開発ループの記録(.ndf/loop/<名>/loop.json)を読む。無ければ null readLoop(name: string): Promise; + // Factory のオーガナイザーとの置き場(#2439)。無ければ置き場の翻訳をしない + organizer?: OrganizerFiles; // 人の判断待ちへの答えを NDF の loop answer で書く answerLoop(name: string, answer: { number: number; decision: "approved" | "rejected"; pr: number | null }): Promise; // 承認の依頼の答えを NDF へ記録する(ゲートは sprint-state.py gate --by user、判断は conductor への指示を返す) @@ -30,7 +33,8 @@ export interface JobRuntime { runConductor( instruction: RunJobInstruction, directive: string | null, - emit: (body: Pick) => Promise, + // at を渡すとその時刻で送る(同じ行から作る事象の時刻を揃える) + emit: (body: Pick, at?: string) => Promise, lapClosed?: () => Promise, ): Promise; } @@ -64,8 +68,8 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { const id: JobIdentity = { sprintRunId: instruction.SPRINT_RUN_ID, jobId: instruction.JOB_ID, repo: instruction.REPO }; const send = createSender({ url: instruction.INGEST_URL, token: () => token, fetch: http, sleep: deps.sleep, stderr }); let seq = 0; - const emit = async (body: Pick): Promise => - send({ ...common(id, ++seq, now().toISOString()), ...body } as ProgressEvent); + const emit = async (body: Pick, at?: string): Promise => + send({ ...common(id, ++seq, at ?? now().toISOString()), ...body } as ProgressEvent); const callApi = async (path: string, body: unknown = {}, missingOk = false) => { const res = await http(apiUrl(instruction.INGEST_URL, path), { @@ -78,6 +82,15 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { return (await res.json()) as Record; }; + const getApi = async (path: string): Promise => { + const res = await http(apiUrl(instruction.INGEST_URL, path), { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) throw new Error(`${path} が ${res.status} を返した`); + return (await res.json()) as T; + }; + // 置き場の事象は受け口の照合と同じ鍵で送る(送り直しでも 1 件。I59) + const sendRelease = async (key: string, body: Pick) => + send({ ...common(id, "finished", now().toISOString()), idempotency_key: key, ...body } as ProgressEvent); + const refreshGitHubToken = async () => runtime.writeGitHubToken((await callApi("/run-jobs/github-token"))!.token!); let failed = false; @@ -86,7 +99,11 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { let report: ReportResult = null; let end: SectionEnd | undefined; let lapAdvanced = false; + // このジョブで新しく出た配布の依頼があり、答えがまだ無い(手動の実行は答えを待って gate で終える。I65) + let releaseRequested = false; + let releaseGate = false; const loopName = instruction.LOOP_NAME ?? null; + const organizer = runtime.organizer ?? null; const timer = (deps.setInterval ?? setInterval)(() => { refreshGitHubToken().catch((err: Error) => stderr(`導入の token を取り直せない: ${err.message}`)); }, GITHUB_TOKEN_REFRESH_MS); @@ -97,13 +114,29 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { if (installed !== instruction.NDF_VERSION) throw new Error(`入った NDF の版が違う(${installed})`); await emit({ type: "job.started", payload: { ndf_version: instruction.NDF_VERSION, start: instruction.START } }); let directive: string | null = null; - if (instruction.START === "resume" || instruction.START === "loop") { - // ループの最初の周回は保存した状態が無い(404)。空から始める - const state = await callApi("/run-jobs/state", { op: "get" }, instruction.START === "loop"); + if (instruction.START === "resume" || instruction.START === "loop" || instruction.START === "release") { + // ループの最初の周回と配布の実行の最初のジョブは保存した状態が無い(404)。空から始める + const state = await callApi("/run-jobs/state", { op: "get" }, instruction.START !== "resume"); if (state) await runtime.restoreState(state.url!); // 承認の依頼の識別子の無い resume(利用上限の後)は答えを書かずに続ける(決定 7) if (instruction.START === "resume" && instruction.APPROVAL_REQUEST_ID) directive = await runtime.recordAnswer(instruction.APPROVAL_REQUEST_ID); + // 配布の実行の判断(承認ゲートの外の問い)への答えは本文を最初の指示へ渡す。承認ゲート 2 の答えは配布のまとめが手(step)にする + if (instruction.START === "release" && instruction.APPROVAL_REQUEST_ID) { + const answer = await getApi<{ kind: string; body?: string | null }>(`/run-jobs/approval-requests/${instruction.APPROVAL_REQUEST_ID}/answer`); + if (answer.kind === "decision") directive = answer.body ?? null; + } } + // 配布の依頼の答えと範囲、配布の実行ならまとめた配布の中身を置き場へ書き、答えの無い依頼を送り直す(#2439) + let requestsBefore = new Set(); + if (organizer) { + await writeAnswers(organizer, await getApi("/run-jobs/organizer/answers")); + if (instruction.START === "release") await organizer.write("batch.json", await getApi("/run-jobs/organizer/batch")); + const open = await openRequests(organizer); + requestsBefore = new Set(open.map((p) => p.request_id)); + for (const p of open) await sendRelease(p.request_id, { type: "release.requested", payload: p }); + if (instruction.RELEASE_REQUEST_ID) directive = releaseAnswerDirective(instruction.RELEASE_REQUEST_ID); + } + const presentedBefore = organizer ? new Set((await organizer.list("presented")).map((f) => f.name)) : new Set(); let lapClosed: (() => Promise) | undefined; let mark = markOf(null); if (loopName) { @@ -114,8 +147,8 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { const out = await runtime.runConductor( instruction, directive, - async (body) => { - await emit(body); + async (body, at) => { + await emit(body, at); }, lapClosed, ); @@ -131,10 +164,25 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { if (stopped) await emit(stopped); lapAdvanced = advancedSince(mark, loopAfter); } + // 置き場の事象は job.finished より前に送る(実行の制御は停止の後に同じ実行の事象として読む) + let mainMerged = false; + if (organizer) { + for (const body of await releaseBodies(organizer, instruction)) { + await sendRelease(body.key, body.event); + if (body.event.type === "release.requested" && !requestsBefore.has(body.key)) releaseRequested = true; + if (body.event.type === "release.progress" && (body.event.payload as { stage: string }).stage === "main_merged") mainMerged = true; + } + releaseGate = (await organizer.list("presented")).some((f) => !presentedBefore.has(f.name) && f.value.auto === false); + } await runtime.saveState((await callApi("/run-jobs/state", { op: "put" }))!.url!); - if (report === "判断が要る" && end?.kind !== "usage_limit") { - gate = { kind: out.gate?.kind ?? "decision", pr_url: out.gate?.pr_url ?? null, material: out.gate?.material ?? summary }; + if (report === "判断が要る" && end?.kind !== "usage_limit" && !(releaseRequested && !loopName)) { + // 配布の実行が承認資料をそろえて止まったら承認ゲート 2。main へのマージの後の判断は続けるだけを選べる(I63) + // 配布の実行の中の止まりは、承認資料をそろえた提示のときだけ gate2。本番のプランの中の「判断が要る」を + // gate2 にすると、確定済みの配布への答えが差し戻しでも本番のプランを流し直す + const kind = releaseGate ? "gate2" : instruction.START === "release" ? "decision" : (out.gate?.kind ?? "decision"); + gate = { kind, pr_url: out.gate?.pr_url ?? null, material: out.gate?.material ?? summary }; if (loopName) gate.loop = { name: loopName, lap: currentLapOf(loopAfter) }; + if (instruction.START === "release" && kind === "decision" && mainMerged) gate.choices = ["continue"]; } } catch (err) { failed = true; @@ -151,7 +199,7 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { const finished: JobFinishedEvent = { ...common(id, "finished", now().toISOString()), type: "job.finished", - payload: finishedPayload(report, failed, end, lapAdvanced, summary), + payload: !failed && releaseRequested && !loopName ? { result: "gate", summary, reason: "release_requested" } : finishedPayload(report, failed, end, lapAdvanced, summary), } as JobFinishedEvent; return (await send(finished)) === "sent" ? 0 : 1; } @@ -171,7 +219,7 @@ export function finishedPayload( return { result: finishedResult(report, failed), summary }; } -// 環境変数から指示を読む。手元のランナー(#2189)は INGEST_TOKEN で token を直に渡す +// 環境変数から指示を読む。手元のマシン(#2189)は INGEST_TOKEN で token を直に渡す export async function loadInstruction(env: Record): Promise<{ instruction: RunJobInstruction; token: string }> { const keys = [ "SPRINT_RUN_ID", @@ -190,6 +238,9 @@ export async function loadInstruction(env: Record): "LOOP_BUDGET", "EXCLUDE_ISSUES", "LOOP_ANSWERS", + "RELEASE_SEQ", + "RELEASE_STEP", + "RELEASE_REQUEST_ID", ]; const raw = Object.fromEntries(keys.filter((k) => env[k] !== undefined).map((k) => [k, env[k]])); const checked = validateRunJobInstruction(raw); diff --git a/factory/runner/src/model.ts b/factory/runner/src/model.ts index 335174ed9..fb28cfc76 100644 --- a/factory/runner/src/model.ts +++ b/factory/runner/src/model.ts @@ -1,6 +1,6 @@ // 形態ごとの claude の呼び方(受け入れ条件 29)。アクセスキーをイメージと環境変数に置かず、 // 子の環境にだけ必要な値を置く。managed はタスクの役割の権限、byok は利用者のキーかロール -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; @@ -51,10 +51,48 @@ export async function writeRoleCredentials( return { configFile }; } -// 子へ渡さない環境変数(秘密の置き場の ARN と、受け口の token。手元のランナーの INGEST_TOKEN も含む) +// 子へ渡さない環境変数(秘密の置き場の ARN と、受け口の token。手元のマシンの INGEST_TOKEN も含む) const PARENT_ONLY = ["INGEST_TOKEN", "INGEST_TOKEN_SECRET_ARN", "MODEL_CREDENTIAL_SECRET_ARN"]; -// モデルの資格。managed と byok では Factory が決めた資格だけを子へ置く。local は利用者が手元で設定した資格をそのまま渡す -const MODEL_CREDENTIALS = ["ANTHROPIC_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"]; +// モデルの資格。managed と byok では Factory が決めた資格だけを子へ置く。local は利用者が手元で設定した資格をそのまま渡す。 +// claude が読む資格(API キー・OAuth の token・認証の token・Bedrock の bearer token・AWS の鍵)をすべて並べる +const MODEL_CREDENTIALS = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", +]; + +// 子へそのまま渡すモデルの資格の値(local だけ)。外へ送る工程の中身から伏せる値に足す +export function passedModelSecrets(base: Record, mode: "local" | "byok" | "managed"): string[] { + return mode === "local" ? MODEL_CREDENTIALS.map((k) => base[k] ?? "").filter(Boolean) : []; +} + +// managed で子が使うタスクの役割の資格。子と同じコンテナの資格の口(ECS の credential endpoint)から読む。 +// 口が無い(手元・byok)か読めなければ空。値は更新されるため、呼ぶ側が読んだ値を控えて伏せる値に足す +export async function containerRoleSecrets( + base: Record, + mode: "local" | "byok" | "managed", + get: typeof fetch = fetch, + readToken: (path: string) => Promise = (p) => readFile(p, "utf8"), +): Promise { + if (mode !== "managed") return []; + const url = base.AWS_CONTAINER_CREDENTIALS_FULL_URI ?? (base.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ? `http://169.254.170.2${base.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}` : null); + if (!url) return []; + try { + const auth = base.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE + ? (await readToken(base.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE)).trim() + : base.AWS_CONTAINER_AUTHORIZATION_TOKEN; + const res = await get(url, auth ? { headers: { Authorization: auth } } : {}); + if (!res.ok) return []; + const c = (await res.json()) as { AccessKeyId?: unknown; SecretAccessKey?: unknown; Token?: unknown }; + return [c.AccessKeyId, c.SecretAccessKey, c.Token].filter((v): v is string => typeof v === "string" && v !== ""); + } catch { + return []; + } +} export async function claudeEnv( base: Record, diff --git a/factory/runner/src/organizer.test.ts b/factory/runner/src/organizer.test.ts new file mode 100644 index 000000000..8665badc0 --- /dev/null +++ b/factory/runner/src/organizer.test.ts @@ -0,0 +1,153 @@ +// Factory のオーガナイザーとの置き場の翻訳(#2439)。NDF の契約の例の組(parallel-external.json)を読み、写した事象が契約に合うかを縛る +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { validateProgressEvent, validateRunJobInstruction, type ProgressEvent, type RunJobInstruction } from "@ndf-factory/contracts"; +import { runJob, type JobRuntime } from "./main.ts"; +import { laneName, organizerEnv, releaseBodies, type OrganizerFiles } from "./organizer.ts"; +import { common } from "./translate.ts"; + +const FORM = JSON.parse(readFileSync(new URL("../../../plugins/ndf/contracts/forms/parallel-external.json", import.meta.url), "utf8")) as { + cases: { message: { file: string; body: Record }; valid: boolean }[]; +}; + +function memoryFiles(seed: Record> = {}): OrganizerFiles & { files: Map } { + const files = new Map(Object.entries(seed)); + return { + files, + async write(rel, value) { + files.set(rel, structuredClone(value)); + }, + async list(sub) { + return [...files.entries()] + .filter(([k]) => k.startsWith(`${sub}/`)) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([k, v]) => ({ name: k.slice(sub.length + 1), value: v as Record })); + }, + }; +} + +const ID = { sprintRunId: "sr1", jobId: "job1", repo: "org-a/repo-a" }; +const full = (key: string, body: Pick) => + ({ ...common(ID, "finished", "2026-11-02T00:00:00.000Z"), idempotency_key: key, ...body }) as ProgressEvent; + +describe("置き場の翻訳(受け入れ条件 10、I59・I68)", () => { + it("契約の例の組の正しいファイルは契約に合う事象へ写り、依頼の形が欠けたもの・知らない段は写らない", async () => { + const valid = FORM.cases.filter((c) => c.valid && /^(requests|presented|progress|results)\//.test(c.message.file)); + expect(valid.length).toBeGreaterThanOrEqual(4); + for (const c of valid) { + const files = memoryFiles({ [c.message.file]: c.message.body }); + const bodies = await releaseBodies(files, { SPRINT_RUN_ID: "sr1", START: "release" }); + expect(bodies.length, c.message.file).toBeGreaterThan(0); + for (const b of bodies) expect(validateProgressEvent(full(b.key, b.event)), `${c.message.file} ${b.event.type}`).toMatchObject({ ok: true }); + } + const invalid = FORM.cases.filter((c) => !c.valid && /^(requests|progress)\//.test(c.message.file) && !c.message.file.startsWith("requests/loop-x")); + for (const c of invalid) { + expect(await releaseBodies(memoryFiles({ [c.message.file]: c.message.body }), { SPRINT_RUN_ID: "sr1", START: "release" })).toEqual([]); + } + }); + + it("答えのある依頼は送り直さず、配布の実行の事象は配布の実行のジョブだけが送る", async () => { + const req = FORM.cases.find((c) => c.valid && c.message.file.startsWith("requests/"))!.message; + const res = FORM.cases.find((c) => c.valid && c.message.file.startsWith("results/"))!.message; + const files = memoryFiles({ [req.file]: req.body, [res.file]: res.body }); + expect((await releaseBodies(files, { SPRINT_RUN_ID: "sr1", START: "loop" })).map((b) => b.event.type)).toEqual(["release.requested"]); + await files.write(`answers/${req.body.id}.json`, { result: "released" }); + expect(await releaseBodies(files, { SPRINT_RUN_ID: "sr1", START: "loop" })).toEqual([]); + }); + + it("レーンの名前は #2436 のレーン名の形に収まり、NDF へ 4 つの環境変数を渡す", () => { + const loop = laneName({ SPRINT_RUN_ID: "x", LOOP_NAME: "LFactory3-very-long-loop-name-over-limit" }); + const run = laneName({ SPRINT_RUN_ID: "0123456789abcdef0123456789abcdef" }); + for (const name of [loop, run]) expect(name).toMatch(/^[a-z0-9][a-z0-9_-]{0,31}$/); + expect(run).toBe("run-0123456789abcdef"); + expect(loop).toBe("loop-lfactory3-very-long-loop-na"); + expect(organizerEnv({ SPRINT_RUN_ID: "sr1", NDF_VERSION: "10.28.0@0123abcd" }, "/w/repo/.ndf/organizer")).toEqual({ + NDF_FACTORY_RUN: "1", + NDF_ORGANIZER_DIR: "/w/repo/.ndf/organizer", + NDF_ORGANIZER_LANE: "run-sr1", + NDF_VERSION: "10.28.0@0123abcd", + }); + }); +}); + +describe("配布の依頼と配布の実行のジョブ(受け入れ条件 7・10、I65)", () => { + const base: RunJobInstruction = { + SPRINT_RUN_ID: "sr1", + JOB_ID: "job1", + REPO: "org-a/repo-a", + NDF_VERSION: "10.27.16", + START: "new", + ISSUES: "1", + INGEST_URL: "https://api.example/ingest/events", + INGEST_TOKEN_SECRET_ARN: "arn:aws:secretsmanager:us-east-1:1:secret:t", + EXECUTION_MODE: "managed", + }; + + function harness(organizer: OrganizerFiles, conductor: JobRuntime["runConductor"]) { + const events: ProgressEvent[] = []; + const fetchImpl = (async (url: string, init: RequestInit = {}) => { + if (url.endsWith("/ingest/events")) { + events.push(JSON.parse(String(init.body)) as ProgressEvent); + return new Response(null, { status: 202 }); + } + if (url.endsWith("/run-jobs/organizer/answers")) return Response.json({ answers: [], ranges: [] }); + if (url.endsWith("/run-jobs/organizer/batch")) return Response.json({ seq: 1, step: "present", lanes: [], presentations: [], choices: ["continue", "reject"] }); + if (url.endsWith("/run-jobs/state") && String(init.body).includes('"get"')) return new Response(null, { status: 404 }); + return Response.json(url.endsWith("github-token") ? { token: "ghs" } : { url: "https://state.example/x" }); + }) as typeof fetch; + const runtime: JobRuntime = { + organizer, + writeGitHubToken: async () => undefined, + clone: async () => undefined, + installNdf: async (v) => v, + restoreState: async () => undefined, + saveState: async () => undefined, + readLoop: async () => null, + answerLoop: vi.fn(async () => undefined), + recordAnswer: vi.fn(async () => null), + runConductor: conductor, + }; + return { events, run: (inst: RunJobInstruction) => runJob({ instruction: inst, token: "tok", runtime, fetch: fetchImpl, setInterval: () => 1, clearInterval: () => undefined, stderr: () => undefined }) }; + } + + it("手動の実行が配布の依頼を出すと release.requested を依頼の id の鍵で送り、gate(release_requested)で終える", async () => { + const req = FORM.cases.find((c) => c.valid && c.message.file.startsWith("requests/"))!.message; + const files = memoryFiles(); + const t = harness(files, async () => { + await files.write(req.file, req.body); + return { report: "判断が要る", summary: "配布待ち", gate: null }; + }); + expect(await t.run(base)).toBe(0); + const requested = t.events.find((e) => e.type === "release.requested")!; + expect(requested.idempotency_key).toBe(req.body.id); + expect(validateProgressEvent(requested).ok).toBe(true); + expect(t.events.some((e) => e.type === "gate.reached")).toBe(false); + expect(t.events.at(-1)).toMatchObject({ type: "job.finished", payload: { result: "gate", reason: "release_requested" } }); + expect(validateProgressEvent(t.events.at(-1)!).ok).toBe(true); + }); + + it("配布の実行はまとめた配布の中身を置き場へ書き、承認資料で止まれば提示と gate2 を送る", async () => { + const files = memoryFiles(); + const inst = { ...base, START: "release" as const, ISSUES: undefined, RELEASE_SEQ: "1", RELEASE_STEP: "present" as const }; + expect(validateRunJobInstruction(Object.fromEntries(Object.entries(inst).filter(([, v]) => v !== undefined))).ok).toBe(true); + const presented = FORM.cases.find((c) => c.valid && c.message.file.startsWith("presented/"))!.message; + const t = harness(files, async () => { + expect(files.files.get("batch.json")).toMatchObject({ seq: 1, step: "present" }); + await files.write(presented.file, presented.body); + return { report: "判断が要る", summary: "承認ゲート 2", gate: null }; + }); + await t.run(inst); + expect(t.events.map((e) => e.type)).toEqual(["job.started", "release.presented", "gate.reached", "job.finished"]); + expect(t.events.find((e) => e.type === "release.presented")!.idempotency_key).toBe("sr1:presented:1"); + expect(t.events.find((e) => e.type === "gate.reached")!.payload).toMatchObject({ kind: "gate2" }); + for (const e of t.events) expect(validateProgressEvent(e).ok, e.type).toBe(true); + }); + + it("配布の実行が提示を書かずに本番のプランの中で止まれば、conductor が gate2 と言っても判断(decision)で送る", async () => { + const files = memoryFiles(); + const inst = { ...base, START: "release" as const, ISSUES: undefined, RELEASE_SEQ: "1", RELEASE_STEP: "confirm:1" as const }; + const t = harness(files, async () => ({ report: "判断が要る", summary: "本番のプランが止まった", gate: { kind: "gate2", pr_url: null, material: "資料" } })); + await t.run(inst); + expect(t.events.find((e) => e.type === "gate.reached")!.payload).toMatchObject({ kind: "decision" }); + }); +}); diff --git a/factory/runner/src/organizer.ts b/factory/runner/src/organizer.ts new file mode 100644 index 000000000..268fc43a6 --- /dev/null +++ b/factory/runner/src/organizer.ts @@ -0,0 +1,167 @@ +// Factory のオーガナイザーとの置き場の翻訳(#2439 の腐敗防止層)。NDF が置き場(NDF_ORGANIZER_DIR)へ書く依頼・提示・届いた段・結果の +// ファイルを進捗の事象へ写し、API の答え・配布済みの範囲・まとめた配布の中身をファイルへ書く。形は NDF の契約 +// plugins/ndf/contracts/forms/parallel-external.json。API は NDF のファイルを読まない +import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + organizerLaneName, + type ProgressEvent, + type ReleasePresentedPayload, + type ReleaseProgressPayload, + type ReleaseRequestedPayload, + type ReleaseSettledPayload, + releasePresentedKey, + releaseProgressKey, + releaseSettledKey, + type RunJobInstruction, +} from "@ndf-factory/contracts"; + +// 置き場(取り込んだリポジトリの .ndf の下。状態の保存に入れ、周回と再開をまたいで残す) +export const ORGANIZER_DIR = ".ndf/organizer"; +const STAGES = new Set(["release_branch", "develop_merged", "prod_pr_opened", "main_merged", "tagged"]); + +// レーンの名前。式は契約の organizerLaneName で、API の Factory のオーガナイザーが同じ式で依頼の無いレーンを名付ける +export function laneName(inst: Pick): string { + return organizerLaneName(inst.SPRINT_RUN_ID, inst.LOOP_NAME); +} + +// NDF へ渡す環境変数(I68)。Factory の実行ジョブでは 1 台の中のオーガナイザーを起動せず、本番の配布は依頼として出す +// NDF_VERSION は入れた NDF の版(開発版は @)。配布の依頼の ndf_version が固定したコミットを落とさないように渡す +export function organizerEnv(inst: Pick, dir: string): Record { + return { NDF_FACTORY_RUN: "1", NDF_ORGANIZER_DIR: dir, NDF_ORGANIZER_LANE: laneName(inst), NDF_VERSION: inst.NDF_VERSION }; +} + +// 置き場の読み書き。テストはメモリに替える +export interface OrganizerFiles { + write(rel: string, value: unknown): Promise; + // sub の下の .json(名前の順)。読めないものは飛ばす + list(sub: string): Promise<{ name: string; value: Record }[]>; +} + +// 一時ファイルへ書いて名前を付け替える(NDF が書きかけを読まない) +export function fsOrganizerFiles(dir: string): OrganizerFiles { + return { + async write(rel, value) { + const path = join(dir, rel); + await mkdir(dirname(path), { recursive: true }); + const tmp = join(dirname(path), `.tmp-${process.pid}-${Date.now()}`); + await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`); + await rename(tmp, path); + }, + async list(sub) { + const names = (await readdir(join(dir, sub)).catch(() => [] as string[])).filter((n) => n.endsWith(".json") && !n.startsWith(".")).sort(); + const out: { name: string; value: Record }[] = []; + for (const name of names) { + try { + const value = JSON.parse(await readFile(join(dir, sub, name), "utf8")) as unknown; + if (value && typeof value === "object" && !Array.isArray(value)) out.push({ name, value: value as Record }); + } catch { + // 読めないファイルは次の読みで拾う + } + } + return out; + }, + }; +} + +export interface OrganizerAnswers { + answers: { request_id: string; result: string; version: string | null; release: string | null; reason: string | null; at: string }[]; + ranges: { seq: number; sha: string | null; version: string | null; prs: number[]; lanes: string[] }[]; +} + +// 答えと範囲を置き場へ書く(answers/<依頼の id>.json・ranges/<連番>.json) +export async function writeAnswers(files: OrganizerFiles, body: OrganizerAnswers): Promise { + for (const a of body.answers) { + await files.write(`answers/${a.request_id}.json`, { result: a.result, version: a.version, release: a.release, reason: a.reason, at: a.at }); + } + for (const r of body.ranges) await files.write(`ranges/${r.seq}.json`, { sha: r.sha, version: r.version, prs: r.prs, lanes: r.lanes }); +} + +// 写した事象(冪等の鍵つき。受け口の照合と同じ鍵) +export interface ReleaseBody { + key: string; + event: Pick; +} + +const ints = (v: unknown): number[] => (Array.isArray(v) ? v.filter((x): x is number => Number.isInteger(x) && x >= 1) : []); +const str = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null); + +export function requestedFrom(value: Record): ReleaseRequestedPayload | null { + const id = str(value.id); + const lane = str(value.lane); + const version = str(value.ndf_version); + const at = str(value.at); + if (!id || !lane || !version || !at || value.kind !== "request") return null; + return { request_id: id, lane, prs: ints(value.prs), issues: ints(value.issues), ndf_version: version, requested_at: at }; +} + +export function presentedFrom(value: Record): ReleasePresentedPayload | null { + const sha = str(value.sha); + const version = str(value.version); + if (!Number.isInteger(value.seq) || (value.k !== 1 && value.k !== 2) || !sha || !version || typeof value.auto !== "boolean") return null; + const lanes = Array.isArray(value.lanes) + ? (value.lanes as Record[]).filter((l) => typeof l?.lane === "string").map((l) => ({ lane: l.lane as string, requested: l.requested === true })) + : []; + return { seq: value.seq as number, k: value.k, sha, version, prs: ints(value.prs), lanes, added_prs: ints(value.added_prs), auto: value.auto }; +} + +export function progressFrom(value: Record): ReleaseProgressPayload[] { + if (!Number.isInteger(value.seq) || !Array.isArray(value.stages)) return []; + return (value.stages as unknown[]) + .filter((s): s is ReleaseProgressPayload["stage"] => typeof s === "string" && STAGES.has(s)) + .map((stage) => ({ seq: value.seq as number, stage })); +} + +export function settledFrom(value: Record): ReleaseSettledPayload | null { + if (!Number.isInteger(value.seq) || (value.result !== "released" && value.result !== "failed")) return null; + const out: ReleaseSettledPayload = { + seq: value.seq as number, + result: value.result, + prs: ints(value.prs), + lanes: Array.isArray(value.lanes) ? (value.lanes as unknown[]).filter((l): l is string => typeof l === "string") : [], + }; + const version = str(value.version); + const url = str(value.prod_pr_url); + const sha = str(value.sha); + const reason = str(value.reason); + if (version) out.version = version; + if (url) out.prod_pr_url = url; + if (sha) out.sha = sha; + if (reason) out.reason = reason.slice(0, 4000); + return out; +} + +// 答えの無い依頼(再送の対象。同じ鍵で送るため受け口で 1 件になる。I59) +export async function openRequests(files: OrganizerFiles): Promise { + const answered = new Set((await files.list("answers")).map((a) => a.name.replace(/\.json$/, ""))); + return (await files.list("requests")) + .map((r) => requestedFrom(r.value)) + .filter((p): p is ReleaseRequestedPayload => p !== null && !answered.has(p.request_id)); +} + +// 置き場のファイルを進捗の事象へ写す。依頼は答えの無いものだけ、配布の実行の事象は配布の実行のジョブだけ(実行の識別子が鍵の頭) +export async function releaseBodies(files: OrganizerFiles, inst: Pick): Promise { + const out: ReleaseBody[] = (await openRequests(files)).map((p) => ({ key: p.request_id, event: { type: "release.requested", payload: p } })); + if (inst.START !== "release") return out; + const run = inst.SPRINT_RUN_ID; + for (const f of await files.list("presented")) { + const p = presentedFrom(f.value); + if (p) out.push({ key: releasePresentedKey(run, p.k), event: { type: "release.presented", payload: p } }); + } + for (const f of await files.list("progress")) { + for (const p of progressFrom(f.value)) out.push({ key: releaseProgressKey(run, p.stage), event: { type: "release.progress", payload: p } }); + } + for (const f of await files.list("results")) { + const p = settledFrom(f.value); + if (p) out.push({ key: releaseSettledKey(run, p.result), event: { type: "release.settled", payload: p } }); + } + return out; +} + +// 配布の実行の conductor への最初の指示(NDF の development-workflow の references/parallel.md の「配布の実行」) +export const RELEASE_PROMPT = "/ndf:development-workflow --parallel-helper --external"; + +// 手動の実行の再開で、答えの出た配布の依頼を conductor へ渡す指示 +export function releaseAnswerDirective(requestId: string): string { + return `配布の依頼 ${requestId} の答えが置き場(NDF_ORGANIZER_DIR)に届いた。parallel.py await --external で読み、続きを進めてください`; +} diff --git a/factory/runner/src/redact.ts b/factory/runner/src/redact.ts new file mode 100644 index 000000000..dd5249b9f --- /dev/null +++ b/factory/runner/src/redact.ts @@ -0,0 +1,28 @@ +// 外へ送る本文(作業の報告・ログ・判断の文・ファイルのパス)から秘密を伏せる(#2172 の決定 7)。 +// 形の規則は NDF の plugins/ndf/scripts/lib/secret_redact.py の 4 つを写したもの(factory/ から plugins/ を参照しないため写す)。 +// 規則を変えるときは両方を直す。行と字の数の切り詰め(redact_output の 5 行・500 字)は写さない +export const MASK = "***"; + +const PATTERNS: [RegExp, string][] = [ + // URL の資格情報(https://user:pass@host・https://x-access-token:@host・https://@host) + [/(?<=:\/\/)[^\s/@]+@/g, `${MASK}@`], + // GitHub の token の形 + [/\b(?:gh[pousr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,})/g, MASK], + // x-access-token:<値> が URL の外に現れたとき + [/(?<=x-access-token:)[^\s@]+/g, MASK], + // 認証ヘッダー(Authorization: Bearer <値>・Proxy-Authorization: ...)は値ごと伏せる + [/\b((?:proxy-)?authorization\s*[:=]\s*)[^\r\n]+/gi, `$1${MASK}`], +]; + +// 短すぎる値は置き換えない(ありふれた語まで伏せないため) +const MIN_SECRET_LENGTH = 8; + +// secrets はランナーが持つ秘密の値(実行ジョブの token・導入の token・持ち込んだキー・引き受けたロールの資格)。形に頼らず文字列のまま伏せる +export function redact(text: string, secrets: readonly string[] = []): string { + let out = text; + for (const value of [...secrets].filter((s) => s.length >= MIN_SECRET_LENGTH).sort((a, b) => b.length - a.length)) { + out = out.split(value).join(MASK); + } + for (const [pattern, repl] of PATTERNS) out = out.replace(pattern, repl); + return out; +} diff --git a/factory/runner/src/runner.test.ts b/factory/runner/src/runner.test.ts index 3466285d7..da04af80b 100644 --- a/factory/runner/src/runner.test.ts +++ b/factory/runner/src/runner.test.ts @@ -4,9 +4,9 @@ import { GITHUB_TOKEN_REFRESH_MS, runJob, type JobRuntime } from "./main.ts"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { claudeEnv, ROLE_PROFILE, type AssumeRole } from "./model.ts"; +import { claudeEnv, containerRoleSecrets, passedModelSecrets, ROLE_PROFILE, type AssumeRole } from "./model.ts"; import { createSender } from "./send.ts"; -import { trackedEntries } from "./state.ts"; +import { packState, trackedEntries } from "./state.ts"; import { common, delegatedFromRecord, @@ -126,6 +126,21 @@ describe("状態(受け入れ条件 28・32、I42)", () => { expect(trackedEntries([".ndf/tmp/x", ".ndf/pace.json"], tracked)).toEqual([".ndf/pace.json"]); expect(trackedEntries(["src/a.ts"], tracked)).toEqual(["src/a.ts"]); }); + + it("配布の提示が作った追跡しない承認資料も保存し、復元で受け入れる。追跡する承認資料とほかの issues/ は断る", () => { + const approval = "issues/approval-ndf-v1.2.0-parallel-3-2.md"; + expect(trackedEntries([".ndf/tmp/x", approval], new Set())).toEqual([]); + expect(trackedEntries([approval], new Set([approval]))).toEqual([approval]); + expect(trackedEntries(["issues/approval-ndf-v1.2.0.md", "issues/x.md"], new Set())).toEqual(["issues/approval-ndf-v1.2.0.md", "issues/x.md"]); + const calls: string[][] = []; + const run = (cmd: string, args: string[]) => { + calls.push([cmd, ...args]); + if (cmd === "test" && args[1] !== ".ndf/tmp") throw new Error("無い"); + return cmd === "git" ? `${approval}\0issues/memo.md\0` : ""; + }; + packState("/r", "/a.tgz", run); + expect(calls.at(-1)).toEqual(["tar", "-czf", "/a.tgz", "--", ".ndf/tmp", approval]); + }); }); describe("claude の呼び方(受け入れ条件 8・29)", () => { @@ -150,6 +165,26 @@ describe("claude の呼び方(受け入れ条件 8・29)", () => { expect(env).toMatchObject({ ANTHROPIC_API_KEY: "mine", AWS_ACCESS_KEY_ID: "AK" }); expect(env.INGEST_TOKEN).toBeUndefined(); expect(env.MODEL_CREDENTIAL_SECRET_ARN).toBeUndefined(); + // 子へそのまま渡した資格は、外へ送る工程の中身から伏せる値に入る + const creds = { ...base, ANTHROPIC_API_KEY: "mine", AWS_SECRET_ACCESS_KEY: "SK" }; + expect(passedModelSecrets(creds, "local")).toEqual(["mine", "SK"]); + expect(passedModelSecrets(creds, "managed")).toEqual([]); + }); + + it("managed はコンテナの資格の口から子が使うタスクの役割の資格を読み、伏せる値に足す。ほかの形態と口の無いときは空", async () => { + const urls: string[] = []; + const get = (async (u: string, init?: RequestInit) => { + urls.push(`${u} ${new Headers(init?.headers).get("authorization") ?? ""}`); + return new Response(JSON.stringify({ AccessKeyId: "ASIA", SecretAccessKey: "SK", Token: "TK", Expiration: AT })); + }) as typeof fetch; + const relative = { ...base, AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: "/v2/credentials/x" }; + expect(await containerRoleSecrets(relative, "managed", get)).toEqual(["ASIA", "SK", "TK"]); + const full = { ...base, AWS_CONTAINER_CREDENTIALS_FULL_URI: "http://127.0.0.1/creds", AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE: "/t" }; + expect(await containerRoleSecrets(full, "managed", get, async () => "auth\n")).toEqual(["ASIA", "SK", "TK"]); + expect(urls).toEqual(["http://169.254.170.2/v2/credentials/x ", "http://127.0.0.1/creds auth"]); + expect(await containerRoleSecrets(relative, "byok", get)).toEqual([]); + expect(await containerRoleSecrets(base, "managed", get)).toEqual([]); + expect(await containerRoleSecrets(relative, "managed", (async () => new Response("", { status: 500 })) as typeof fetch)).toEqual([]); }); it("byok の API キーは子の環境にだけ置き、ロールは外部 ID つきで引き受ける。外部 ID が違えば失敗する", async () => { diff --git a/factory/runner/src/runtime.ts b/factory/runner/src/runtime.ts index c255005e2..33d98e374 100644 --- a/factory/runner/src/runtime.ts +++ b/factory/runner/src/runtime.ts @@ -3,14 +3,16 @@ import { spawn } from "node:child_process"; import { mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import type { RunJobInstruction } from "@ndf-factory/contracts"; import type { JobRuntime } from "./main.ts"; -import { claudeEnv, ROLE_REFRESH_MS, stsAssumeRole, writeRoleCredentials, type ModelCredential } from "./model.ts"; +import { claudeEnv, containerRoleSecrets, passedModelSecrets, ROLE_REFRESH_MS, stsAssumeRole, writeRoleCredentials, type AssumeRole, type ModelCredential } from "./model.ts"; import { parseLoopFile } from "./loop-file.ts"; +import { fsOrganizerFiles, ORGANIZER_DIR, organizerEnv, RELEASE_PROMPT } from "./organizer.ts"; import { runSections, sectionLimitOf, type SectionRun } from "./sections.ts"; import { exec, packState, statePaths, unpackState } from "./state.ts"; +import { collectStepDetails, readPlan, type GitExec } from "./step-details.ts"; import { delegatedFromRecord, issueRequestFromLine, @@ -80,6 +82,8 @@ export function loopPrompt(inst: RunJobInstruction): string { export function firstPrompt(inst: RunJobInstruction, directive: string | null): string { if (inst.START === "new") return `/ndf:development-workflow ${inst.ISSUES!.split(",").map((n) => `#${n}`).join(" ")}`; if (inst.START === "loop") return loopPrompt(inst); + // 配布の実行を判断への答えで再開したときは、答えの本文を添える(添えないと同じプランを答え無しで流し直して同じ問いで止まる) + if (inst.START === "release") return directive ? `${RELEASE_PROMPT}\n\n利用者の判断: ${directive}` : RELEASE_PROMPT; return `前回の続きから進めてください。${directive ? `利用者の判断: ${directive}` : ""}`; } @@ -108,7 +112,21 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< const issueRequests = join(jobHome, "issue-requests.jsonl"); jobEnv.NDF_ISSUE_REQUEST_FILE = issueRequests; const sh = (cmd: string, args: string[], opts: { cwd: string; env?: Record }) => run(cmd, args, { env: jobEnv, ...opts }); - const childEnv = () => claudeEnv(jobEnv, instruction.EXECUTION_MODE, credential, stsAssumeRole, sessionName, credentialDir); + // 引き受けたロールの資格は発行のたびに控える。更新の前の値も期限まで有効なため、伏せる値から外さない + const assumedSecrets: string[] = []; + const assumeRole: AssumeRole = async (cred, name) => { + const c = await stsAssumeRole(cred, name); + assumedSecrets.push(c.accessKeyId, c.secretAccessKey, c.sessionToken); + return c; + }; + // managed で子が使うタスクの役割の資格も、読んだ値を控える。更新の前の値も期限まで有効なため外さない + const noteContainerRole = async () => { + for (const v of await containerRoleSecrets(jobEnv, instruction.EXECUTION_MODE)) if (!assumedSecrets.includes(v)) assumedSecrets.push(v); + }; + const childEnv = async () => { + await noteContainerRole(); + return claudeEnv(jobEnv, instruction.EXECUTION_MODE, credential, assumeRole, sessionName, credentialDir); + }; // 版を固定して取った取得元。スクリプトは入れた版と同じこの写しから読む const ndfSource = join(work, "ndf-marketplace"); const ndfScripts = () => join(ndfSource, "plugins/ndf/scripts"); @@ -118,9 +136,41 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< const paths = statePaths(instruction.LOOP_NAME); // 同じリポジトリの別の実行が持つ課題を、NDF の loop select がその周回の選定から外す(決定 3) if (instruction.EXCLUDE_ISSUES) jobEnv.NDF_LOOP_EXCLUDE = instruction.EXCLUDE_ISSUES; + // Factory のオーガナイザーの置き場(#2439 の I68)。1 台の中のオーガナイザーを起動させず、本番の配布は依頼として出させる + const organizerDir = join(repoDir, ORGANIZER_DIR); + Object.assign(jobEnv, organizerEnv(instruction, organizerDir)); + // 外へ送る工程の中身から伏せる秘密の値(実行ジョブの token・導入の token・持ち込んだキー・引き受けたロールとタスクの役割の資格。#2172 の決定 7) + const githubTokens: string[] = []; + const secretValues = async (): Promise => { + const out = [token, ...githubTokens, jobEnv.INGEST_TOKEN ?? ""]; + // local は利用者が手元で設定したモデルの資格をそのまま子へ渡すため、その値も伏せる + out.push(...passedModelSecrets(jobEnv, instruction.EXECUTION_MODE)); + if (credential?.kind === "anthropic_api_key") out.push(credential.api_key); + await noteContainerRole(); + out.push(...assumedSecrets); + return out.filter(Boolean); + }; + const git: GitExec = async (args, cwd) => { + let stdout = ""; + const code = await run("git", args, { cwd, env: jobEnv, onLine: (line) => (stdout += `${line}\n`), onStderr: () => undefined }); + return { code, stdout }; + }; + const ledgerRows = async () => + (await readFile(askLedger, "utf8").catch(() => "")) + .split("\n") + .filter(Boolean) + .flatMap((raw) => { + try { + return [JSON.parse(raw) as Record]; + } catch { + return []; + } + }); return { + organizer: fsOrganizerFiles(organizerDir), async writeGitHubToken(token) { + githubTokens.push(token); const dir = jobEnv.GH_CONFIG_DIR!; await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(join(dir, "hosts.yml"), `github.com:\n oauth_token: ${token}\n git_protocol: https\n`, { mode: 0o600 }); @@ -202,8 +252,9 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< // このジョブの記録だけを送る(ask.jsonl は利用者の全プロジェクトで共有される)。cwd は realpath で書かれる const root = await realpath(work); const ownRow = (row: Record) => typeof row.cwd === "string" && (row.cwd === root || row.cwd.startsWith(`${root}/`)); - // 再開では復元したログの末尾から読む。前のジョブが送った行を新しい job_id で送り直さない - if (inst.START === "resume") { + // 再開と配布の実行(承認・出し直し・続きも START は release のまま)では復元したログの末尾から読む。 + // 前のジョブが送った工程と使用量を新しい job_id で送り直さない + if (inst.START === "resume" || inst.START === "release") { for (const file of [...(await findFiles(join(repoDir, ".ndf"), "progress.jsonl")), askLedger]) { offsets.set(file, (await readFile(file, "utf8").catch(() => "")).length); } @@ -219,17 +270,22 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< const content = await readFile(file, "utf8").catch(() => ""); const from = offsets.get(file) ?? 0; offsets.set(file, content.length); + const stateDir = dirname(file); + const name = basename(stateDir).replace(/-state$/, ""); + const info = await readPlan(stateDir); for (const raw of content.slice(from).split("\n").filter(Boolean)) { const line = JSON.parse(raw) as Record; - const name = file.split("/").slice(-2, -1)[0]!.replace(/-state$/, ""); if (name !== plan) { plan = name; - await emit(phaseChanged(name)); + await emit(phaseChanged(name, info.phase)); } - const body = translateProgressLine(line, inst.REPO); - if (body) await emit(body); + const details = line.kind === "step" ? await collectStepDetails({ git, ledgerRows, secrets: secretValues }, name, stateDir, line, info) : {}; + const body = translateProgressLine(line, inst.REPO, details); + // 同じ行の step.recorded と usage.recorded は同じ at で送り、工程の費用を結ぶ(決定 8) + const at = new Date().toISOString(); + if (body) await emit(body, at); // supervise が別のプロセスで起動したステップの使用量(conductor の result には入らない) - for (const u of usageFromStepLine(line)) await emit(u); + for (const u of usageFromStepLine(line)) await emit(u, at); } } // 代わりの判断の記録(ask_ledger.py の ask.jsonl の kind: decision の行) @@ -271,7 +327,7 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< // ロールの資格は 1 時間で切れるため、動いている間は置き直す(子は credential_process で読み直す) const role = credential?.kind === "aws_role" ? credential : null; const refresher = role - ? setInterval(() => void writeRoleCredentials(role, stsAssumeRole, sessionName, credentialDir).catch(() => undefined), ROLE_REFRESH_MS) + ? setInterval(() => void writeRoleCredentials(role, assumeRole, sessionName, credentialDir).catch(() => undefined), ROLE_REFRESH_MS) : null; // 区間ごとに claude -p を 1 回起動する。使用量(usage.recorded)は区間ごとに今のとおり送る const launch = async (prompt: string): Promise => { diff --git a/factory/runner/src/state.ts b/factory/runner/src/state.ts index 3279c0ecf..81911ddbc 100644 --- a/factory/runner/src/state.ts +++ b/factory/runner/src/state.ts @@ -1,8 +1,14 @@ -// 状態の保存と復元(受け入れ条件 28・32、I42)。保存は NDF の状態ディレクトリ(追跡しないパス)だけを tar にし、 +// 状態の保存と復元(受け入れ条件 28・32、I42)。保存は NDF の状態ディレクトリと配布の承認資料(追跡しないパス)だけを tar にし、 // 復元は追跡するパスを 1 つでも含む保存を捨てる。取り込んだリポジトリの .ndf/ を上書きしない import { execFileSync } from "node:child_process"; -export const STATE_PATHS = [".ndf/tmp", ".ndf/loop"]; +// .ndf/organizer は Factory のオーガナイザーとの置き場(#2439)。依頼と答えを周回と再開をまたいで残す +export const STATE_PATHS = [".ndf/tmp", ".ndf/loop", ".ndf/organizer"]; + +// 名前で決まる状態のファイル。配布の提示のプランが作る承認資料(parallel_release.py の plan_parallel_release が +// issues/approval-<プラグイン>-v<版>-parallel-<連番>-<提示>.md に書く)は、承認の後の新しいコンテナで confirm のプランが +// --from approve で読むため、追跡していなければ保存と復元の対象にする +export const STATE_FILE_PATTERNS = [/^issues\/approval-[^/]+-parallel-\d+-\d+\.md$/]; // 保存するパス。開発ループの実行は、そのループの引継ぎ文書(.ndf/handoff/loop-<名>.md)を 1 つ足す(#2347) export function statePaths(loopName?: string | null): string[] { @@ -18,7 +24,7 @@ export function trackedFiles(repoDir: string, run: Exec = exec): Set { // 保存の中のパスのうち、追跡するパスか、状態ディレクトリの外に当たるもの export function trackedEntries(entries: string[], tracked: Set, paths: string[] = STATE_PATHS): string[] { - const inState = (e: string) => paths.some((s) => e === s || e.startsWith(`${s}/`)); + const inState = (e: string) => paths.some((s) => e === s || e.startsWith(`${s}/`)) || STATE_FILE_PATTERNS.some((p) => p.test(e)); return entries .map((e) => e.replace(/^\.\//, "").replace(/\/$/, "")) .filter((e) => e !== "" && (tracked.has(e) || !inState(e))); @@ -34,7 +40,18 @@ export class StateRejectedError extends Error { } } -// 保存する。追跡しない状態ディレクトリだけを tar にする +// 名前で決まる状態のファイルのうち、追跡しないもの(無視するファイルも含む) +export function untrackedStateFiles(repoDir: string, run: Exec = exec): string[] { + try { + return run("git", ["ls-files", "--others", "-z", "--", "issues"], repoDir) + .split("\0") + .filter((f) => STATE_FILE_PATTERNS.some((p) => p.test(f))); + } catch { + return []; + } +} + +// 保存する。追跡しない状態ディレクトリと、名前で決まる追跡しない状態のファイルだけを tar にする export function packState(repoDir: string, archive: string, run: Exec = exec, paths: string[] = STATE_PATHS): void { const present = paths.filter((p) => { try { @@ -44,6 +61,7 @@ export function packState(repoDir: string, archive: string, run: Exec = exec, pa return false; } }); + present.push(...untrackedStateFiles(repoDir, run)); run("tar", ["-czf", archive, "--", ...(present.length > 0 ? present : ["--files-from=/dev/null"])], repoDir); } diff --git a/factory/runner/src/step-details.test.ts b/factory/runner/src/step-details.test.ts new file mode 100644 index 000000000..fa2520faf --- /dev/null +++ b/factory/runner/src/step-details.test.ts @@ -0,0 +1,159 @@ +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { validateProgressEvent, type ProgressEvent, type StepRecordedEvent } from "@ndf-factory/contracts"; +import { redact } from "./redact.ts"; +import { collectStepDetails, parseNumstat, type GitExec } from "./step-details.ts"; +import { common, fitStep, MAX_EVENT_BYTES, phaseChanged, phaseNameOf, translateProgressLine } from "./translate.ts"; + +const ID = { sprintRunId: "sr1", jobId: "job1", repo: "org-a/repo-a" }; +const AT = "2026-11-04T09:02:11.000Z"; +const full = (body: Pick) => ({ ...common(ID, 1, AT), ...body }) as ProgressEvent; +const SHA = (c: string) => c.repeat(40); + +describe("フェーズの名前の写し(受け入れ条件 4・決定 1)", () => { + it("プランの フェーズ の 6 つの値を 5 つの名前へ写し、表に無い値・読めないときは計画の名前を送る", () => { + const cases: [string, string][] = [ + ["設計", "設計"], + ["実装", "実装"], + ["検査", "検査"], + ["配布(開発版)", "取り込み"], + ["配布(本番)", "仕上げ"], + ["まとめ", "仕上げ"], + ]; + for (const [plan, name] of cases) expect(phaseNameOf("7-release", plan)).toBe(name); + expect(phaseNameOf("8-release-request", "配布の依頼")).toBe("release-request"); + expect(phaseNameOf("5-impl-2172", null)).toBe("impl"); + const changed = phaseChanged("5-impl-2172", "実装"); + expect(changed.payload).toEqual({ phase: "実装", issues: [2172] }); + expect(validateProgressEvent(full(changed)).ok).toBe(true); + }); +}); + +describe("伏せ字(I3・決定 7)", () => { + it("NDF の 4 つの形と、ランナーが持つ秘密の値を *** にする", () => { + const text = [ + "push https://x-access-token:abcdEFGH1234@github.com/o/r.git", + "token ghs_ABCDEFGHijklmnop1234", + "x-access-token:zzzzzzzzzz に失敗", + "Authorization: Bearer eyJhbGciOi.secret", + "key sk-ant-test-SECRET-0001 を読んだ", + ].join("\n"); + const out = redact(text, ["sk-ant-test-SECRET-0001", "short"]); + for (const leaked of ["abcdEFGH1234", "ghs_ABCDEFGHijklmnop1234", "zzzzzzzzzz", "eyJhbGciOi.secret", "sk-ant-test-SECRET-0001"]) { + expect(out).not.toContain(leaked); + } + expect(out).toContain("https://***@github.com/o/r.git"); + expect(out).toContain("Authorization: ***"); + }); +}); + +describe("事象 1 件の上限(I2)", () => { + // 4 バイトの文字で、定義の上限の内のまま 300 KiB を超える + it("300 KiB になる工程の中身を 256 KiB 以下に削り、output.truncated を真にし、契約に合う", () => { + const payload: StepRecordedEvent["payload"] = { + step: "impl", + exit_code: 0, + seconds: 1, + summary: "s", + output: { kind: "report", text: "あ".repeat(10_000), truncated: false }, + files: Array.from({ length: 100 }, (_, i) => ({ path: `${"𠀋".repeat(500)}${i}`, added: 1, deleted: 0 })), + decisions: Array.from({ length: 20 }, () => ({ question: "𠀋".repeat(1000), answer: "𠀋".repeat(1000), by: "ai" as const })), + }; + expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan(300 * 1024); + const fitted = fitStep(payload); + const event = full({ type: "step.recorded", payload: fitted }); + expect(Buffer.byteLength(JSON.stringify(event))).toBeLessThanOrEqual(MAX_EVENT_BYTES); + expect(fitted.output!.truncated).toBe(true); + expect(validateProgressEvent(event).ok).toBe(true); + }); + + it("上限に収まる工程はそのまま送る", () => { + const payload: StepRecordedEvent["payload"] = { step: "impl", exit_code: 0, seconds: 1, summary: "s", files: [] }; + expect(fitStep(payload)).toEqual(payload); + }); +}); + +describe("工程の中身の収集(決定 3〜6)", () => { + const setup = () => { + const root = mkdtempSync(join(tmpdir(), "step-details-")); + const workdir = join(root, "wt"); + mkdirSync(workdir); + const stateDir = join(root, "5-impl-2172-state"); + mkdirSync(stateDir); + writeFileSync(join(root, "5-impl-2172.json"), JSON.stringify({ フェーズ: "実装", 作業場所: workdir })); + writeFileSync(join(stateDir, "01-impl.out"), "## 作業の報告\n- 結果: 完了\ntoken ghs_ABCDEFGHijklmnop1234\n"); + return { root, workdir, stateDir }; + }; + const line = { kind: "step", at: AT, step: "impl", type: "work", exit: 0, seconds: 60, summary: "済んだ", out: "01-impl.out" }; + + it("プランのフェーズ・計画の名前の課題・時間の窓のコミットのファイル・作業場所の判断・作業の報告を集め、伏せて送る", async () => { + const { workdir, stateDir } = setup(); + const calls: { args: string[]; cwd: string }[] = []; + const git: GitExec = async (args, cwd) => { + calls.push({ args, cwd }); + return { code: 0, stdout: [`@${SHA("b")} ${SHA("a")}`, "", "3\t1\tfactory/a.ts", "-\t-\timg.png", `@${SHA("a")} ${SHA("9")}`, "", "2\t0\tfactory/a.ts", ""].join("\n") }; + }; + const ledgerRows = async () => [ + { kind: "decision", cwd: `${workdir}/sub`, at: "2026-11-04T09:01:40Z", question: "どちらにする", answer: "A にする" }, + { kind: "decision", cwd: "/elsewhere", at: "2026-11-04T09:01:40Z", question: "別の作業場所", answer: "x" }, + { kind: "decision", cwd: workdir, at: "2026-11-04T08:00:00Z", question: "窓の外", answer: "x" }, + ]; + const details = await collectStepDetails({ git, ledgerRows, secrets: async () => [] }, "5-impl-2172", stateDir, line); + expect(calls[0]!.cwd).toBe(workdir); + expect(calls[0]!.args).toContain("--since=2026-11-04T09:01:11+00:00"); + expect(calls[0]!.args).toContain("--until=2026-11-04T09:02:11+00:00"); + expect(details).toMatchObject({ + phase: "実装", + issues: [2172], + files: [ + { path: "factory/a.ts", added: 5, deleted: 1 }, + { path: "img.png", added: null, deleted: null }, + ], + commit_range: { from: SHA("9"), to: SHA("b") }, + decisions: [{ question: "どちらにする", answer: "A にする", by: "ai" }], + output: { kind: "report", truncated: false }, + }); + expect(details.output!.text).not.toContain("ghs_ABCDEFGHijklmnop1234"); + const body = translateProgressLine(line, ID.repo, details)!; + expect(validateProgressEvent(full(body)).ok).toBe(true); + }); + + it("作業場所が無い・git が 0 以外で終わると、ファイルとコミットの範囲を省く。run のステップはログ", async () => { + const { stateDir } = setup(); + const git: GitExec = async () => ({ code: 128, stdout: "" }); + const details = await collectStepDetails({ git, ledgerRows: async () => [], secrets: async () => [] }, "5-impl-2172", stateDir, { ...line, type: "run" }); + expect(details.files).toBeUndefined(); + expect(details.commit_range).toBeUndefined(); + expect(details.output!.kind).toBe("log"); + }); + + it("プランの JSON が読めないときはフェーズに計画の名前を入れ、スプリントで 1 つの計画は課題を持たない", async () => { + const root = mkdtempSync(join(tmpdir(), "step-details-")); + const stateDir = join(root, "6-check-state"); + mkdirSync(stateDir); + const details = await collectStepDetails({ git: async () => ({ code: 0, stdout: "" }), ledgerRows: async () => [], secrets: async () => [] }, "6-check", stateDir, line); + expect(details).toEqual({ phase: "check" }); + }); + + it("出力の参照が状態のディレクトリの外(../・絶対パス・外を指すシンボリックリンク)なら出力を省く", async () => { + const root = mkdtempSync(join(tmpdir(), "step-details-")); + const stateDir = join(root, "6-check-state"); + mkdirSync(stateDir); + writeFileSync(join(root, "secret.pem"), "PRIVATE KEY"); + symlinkSync(join(root, "secret.pem"), join(stateDir, "01-link.out")); + writeFileSync(join(stateDir, "02-check.out"), "通った"); + const deps = { git: async () => ({ code: 0, stdout: "" }), ledgerRows: async () => [], secrets: async () => [] }; + for (const out of ["../secret.pem", join(root, "secret.pem"), "01-link.out"]) { + const details = await collectStepDetails(deps, "6-check", stateDir, { ...line, out }); + expect(details.output).toBeUndefined(); + } + const inside = await collectStepDetails(deps, "6-check", stateDir, { ...line, out: "02-check.out" }); + expect(inside.output!.text).toBe("通った"); + }); + + it("コミットの無い工程は空の配列で、コミットの範囲を持たない", () => { + expect(parseNumstat("")).toEqual({ files: [], files_omitted: 0 }); + }); +}); diff --git a/factory/runner/src/step-details.ts b/factory/runner/src/step-details.ts new file mode 100644 index 000000000..68b14f650 --- /dev/null +++ b/factory/runner/src/step-details.ts @@ -0,0 +1,162 @@ +// 工程の中身の収集(#2172 の決定 3〜7)。NDF のプランの JSON・作業場所の git・判断の台帳(ask.jsonl)・ステップの出力(.out)から、 +// step.recorded に添える項目を集める。外のファイルとコマンドに触るのはここだけで、集められなかった項目は省く(工程の送信を止めない) +import { readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { redact } from "./redact.ts"; +import { cutUtf8, phaseNameOf, phaseOf, type StepDetails } from "./translate.ts"; + +export const MAX_FILES = 100; +export const MAX_DECISIONS = 20; +export const MAX_DECISION_CHARS = 1000; +export const MAX_PATH_CHARS = 512; +export const MAX_OUTPUT_BYTES = 32_768; + +export type GitExec = (args: string[], cwd: string) => Promise<{ code: number; stdout: string }>; + +export interface StepDetailsDeps { + git: GitExec; + // 判断の台帳の行(kind: decision。cwd・at・question・answer を持つ) + ledgerRows: () => Promise[]>; + // 送る前に伏せる秘密の値(その時点の値) + secrets: () => Promise; +} + +export interface PlanInfo { + // プランの JSON の "フェーズ"(読めなければ null) + phase: string | null; + // プランの JSON の "作業場所"(読めなければ null) + workdir: string | null; +} + +type Line = Record; + +// 状態のディレクトリ(<計画の名前>-state)の隣のプランの JSON(<計画の名前>.json) +export async function readPlan(stateDir: string): Promise { + const text = await readFile(`${stateDir.replace(/-state\/?$/, "")}.json`, "utf8").catch(() => null); + if (text === null) return { phase: null, workdir: null }; + try { + const json = JSON.parse(text) as Record; + const str = (v: unknown) => (typeof v === "string" && v !== "" ? v : null); + return { phase: str(json["フェーズ"]), workdir: str(json["作業場所"]) }; + } catch { + return { phase: null, workdir: null }; + } +} + +// 工程の時間の窓(at − seconds 〜 at)。at が読めなければ null +export function stepWindow(line: Line): { since: Date; until: Date } | null { + const until = typeof line.at === "string" ? new Date(line.at) : null; + if (!until || Number.isNaN(until.getTime())) return null; + const seconds = typeof line.seconds === "number" && line.seconds >= 0 ? line.seconds : 0; + return { since: new Date(until.getTime() - seconds * 1000), until }; +} + +// git の --since / --until は秒の精度で受ける +const gitTime = (d: Date, round: "floor" | "ceil") => { + const ms = round === "floor" ? Math.floor(d.getTime() / 1000) * 1000 : Math.ceil(d.getTime() / 1000) * 1000; + return `${new Date(ms).toISOString().slice(0, 19)}+00:00`; +}; + +const SHA = /^[0-9a-f]{40}$/; + +// git log --numstat の出力から、触ったファイル(パスごとの和)と、最初の親・最後のコミットを読む +export function parseNumstat(stdout: string): Pick { + const byPath = new Map(); + const commits: { sha: string; parent: string | null }[] = []; + for (const raw of stdout.split("\n")) { + const line = raw.trimEnd(); + if (line.startsWith("@")) { + const [sha, parent] = line.slice(1).split(" "); + if (sha && SHA.test(sha)) commits.push({ sha, parent: parent && SHA.test(parent) ? parent : null }); + continue; + } + const m = /^(-|\d+)\t(-|\d+)\t(.+)$/.exec(line); + if (!m) continue; + const num = (v: string) => (v === "-" ? null : Number(v)); + const prev = byPath.get(m[3]!); + const add = (a: number | null, b: number | null) => (a === null || b === null ? null : a + b); + byPath.set(m[3]!, prev ? { added: add(prev.added, num(m[1]!)), deleted: add(prev.deleted, num(m[2]!)) } : { added: num(m[1]!), deleted: num(m[2]!) }); + } + const all = [...byPath.entries()].map(([path, n]) => ({ path, ...n })); + const files = all.slice(0, MAX_FILES); + const out: Pick = { files, files_omitted: all.length - files.length }; + // git log は新しい順。最後のコミットが先頭、最初のコミットが末尾 + const last = commits[0]; + const first = commits.at(-1); + if (last && first?.parent) out.commit_range = { from: first.parent, to: last.sha }; + return out; +} + +const isUnder = (path: string, dir: string) => path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`); + +// 判断の台帳の行のうち、cwd が作業場所の下にあり、at が工程の窓に入るもの(決定 5)。文は伏せてから上限で切る +export function decisionsOf( + rows: Line[], + workdir: string, + window: { since: Date; until: Date }, + clean: (text: string) => string = (t) => t, +): NonNullable { + const out: NonNullable = []; + for (const row of rows) { + if (row.kind !== "decision" || typeof row.cwd !== "string" || !isUnder(row.cwd, workdir)) continue; + const at = typeof row.at === "string" ? new Date(row.at).getTime() : Number.NaN; + if (Number.isNaN(at) || at < window.since.getTime() || at > window.until.getTime()) continue; + const text = (v: unknown) => (typeof v === "string" && v !== "" ? Array.from(clean(v)).slice(0, MAX_DECISION_CHARS).join("") : null); + const question = text(row.question); + const answer = text(row.answer); + if (question && answer) out.push({ question, answer, by: "ai" }); + if (out.length >= MAX_DECISIONS) break; + } + return out; +} + +type Changes = Pick; + +// 工程の窓に作業場所で作られたコミットの差分(決定 3・4)。git log が失敗したら空(項目を省く) +async function changesOf(deps: StepDetailsDeps, workdir: string, window: { since: Date; until: Date }, clean: (text: string) => string): Promise { + const args = ["log", "--no-merges", `--since=${gitTime(window.since, "floor")}`, `--until=${gitTime(window.until, "ceil")}`, "--numstat", "--format=@%H %P"]; + const res = await deps.git(args, workdir).catch(() => null); + if (!res || res.code !== 0) return {}; + const parsed = parseNumstat(res.stdout); + const out: Changes = { files: parsed.files!.map((f) => ({ ...f, path: Array.from(clean(f.path)).slice(0, MAX_PATH_CHARS).join("") })) }; + if (parsed.files_omitted) out.files_omitted = parsed.files_omitted; + if (parsed.commit_range) out.commit_range = parsed.commit_range; + return out; +} + +// 状態のディレクトリの中の出力ファイルの実パス。../ や絶対パス、外を指すシンボリックリンクは null(外のファイルを送らない) +async function outputPath(stateDir: string, out: string): Promise { + if (out.startsWith("/") || out.split("/").includes("..")) return null; + const [dir, file] = await Promise.all([realpath(stateDir).catch(() => null), realpath(join(stateDir, out)).catch(() => null)]); + return dir && file && file !== dir && isUnder(file, dir) ? file : null; +} + +// ステップの出力(.out)。伏せてから上限のバイト数で切る。読めなければ undefined(項目を省く) +async function outputOf(stateDir: string, line: Line, clean: (text: string) => string): Promise { + if (typeof line.out !== "string" || line.out === "") return undefined; + const path = await outputPath(stateDir, line.out); + if (path === null) return undefined; + const text = await readFile(path, "utf8").catch(() => null); + if (text === null) return undefined; + const cut = cutUtf8(clean(text), MAX_OUTPUT_BYTES); + return { kind: line.type === "run" ? "log" : "report", text: cut.text, truncated: cut.cut }; +} + +// 1 つのステップの行の工程の中身。plan は計画の名前(5-impl-2172)、stateDir はその状態のディレクトリ +export async function collectStepDetails(deps: StepDetailsDeps, plan: string, stateDir: string, line: Line, info?: PlanInfo): Promise { + const { phase, workdir } = info ?? (await readPlan(stateDir)); + const secrets = await deps.secrets().catch(() => [] as string[]); + const clean = (text: string) => redact(text, secrets); + const details: StepDetails = { phase: phaseNameOf(plan, phase) }; + const issues = phaseOf(plan).issues; + if (issues.length > 0) details.issues = issues; + const window = stepWindow(line); + if (workdir && window) { + Object.assign(details, await changesOf(deps, workdir, window, clean)); + const rows = await deps.ledgerRows().catch(() => null); + if (rows) details.decisions = decisionsOf(rows, workdir, window, clean); + } + const output = await outputOf(stateDir, line, clean); + if (output) details.output = output; + return details; +} diff --git a/factory/runner/src/translate.ts b/factory/runner/src/translate.ts index 62ff3b13a..c65e7c0ab 100644 --- a/factory/runner/src/translate.ts +++ b/factory/runner/src/translate.ts @@ -12,6 +12,7 @@ import type { StepRecordedEvent, UsageRecordedEvent, } from "@ndf-factory/contracts"; +import type { PhaseName } from "@ndf-factory/contracts/phases"; export interface JobIdentity { sprintRunId: string; @@ -52,6 +53,22 @@ export function phaseOf(planName: string): { phase: string; issues: number[] } { return { phase, issues }; } +// プランの JSON の "フェーズ" の値からフェーズの名前への写し(#2172 の決定 1)。まとめ(pace: fast / auto の close)は振り返りと棚卸しを持つため仕上げ +const PLAN_PHASES: Readonly> = { + 設計: "設計", + 実装: "実装", + 検査: "検査", + "配布(開発版)": "取り込み", + "配布(本番)": "仕上げ", + まとめ: "仕上げ", +}; + +// フェーズの名前。プランの JSON が読めない(null)・表に無い値は、計画の名前の断片をそのまま返す(画面は「その他」と出す) +export function phaseNameOf(planName: string, planPhase: string | null): string { + if (planPhase !== null && Object.hasOwn(PLAN_PHASES, planPhase)) return PLAN_PHASES[planPhase]!; + return phaseOf(planName).phase; +} + // summary が JSON の文字列なら summary の値を使う function summaryOf(raw: unknown): string { if (typeof raw !== "string") return ""; @@ -66,8 +83,16 @@ function summaryOf(raw: unknown): string { type Line = Record; -// progress.jsonl の 1 行から事象の本体(common を除く)を作る。扱わない行は null -export function translateProgressLine(line: Line, repo: string): Pick | Pick | null { +// 工程の中身(#2172)。どれも任意で、集められなかった項目は持たない +export type StepDetails = Pick; + +// progress.jsonl の 1 行から事象の本体(common を除く)を作る。扱わない行は null。 +// step の行には工程の中身を添え、事象 1 件の上限に収める +export function translateProgressLine( + line: Line, + repo: string, + details: StepDetails = {}, +): Pick | Pick | null { if (line.kind === "step" && typeof line.step === "string") { const summary = summaryOf(line.summary); const payload: StepRecordedEvent["payload"] = { @@ -79,7 +104,8 @@ export function translateProgressLine(line: Line, repo: string): Pick String(v ?? "")).join("\n"), repo); if (pr) payload.pull_request = pr; - return { type: "step.recorded", payload }; + Object.assign(payload, Object.fromEntries(Object.entries(details).filter(([, v]) => v !== undefined))); + return { type: "step.recorded", payload: fitStep(payload) }; } if (line.kind === "attention") { // 取り込み(base-sync)で解けずに止まった行は Factory の語 base_conflict へ写す(#2347 の決定 10)。ほかの行の理由は変えない @@ -91,8 +117,9 @@ export function translateProgressLine(line: Line, repo: string): Pick { - return { type: "phase.changed", payload: phaseOf(planName) }; +// planPhase はプランの JSON の "フェーズ" の値(読めなければ null) +export function phaseChanged(planName: string, planPhase: string | null = null): Pick { + return { type: "phase.changed", payload: { phase: phaseNameOf(planName, planPhase), issues: phaseOf(planName).issues } }; } // claude の stream-json の result の modelUsage から、モデルごとに 1 件(#2170 の数え方: cache の読み書きを入力に足す) @@ -178,6 +205,40 @@ export function truncateGate(event: GateReachedEvent): GateReachedEvent { return build(lo); } +// 共通の列(common)に見込む大きさ。step.recorded の本体はこれを除いた分に収める +const COMMON_BUDGET = 1024; + +// UTF-8 で max バイト以下になるよう、文字の境目で切る +export function cutUtf8(text: string, max: number): { text: string; cut: boolean } { + if (Buffer.byteLength(text, "utf8") <= max) return { text, cut: false }; + let out = Buffer.from(text, "utf8").subarray(0, max).toString("utf8"); + // 途中で切れた文字は U+FFFD になるため落とす + while (out.endsWith("\uFFFD") || Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1); + return { text: out, cut: true }; +} + +// 事象 1 件の上限(256 KiB)を超える step.recorded を output.text → files → decisions の順に削る(I2)。 +// 削っても超えるなら工程の中身をすべて外す(足す前の形に戻る) +export function fitStep(payload: StepRecordedEvent["payload"]): StepRecordedEvent["payload"] { + const limit = MAX_EVENT_BYTES - COMMON_BUDGET; + const size = (p: StepRecordedEvent["payload"]) => bytes({ type: "step.recorded", payload: p }); + if (size(payload) <= limit) return payload; + let p: StepRecordedEvent["payload"] = { ...payload }; + if (p.output) { + const over = size(p) - limit; + const keep = Math.max(0, Buffer.byteLength(p.output.text, "utf8") - over); + p = { ...p, output: { ...p.output, text: cutUtf8(p.output.text, keep).text, truncated: true } }; + } + while (size(p) > limit && p.files && p.files.length > 0) { + const drop = Math.max(1, Math.ceil(p.files.length / 2)); + p = { ...p, files: p.files.slice(0, p.files.length - drop), files_omitted: (p.files_omitted ?? 0) + drop }; + } + while (size(p) > limit && p.decisions && p.decisions.length > 0) p = { ...p, decisions: p.decisions.slice(0, -1) }; + if (size(p) <= limit) return p; + const { step, exit_code, seconds, summary, pull_request } = payload; + return { step, exit_code, seconds, summary, ...(pull_request ? { pull_request } : {}) }; +} + // #2206 の代わりの判断の記録(1 行 1 件)から decision.delegated を作る。形が違う行は送らない。 // category は ask_ledger.py の decision の行に無いため、ref が指す判定の行(kind: verdict)の source から埋める export function delegatedFromRecord(record: Line, verdictSources: ReadonlyMap = new Map()): Pick | null { diff --git a/factory/web/e2e/paths.ts b/factory/web/e2e/paths.ts index f8336dd2f..e83bfa83c 100644 --- a/factory/web/e2e/paths.ts +++ b/factory/web/e2e/paths.ts @@ -2,7 +2,7 @@ export const PERSONAL = "00000000-0000-4000-8000-000000000001"; export const TEAM = "00000000-0000-4000-8000-000000000002"; -// ログインの後の画面(既存の 6 画面と見本の 2 画面) +// ログインの後の画面(既存の 6 画面と、スプリントの一覧・詳細・バックログ) export const SIGNED_IN_PATHS = [ "/account", "/teams/new", @@ -12,4 +12,6 @@ export const SIGNED_IN_PATHS = [ `/accounts/${PERSONAL}/repositories/101/sprints/new`, `/accounts/${PERSONAL}/sprints`, `/accounts/${PERSONAL}/sprints/sr-0002`, + `/accounts/${PERSONAL}/sprints/sr-0003`, + `/accounts/${PERSONAL}/repositories/101/backlog`, ]; diff --git a/factory/web/package.json b/factory/web/package.json index d56320335..17385678e 100644 --- a/factory/web/package.json +++ b/factory/web/package.json @@ -14,6 +14,7 @@ "e2e": "playwright test" }, "dependencies": { + "@ndf-factory/contracts": "*", "i18next": "^26.7.1", "oidc-client-ts": "^3.5.0", "react": "^19.3.1", diff --git a/factory/web/src/App.test.tsx b/factory/web/src/App.test.tsx index 0b568ba45..e6cc432b4 100644 --- a/factory/web/src/App.test.tsx +++ b/factory/web/src/App.test.tsx @@ -11,6 +11,7 @@ const config = { cognitoClientId: "web-client", webUrl: "https://factory.example.test", apiUrl: "https://api.example.test", + pollIntervalMs: 30_000, }; const me: Me = { @@ -90,10 +91,10 @@ describe("ログインの画面(受け入れ条件 1・2)", () => { expect(auth.getToken()).toBeNull(); }); - it("戻りの後に自分のアカウントの画面へ移り、プランが出る", async () => { + it("戻りの後に自分のアカウントの画面へ移り、料金プランが出る", async () => { const stub: Auth = { ...auth, completeLogin: async () => (sessionStorage.setItem("ndf-factory.access_token", "t"), { ok: true }) }; show("/callback?code=c&state=s", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) }, stub); - expect(await screen.findByText("プラン: Free")).toBeTruthy(); + expect(await screen.findByText("料金プラン: Free")).toBeTruthy(); expect(where()).toBe("/account"); }); }); @@ -106,7 +107,7 @@ describe("自分のアカウント(受け入れ条件 3・8)", () => { expect(await screen.findByText("同時に動かせるスプリントの実行")).toBeTruthy(); expect(screen.getByText("1")).toBeTruthy(); expect(screen.getByText("掛けない")).toBeTruthy(); - expect(screen.getByText("実行の形態: local(手元のランナー)")).toBeTruthy(); + expect(screen.getByText("実行の形態: local(手元のマシン)")).toBeTruthy(); expect(screen.getByRole("link", { name: "開発" })).toBeTruthy(); expect((screen.getByRole("option", { name: "managed(サービスの Bedrock)" }) as HTMLOptionElement).disabled).toBe(true); }); diff --git a/factory/web/src/App.tsx b/factory/web/src/App.tsx index 8de6649c2..71b92d522 100644 --- a/factory/web/src/App.tsx +++ b/factory/web/src/App.tsx @@ -17,6 +17,7 @@ import { ModelCredentialPage } from "./pages/ModelCredentialPage.tsx"; import { NewSprintPage } from "./pages/NewSprintPage.tsx"; import { SprintsPage } from "./pages/SprintsPage.tsx"; import { SprintDetailPage } from "./pages/SprintDetailPage.tsx"; +import { BacklogPage } from "./pages/BacklogPage.tsx"; import { LoopsPage } from "./pages/LoopsPage.tsx"; import { LoopDetailPage } from "./pages/LoopDetailPage.tsx"; @@ -32,6 +33,7 @@ const ELEMENTS: Partial> = { newSprint: , sprints: , sprintDetail: , + backlog: , loops: , loopDetail: , }; diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts index 3c2f2f83c..bed32de43 100644 --- a/factory/web/src/api.ts +++ b/factory/web/src/api.ts @@ -1,4 +1,5 @@ import type { Auth } from "./auth.ts"; +import type { StepDetailBody } from "@ndf-factory/contracts/views"; // API の応答の形(factory/contracts/openapi/accounts.yaml) export type Plan = "free" | "pro" | "max" | "team"; @@ -59,7 +60,7 @@ export interface Repository { installation_id: number; registered_at: string; active_sprint_runs?: number; - // 同時の実行の数の宣言。無いか null ならプランの値だけで数える + // 同時の実行の数の宣言。無いか null なら料金プランの値だけで数える max_concurrent_runs?: number | null; } @@ -88,6 +89,10 @@ export interface IssueCandidates { note: string | null; } +// 実行の終わりの状態(API の TERMINAL_RUN_STATES と同じ)。終わった実行の中身はもう変わらない +const FINISHED_RUN_STATES: ReadonlySet = new Set(["done", "stopped", "failed", "cancelled"]); +export const isFinishedRun = (state: string) => FINISHED_RUN_STATES.has(state); + export interface SprintRunSummary { sprint_run_id: string; repository_id: number; @@ -97,6 +102,66 @@ export interface SprintRunSummary { created_at: string; } +// 一覧の行(#2172)。今のフェーズは最後の phase.changed の名前で、null は始まる前。5 つの名前に無い値もそのまま来る +export interface SprintRunRow extends SprintRunSummary { + repo: string; + current_phase: string | null; + current_step: string | null; + last_event_at: string | null; +} + +export interface PullRequestLink { + repo: string; + number: number; + url: string; +} + +export interface StepRow { + key: string; + at: string; + job_id: string | null; + step: string; + phase: string | null; + issues: number[]; + exit_code: number; + seconds: number; + cost_usd: number | null; + summary: string; + pull_request: PullRequestLink | null; + has_details: boolean; +} + +export type GateState = "not_reached" | "waiting" | "approved" | "rejected"; +export type DecisionState = "none" | "waiting" | "answered"; + +export interface SprintRunDetail extends SprintRunSummary { + repo: string; + ndf_version: string; + execution_mode: ExecutionMode; + job_count: number; + current_phase: string | null; + phases: { name: string; step_keys: string[]; required: { label: string; step: string; record: { key: string; exit_code: number; seconds: number } | null }[] }[]; + columns: { + issue: number | null; + step_keys: string[]; + pull_requests: (PullRequestLink & { state: "open" | "closed" | "merged" | "unknown"; checks: { name: string; conclusion: string | null }[] })[]; + }[]; + steps: StepRow[]; + gates: { gate1: { state: GateState; at: string | null }; gate2: { state: GateState; at: string | null }; decision: { state: DecisionState; at: string | null } }; + delegated_decisions: number; + usage: { input_tokens: number; output_tokens: number; cost_usd: number }; + omitted_steps: number; +} + +export type StepDetail = StepRow & StepDetailBody; + +export interface Backlog { + repository_id: number; + repo: string; + ranking: { milestone: string; rows: { rank: number; number: number; title: string }[] } | null; + refinements: { sprint_run_id: string; key: string; at: string; exit_code: number; summary: string; pull_request: PullRequestLink | null }[]; +} + // #2347 の開発ループ(factory/contracts/openapi/loops.yaml) export type LoopState = "running" | "between_laps" | "awaiting_approval" | "stopped" | "cancelled"; export type LoopWaitReason = "slots" | "legacy_version" | "user_start"; @@ -185,9 +250,13 @@ export interface Api { issueCandidates(id: string, repositoryId: number, exclude: number[]): Promise; startSprintRun(id: string, repositoryId: number, issues: number[]): Promise<{ sprint_run_id: string; state: string }>; // repositoryId を省くとアカウントの新しい順の 50 件(画面は 1 回だけ呼んでリポジトリごとに分ける) - sprintRuns(id: string, repositoryId?: number): Promise<{ sprint_runs: SprintRunSummary[] }>; - // 実行の ID の配列で受け、見つかった要素の配列を返す(1 件のときも要素 1 個の配列)。見つからない ID の要素は返さない - sprintRunDetails(id: string, sprintRunIds: string[]): Promise; + sprintRuns(id: string, repositoryId?: number): Promise<{ sprint_runs: SprintRunRow[] }>; + // 実行の ID の配列で受け、要素の配列を返す(1 件のときも要素 1 個の配列)。無い実行は API が他人の実行と同じ 403 を返し、そのまま投げる + sprintRunDetails(id: string, sprintRunIds: string[]): Promise; + // 工程の中身。工程の鍵(事象の idempotency_key)の配列で受ける + stepDetails(id: string, sprintRunId: string, stepKeys: string[]): Promise; + // バックログ(順位の表と棚卸しの記録)。リポジトリの ID の配列で受ける + backlogs(id: string, repositoryIds: number[]): Promise; cancelSprintRun(id: string, sprintRunId: string): Promise<{ state: string }>; // 同時の実行の数の宣言(持ち主だけ)。null は宣言を外す updateRepository(id: string, repositoryId: number, maxConcurrentRuns: number | null): Promise; @@ -245,12 +314,12 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. startSprintRun: (id, repository_id, issues) => request("POST", `/accounts/${seg(id)}/sprint-runs`, { repository_id, issues }), sprintRuns: (id, repositoryId) => request("GET", `/accounts/${seg(id)}/sprint-runs${repositoryId === undefined ? "" : `?repository_id=${repositoryId}`}`), - // 詳細の経路はまだ無いため、アカウントの新しい順の 50 件から選ぶ。#2172 が詳細の経路を足したらここだけを差し替える - sprintRunDetails: async (id, sprintRunIds) => { - const wanted = new Set(sprintRunIds); - const { sprint_runs } = await request<{ sprint_runs: SprintRunSummary[] }>("GET", `/accounts/${seg(id)}/sprint-runs`); - return sprint_runs.filter((r) => wanted.has(r.sprint_run_id)); - }, + // 経路は 1 件ずつ読む + sprintRunDetails: (id, sprintRunIds) => + Promise.all(sprintRunIds.map((sprintRunId) => request("GET", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}`))), + stepDetails: (id, sprintRunId, stepKeys) => + Promise.all(stepKeys.map((key) => request("GET", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}/steps/${seg(key)}`))), + backlogs: (id, repositoryIds) => Promise.all(repositoryIds.map((repositoryId) => request("GET", `/accounts/${seg(id)}/repositories/${repositoryId}/backlog`))), cancelSprintRun: (id, sprintRunId) => request("POST", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}/cancel`), updateRepository: (id, repositoryId, max_concurrent_runs) => request("PATCH", `/accounts/${seg(id)}/repositories/${repositoryId}`, { max_concurrent_runs }), loops: (id, repositoryId) => request("GET", `/accounts/${seg(id)}/repositories/${repositoryId}/loops`), diff --git a/factory/web/src/auth.test.ts b/factory/web/src/auth.test.ts index 1c49fd111..fd48ccd23 100644 --- a/factory/web/src/auth.test.ts +++ b/factory/web/src/auth.test.ts @@ -6,6 +6,7 @@ const config = { cognitoClientId: "web-client", webUrl: "https://factory.example.test", apiUrl: "https://api.example.test", + pollIntervalMs: 30_000, }; function unsignedJwt(claims: Record) { diff --git a/factory/web/src/config.ts b/factory/web/src/config.ts index 49ff9dab0..f89825a6d 100644 --- a/factory/web/src/config.ts +++ b/factory/web/src/config.ts @@ -4,6 +4,8 @@ export interface FactoryConfig { cognitoClientId: string; webUrl: string; apiUrl: string; + // 一覧と詳細を読み直す間隔(ミリ秒。#2172 の決定 12) + pollIntervalMs: number; } declare const __FACTORY_CONFIG__: FactoryConfig; diff --git a/factory/web/src/context.tsx b/factory/web/src/context.tsx index ff9f3c720..fbe53641a 100644 --- a/factory/web/src/context.tsx +++ b/factory/web/src/context.tsx @@ -10,6 +10,8 @@ export interface Services { auth: Auth; api: Api; connection: ConnectionSource; + // 一覧と詳細を読み直す間隔(ミリ秒)。無ければ既定の 30 秒 + pollIntervalMs?: number; } export const ServicesContext = createContext(null); diff --git a/factory/web/src/dummy/api.ts b/factory/web/src/dummy/api.ts index 30a535820..1e00b781e 100644 --- a/factory/web/src/dummy/api.ts +++ b/factory/web/src/dummy/api.ts @@ -41,7 +41,31 @@ export function createDummyApi(scenario: Scenario = "normal"): Api { startSprintRun: () => read(() => ({ sprint_run_id: "sr-new", state: "starting" })), sprintRuns: (_id, repositoryId) => read(() => ({ sprint_runs: list(data.sprintRuns).filter((r) => repositoryId === undefined || r.repository_id === repositoryId) })), - sprintRunDetails: (_id, ids) => read(() => list(data.sprintRuns).filter((r) => ids.includes(r.sprint_run_id))), + // 無い実行は API と同じく 403 + sprintRunDetails: (_id, ids) => + read(() => + ids.map((id) => { + const found = list(data.sprintRunDetails).find((d) => d.sprint_run_id === id); + if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); + return found; + }), + ), + stepDetails: (_id, sprintRunId, keys) => + read(() => + keys.map((key) => { + const found = data.stepDetailOf(sprintRunId, key); + if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); + return found; + }), + ), + backlogs: (_id, repositoryIds) => + read(() => + repositoryIds.map((repositoryId) => { + const found = data.backlogs.find((b) => b.repository_id === repositoryId); + if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); + return scenario === "empty" ? { ...found, ranking: null, refinements: [] } : found; + }), + ), cancelSprintRun: () => read(() => ({ state: "cancelled" })), updateRepository: (_id, repositoryId, max) => read(() => ({ ...(data.repositories.find((r) => r.repository_id === repositoryId) ?? data.repositories[0]!), max_concurrent_runs: max })), diff --git a/factory/web/src/dummy/data.ts b/factory/web/src/dummy/data.ts index 705193ae7..2c42cf860 100644 --- a/factory/web/src/dummy/data.ts +++ b/factory/web/src/dummy/data.ts @@ -1,4 +1,4 @@ -import type { AccountDetail, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunSummary } from "../api.ts"; +import type { AccountDetail, Backlog, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunDetail, SprintRunRow, StepDetail, StepRow } from "../api.ts"; // ダミーデータ。本番の成果物にこの印が出たら check:bundle が落とす。実在の人・秘密を書かない export const DUMMY_MARKER = "ndf-factory-dummy-data"; @@ -34,10 +34,168 @@ export const repositories: Repository[] = [ { repository_id: 102, full_name: "example-org/a-repository-with-a-very-long-name-for-narrow-screens", installation_id: 9001, registered_at: "2026-10-21T00:00:00Z", active_sprint_runs: 0 }, ]; -export const sprintRuns: SprintRunSummary[] = [ - { sprint_run_id: "sr-0003", repository_id: 101, issues: [12, 15], state: "awaiting_approval", failure_reason: null, created_at: "2026-11-01T23:30:00Z" }, - { sprint_run_id: "sr-0002", repository_id: 102, issues: [8], state: "failed", failure_reason: "timeout", created_at: "2026-10-31T10:00:00Z" }, - { sprint_run_id: "sr-0001", repository_id: 101, issues: [3, 4, 5], state: "done", failure_reason: null, created_at: "2026-10-30T01:15:00Z" }, +const REPO_A = "example-org/sample-service"; +const REPO_B = "example-org/a-repository-with-a-very-long-name-for-narrow-screens"; + +export const sprintRuns: SprintRunRow[] = [ + { + sprint_run_id: "sr-0003", + repository_id: 101, + issues: [12, 15], + state: "awaiting_approval", + failure_reason: null, + created_at: "2026-11-01T23:30:00Z", + repo: REPO_A, + current_phase: "実装", + current_step: "impl", + last_event_at: "2026-11-02T00:20:00Z", + }, + { + sprint_run_id: "sr-0002", + repository_id: 102, + issues: [8], + state: "failed", + failure_reason: "timeout", + created_at: "2026-10-31T10:00:00Z", + repo: REPO_B, + current_phase: null, + current_step: null, + last_event_at: null, + }, + { + sprint_run_id: "sr-0001", + repository_id: 101, + issues: [3, 4, 5], + state: "done", + failure_reason: null, + created_at: "2026-10-30T01:15:00Z", + repo: REPO_A, + current_phase: "仕上げ", + current_step: "refine", + last_event_at: "2026-10-30T05:00:00Z", + }, +]; + +// 工程の記録の行。key は job:連番、費用は結べたものだけ +function step(key: string, at: string, name: string, phase: string, issues: number[], patch: Partial = {}): StepRow { + return { + key, + at, + job_id: key.split(":")[0]!, + step: name, + phase, + issues, + exit_code: 0, + seconds: 120, + cost_usd: null, + summary: `${name} の要約(見本)`, + pull_request: null, + has_details: false, + ...patch, + }; +} + +const PHASES = ["設計", "実装", "検査", "取り込み", "仕上げ"]; + +function detailOf(row: SprintRunRow, steps: StepRow[], patch: Partial = {}): SprintRunDetail { + const keysOf = (pred: (s: StepRow) => boolean) => steps.filter(pred).map((s) => s.key); + const last = (name: string) => { + const s = steps.filter((x) => x.step === name).at(-1); + return s ? { key: s.key, exit_code: s.exit_code, seconds: s.seconds } : null; + }; + const pr = (issue: number | null) => + steps + .filter((s) => s.pull_request && (issue === null ? s.issues.length === 0 : s.issues.includes(issue))) + .map((s) => ({ ...s.pull_request!, state: "open" as const, checks: [{ name: "factory-check", conclusion: "success" }, { name: "lint", conclusion: null }] })); + return { + sprint_run_id: row.sprint_run_id, + repository_id: row.repository_id, + issues: row.issues, + state: row.state, + failure_reason: row.failure_reason, + created_at: row.created_at, + repo: row.repo, + ndf_version: "10.27.15", + execution_mode: "byok", + job_count: 1, + current_phase: row.current_phase, + phases: PHASES.map((name) => ({ + name, + step_keys: keysOf((s) => s.phase === name), + required: name === "仕上げ" ? [{ label: "振り返り", step: "retro", record: last("retro") }, { label: "棚卸し", step: "refine", record: last("refine") }] : [], + })), + columns: [...row.issues.map((issue) => ({ issue, step_keys: keysOf((s) => s.issues.includes(issue)), pull_requests: pr(issue) })), { issue: null, step_keys: keysOf((s) => s.issues.length === 0), pull_requests: pr(null) }], + steps, + gates: { gate1: { state: "not_reached", at: null }, gate2: { state: "not_reached", at: null }, decision: { state: "none", at: null } }, + delegated_decisions: 0, + usage: { input_tokens: 1_250_000, output_tokens: 48_000, cost_usd: 3.75 }, + omitted_steps: 0, + ...patch, + }; +} + +const sr3Steps = [ + step("job-a:1", "2026-11-01T23:40:00Z", "design", "設計", [12], { cost_usd: 1.25, has_details: true, pull_request: { repo: REPO_A, number: 41, url: `https://github.com/${REPO_A}/pull/41` } }), + step("job-a:2", "2026-11-01T23:45:00Z", "design", "設計", [15], { cost_usd: 1.1 }), + step("job-a:3", "2026-11-01T23:50:00Z", "sprint-branch", "実装", []), + step("job-a:4", "2026-11-02T00:20:00Z", "impl", "実装", [12], { cost_usd: 1.4, seconds: 1800, has_details: true }), +]; + +const sr1Steps = [ + step("job-c:1", "2026-10-30T02:00:00Z", "impl", "実装", [3, 4, 5], { cost_usd: 2 }), + step("job-c:2", "2026-10-30T03:00:00Z", "check", "検査", []), + step("job-c:3", "2026-10-30T04:00:00Z", "release", "取り込み", []), + step("job-c:4", "2026-10-30T04:30:00Z", "retro", "仕上げ", [], { seconds: 300 }), + step("job-c:5", "2026-10-30T05:00:00Z", "refine", "仕上げ", [], { seconds: 240, summary: "順位を見直した(見本)" }), +]; + +export const sprintRunDetails: SprintRunDetail[] = [ + detailOf(sprintRuns[0]!, sr3Steps, { + gates: { gate1: { state: "waiting", at: "2026-11-02T00:20:00Z" }, gate2: { state: "not_reached", at: null }, decision: { state: "none", at: null } }, + delegated_decisions: 2, + }), + detailOf(sprintRuns[1]!, []), + detailOf(sprintRuns[2]!, sr1Steps, { + gates: { gate1: { state: "approved", at: "2026-10-30T01:50:00Z" }, gate2: { state: "approved", at: "2026-10-30T04:10:00Z" }, decision: { state: "none", at: null } }, + }), +]; + +const sha = (c: string) => c.repeat(40); + +// 工程の中身。中身を持たない工程は欄がどれも null +export function stepDetailOf(sprintRunId: string, key: string): StepDetail | null { + const row = sprintRunDetails.find((d) => d.sprint_run_id === sprintRunId)?.steps.find((s) => s.key === key); + if (!row) return null; + if (!row.has_details) return { ...row, files: null, files_omitted: null, commit_range: null, decisions: null, output: null, diff_url: null }; + const range = { from: sha("a"), to: sha("b") }; + return { + ...row, + files: [ + { path: "src/service/handler.ts", added: 42, deleted: 7, url: `https://github.com/${REPO_A}/blob/${range.to}/src/service/handler.ts` }, + { path: "docs/design.md", added: 120, deleted: 0, url: `https://github.com/${REPO_A}/blob/${range.to}/docs/design.md` }, + ], + files_omitted: null, + commit_range: range, + decisions: [{ question: "見本の問い: 一覧の並びはどちらにするか", answer: "新しい順にする", by: "ai" }], + output: { kind: "report", text: "## 作業の報告(見本)\n- 作業: 実装\n- 結果: 完了\n", truncated: false }, + diff_url: row.pull_request ? `${row.pull_request.url}/files` : `https://github.com/${REPO_A}/compare/${range.from}...${range.to}`, + }; +} + +export const backlogs: Backlog[] = [ + { + repository_id: 101, + repo: REPO_A, + ranking: { + milestone: "01 見本", + rows: [ + { rank: 1, number: 21, title: "見本の課題 1" }, + { rank: 2, number: 22, title: "見本の課題 2" }, + ], + }, + refinements: [{ sprint_run_id: "sr-0001", key: "job-c:5", at: "2026-10-30T05:00:00Z", exit_code: 0, summary: "順位を見直した(見本)", pull_request: null }], + }, + { repository_id: 102, repo: REPO_B, ranking: null, refinements: [] }, ]; export const issueCandidates: IssueCandidates = { diff --git a/factory/web/src/i18n/ja.json b/factory/web/src/i18n/ja.json index 44a92001a..b07cc7b59 100644 --- a/factory/web/src/i18n/ja.json +++ b/factory/web/src/i18n/ja.json @@ -27,7 +27,7 @@ "backlog": "バックログ", "approvals": "承認", "billing": "請求と使用量", - "runners": "手元のランナー", + "runners": "手元のマシン", "loops": "ループ", "loopDetail": "ループの詳細" }, @@ -61,7 +61,7 @@ "team": "Team" }, "mode": { - "local": "local(手元のランナー)", + "local": "local(手元のマシン)", "byok": "byok(キーの持ち込み)", "managed": "managed(サービスの Bedrock)" }, @@ -82,7 +82,7 @@ "idpGoogle": "Google", "idpGithub": "GitHub", "personal": "個人のアカウント", - "plan": "プラン: {{plan}}", + "plan": "料金プラン: {{plan}}", "teams": "チーム", "teamRole": "({{role}})", "owner": "持ち主", @@ -115,18 +115,19 @@ "run": "{{issues}}: {{state}}", "runWithReason": "{{issues}}: {{state}}({{reason}})", "loops": "ループ", - "maxConcurrent": "同時の実行の数(空ならプランの値)", + "maxConcurrent": "同時の実行の数(空なら料金プランの値)", "maxConcurrentRange": "1 から 100 の整数を入れてください", - "saveMaxConcurrent": "宣言する" + "saveMaxConcurrent": "宣言する", + "backlog": "バックログ" }, "sprintState": { "starting": "起動中", - "running": "動いている", + "running": "実行中", "awaiting_approval": "承認待ち", "done": "終わった", "stopped": "止まった", "failed": "失敗", - "cancelled": "取り消した", + "cancelled": "取り消し", "awaiting_reset": "利用上限の待ち" }, "modelCredential": { @@ -166,13 +167,104 @@ "createdAt": "始めた日時", "empty": "スプリントの実行はまだありません", "noMatch": "絞り込みに合う実行はありません", - "repositoryId": "リポジトリ {{id}}" + "repositoryId": "リポジトリ {{id}}", + "phase": "フェーズ", + "currentStep": "今の工程: {{step}}", + "lastEventAt": "最後の動き", + "noEvent": "—", + "summary": "件数", + "activeCount": "動いている", + "waitingCount": "対応待ち", + "tabs": "状態で絞る", + "tabAll": "すべて", + "tabActive": "動いている", + "tabWaiting": "対応待ち", + "tabFinished": "終わった" }, "sprintDetail": { - "notFound": "このスプリントの実行は見つかりません", "failureReason": "失敗の理由", "none": "無し", - "back": "スプリントの一覧へ戻る" + "back": "スプリントの一覧へ戻る", + "openGitHub": "GitHub で開く", + "overview": "概要", + "ndfVersion": "NDF の版", + "executionMode": "実行の形態", + "jobCount": "実行ジョブの数", + "phases": "フェーズ", + "phaseSteps": "{{count}} 件の工程", + "required": "必須の工程", + "notYet": "まだ", + "recordResult": "終了コード {{code}}・{{seconds}} 秒", + "steps": "工程の記録", + "stepsEmpty": "工程の記録はまだありません", + "columns": "課題で絞る", + "columnAll": "すべて", + "columnSprint": "スプリント全体", + "columnIssue": "#{{issue}}", + "omitted": "古い工程 {{count}} 件は省きました", + "stepLine": "{{step}}({{phase}})", + "stepMeta": "終了コード {{code}}・{{seconds}} 秒・{{at}}", + "gates": "承認ゲート", + "gate1": "ゲート 1", + "gate2": "ゲート 2", + "decision": "判断", + "gateState": { + "not_reached": "まだ", + "waiting": "承認待ち", + "approved": "承認された", + "rejected": "差し戻された" + }, + "decisionState": { + "none": "無し", + "waiting": "判断を待っている", + "answered": "判断に答えた" + }, + "pullRequests": "Pull Request と CI", + "pullRequestsEmpty": "Pull Request はまだありません", + "prLabel": "#{{number}}", + "prState": { + "open": "open", + "closed": "closed", + "merged": "merged", + "unknown": "不明" + }, + "checkRunning": "実行中", + "checkLine": "{{name}}: {{conclusion}}", + "delegated": "AI が代わりに判断した問い合わせ", + "usage": "使用量", + "inputTokens": "入力の token", + "outputTokens": "出力の token", + "cost": "費用", + "panel": "工程の詳細", + "backToOverview": "概要に戻る", + "stepName": "ステップ", + "exitCode": "終了コード", + "seconds": "秒", + "stepCost": "費用", + "summaryLabel": "要約", + "noCost": "—", + "files": "触ったファイル", + "filesOmitted": "ほか {{count}} 件", + "fileChange": "+{{added}} −{{deleted}}", + "binary": "バイナリ", + "decisions": "この工程で決めたこと", + "decisionLine": "{{question}} → {{answer}}", + "decidedBy": { + "ai": "AI", + "user": "利用者" + }, + "noDecisions": "決めたことはありません", + "noFiles": "触ったファイルはありません", + "diff": "差分を GitHub で見る", + "openReport": "作業の報告を開く", + "openLog": "ログを開く", + "closeOutput": "閉じる", + "truncated": "長いため途中で切りました", + "noRecord": "記録なし", + "requiredStep": { + "retro": "振り返り", + "refine": "棚卸し" + } }, "loopState": { "running": "動いている", @@ -265,5 +357,38 @@ "cancel": "ループを止める", "nextLap": "次の周回を始める", "minutes": "{{value}} 分" + }, + "phase": { + "design": "設計", + "impl": "実装", + "check": "検査", + "integrate": "取り込み", + "finish": "仕上げ", + "notStarted": "始まる前", + "other": "その他({{name}})", + "bars": "フェーズの進み", + "passed": "{{name}}(通った)", + "current": "{{name}}(今)", + "ahead": "{{name}}(まだ)" + }, + "poll": { + "stale": "読み直せませんでした({{time}})" + }, + "backlog": { + "lastRefinement": "最後の棚卸し", + "milestone": "順位の表のマイルストーン", + "none": "—", + "ranking": "着手の順位", + "rankingEmpty": "順位の表がありません", + "rank": "順位", + "issue": "課題", + "refinements": "棚卸しの記録", + "refinementsEmpty": "棚卸しの記録はまだありません", + "at": "日時", + "summary": "要約", + "pullRequest": "Pull Request", + "exitCode": "終了コード", + "back": "リポジトリへ戻る", + "issueLine": "#{{number}} {{title}}" } } diff --git a/factory/web/src/main.tsx b/factory/web/src/main.tsx index bdc250695..16f3b25c3 100644 --- a/factory/web/src/main.tsx +++ b/factory/web/src/main.tsx @@ -20,7 +20,7 @@ async function buildServices(): Promise { } const config = embeddedConfig(); const auth = createAuth(config); - return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection() }; + return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection(), pollIntervalMs: config.pollIntervalMs }; } const style = document.createElement("style"); diff --git a/factory/web/src/pages/BacklogPage.tsx b/factory/web/src/pages/BacklogPage.tsx new file mode 100644 index 000000000..9be25bb09 --- /dev/null +++ b/factory/web/src/pages/BacklogPage.tsx @@ -0,0 +1,112 @@ +import { useCallback, type ReactNode } from "react"; +import { Link, Navigate, useParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Backlog } from "../api.ts"; +import { useServices } from "../context.tsx"; +import { useFormat } from "../i18n/index.ts"; +import { usePolled } from "../poll.ts"; +import { Button } from "../ui/Button.tsx"; +import { DataList } from "../ui/DataList.tsx"; +import { DetailList } from "../ui/DetailList.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { ErrorState } from "../ui/ErrorState.tsx"; +import { LoadingState } from "../ui/LoadingState.tsx"; +import { PageHeader } from "../ui/PageHeader.tsx"; +import { Section } from "../ui/Section.tsx"; + +type Refinement = Backlog["refinements"][number]; +type RankRow = NonNullable["rows"][number]; + +// バックログの画面(#2172)。順位の表と棚卸しの記録をスプリントの外で読む。読むだけで、課題・マイルストーン・ボードへ書く操作を持たない +export function BacklogPage() { + const { t } = useTranslation(); + const format = useFormat(); + const { accountId = "", repositoryId = "" } = useParams(); + const { auth, api } = useServices(); + const hasToken = auth.getToken() !== null; + const read = useCallback(async () => (await api.backlogs(accountId, [Number(repositoryId)]))[0] ?? null, [api, accountId, repositoryId]); + // 1 回の読み出しが GitHub の全ページと実行ごとの事象を読むため、間隔では読み直さない(開いたときと「もう一度読む」だけ) + const { data: backlog, error, reload } = usePolled(read, hasToken, { poll: false }); + + if (!hasToken) return ; + const header = (title: string, actions?: ReactNode) => ( + <> + + {t("backlog.back")} + + + + ); + if (error) { + return ( + <> + {header(t("screens.backlog"))} + + + ); + } + if (backlog === null) { + return ( + <> + {header(t("screens.backlog"))} + + + ); + } + const last = backlog.refinements[0]; + return ( + <> + {header(backlog.repo, )} + +
+ {backlog.ranking === null ? ( + + ) : ( + + label={t("backlog.ranking")} + rows={backlog.ranking.rows} + rowKey={(r) => String(r.number)} + columns={[ + { key: "rank", header: t("backlog.rank"), render: (r) => format.number(r.rank) }, + { key: "issue", header: t("backlog.issue"), render: (r) => t("backlog.issueLine", { number: r.number, title: r.title }) }, + ]} + /> + )} +
+
+ {backlog.refinements.length === 0 ? ( + + ) : ( + + label={t("backlog.refinements")} + rows={backlog.refinements} + rowKey={(r) => `${r.sprint_run_id}-${r.key}`} + rowHref={(r) => `/accounts/${accountId}/sprints/${encodeURIComponent(r.sprint_run_id)}`} + columns={[ + { key: "at", header: t("backlog.at"), render: (r) => format.dateTime(r.at) }, + { key: "summary", header: t("backlog.summary"), render: (r) => r.summary }, + { + key: "pr", + header: t("backlog.pullRequest"), + render: (r) => + r.pull_request ? ( + + {t("sprintDetail.prLabel", { number: r.pull_request.number })} + + ) : ( + t("backlog.none") + ), + }, + { key: "exit", header: t("backlog.exitCode"), render: (r) => String(r.exit_code) }, + ]} + /> + )} +
+ + ); +} diff --git a/factory/web/src/pages/RepositoriesPage.tsx b/factory/web/src/pages/RepositoriesPage.tsx index e4d1622c6..381d12bd1 100644 --- a/factory/web/src/pages/RepositoriesPage.tsx +++ b/factory/web/src/pages/RepositoriesPage.tsx @@ -112,6 +112,7 @@ export function RepositoriesPage() { {r.full_name}{" "} {t("screens.newSprint")}{" "} {t("repositories.loops")}{" "} + {t("repositories.backlog")}{" "} {owner && ( + ); + const noRecord = t("sprintDetail.noRecord"); + return ( +
+ {back} + {error ? ( + {error} + ) : !step ? ( + + ) : ( + <> + +

{t("sprintDetail.files")}

+ +

{t("sprintDetail.decisions")}

+ +
+ {step.diff_url && ( + + {t("sprintDetail.diff")} + + )} + {step.output ? ( + + ) : ( + {noRecord} + )} +
+ {step.output && showOutput && ( + <> + {step.output.truncated &&

{t("sprintDetail.truncated")}

} +
{step.output.text}
+ + )} + + )} +
+ ); +} + +// 工程の記録(新しいものが上)。課題の欄ごとに絞り、押すと右が詳細パネルになる +function StepLog({ detail, selected, onSelect }: { detail: SprintRunDetail; selected: string | null; onSelect: (key: string) => void }) { + const { t } = useTranslation(); + const format = useFormat(); + const phaseLabel = usePhaseLabel(); + const [column, setColumn] = useState("all"); + const keys = column === "all" ? null : new Set(detail.columns.find((c) => c.issue === column)?.step_keys ?? []); + const rows: StepRow[] = [...detail.steps].reverse().filter((s) => keys === null || keys.has(s.key)); + const columnLabel = (issue: number | null) => (issue === null ? t("sprintDetail.columnSprint") : t("sprintDetail.columnIssue", { issue })); + return ( +
+
+ + {detail.columns.map((c) => ( + + ))} +
+ {detail.omitted_steps > 0 &&

{t("sprintDetail.omitted", { count: detail.omitted_steps })}

} + {rows.length === 0 ? ( + + ) : ( +
    + {rows.map((s) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +// フェーズの欄。仕上げには振り返りと棚卸しの行を常に置く +function PhasesView({ detail }: { detail: SprintRunDetail }) { + const { t } = useTranslation(); + const label = usePhaseLabel(); + return ( +
+ +
    + {detail.phases.map((p) => ( +
  • + {label(p.name)} {t("sprintDetail.phaseSteps", { count: p.step_keys.length })} + {p.required.length > 0 && ( + ({ + term: r.step in REQUIRED_KEYS ? t(REQUIRED_KEYS[r.step as keyof typeof REQUIRED_KEYS]) : r.label, + value: r.record === null ? t("sprintDetail.notYet") : t("sprintDetail.recordResult", { code: r.record.exit_code, seconds: r.record.seconds }), + }))} + /> + )} +
  • + ))} +
+
+ ); +} + +// スプリントの詳細(#2172)。開いている間は読み直し(終わった実行は除く)、工程を押すと右が詳細パネルに切り替わる export function SprintDetailPage() { const { t } = useTranslation(); const format = useFormat(); const sprintState = useSprintState(); const { accountId = "", sprintRunId = "" } = useParams(); const { auth, api } = useServices(); - const onFailure = useFailureHandler(); - const [loaded, setLoaded] = useState(null); - const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); const hasToken = auth.getToken() !== null; - const load = useCallback(async () => { - setError(null); - setLoaded(null); - try { - const [runs, names] = await Promise.all([api.sprintRunDetails(accountId, [sprintRunId]), repositoryNames(() => api.repositories(accountId))]); - const run = runs[0] ?? null; - setLoaded({ run, repository: run ? (names.get(run.repository_id) ?? null) : null }); - } catch (err) { - setError(onFailure(err)); - } - }, [api, accountId, sprintRunId, onFailure]); - - useEffect(() => { - if (hasToken) void load(); - }, [hasToken, load]); + const read = useCallback(async () => (await api.sprintRunDetails(accountId, [sprintRunId]))[0] ?? null, [api, accountId, sprintRunId]); + // 終わった実行は変わらないため、間隔では読み直さない(開いたときと手動の読み直しだけ。1 回の読み出しが事象の全件を読む) + const { data: detail, error, staleSince, reload } = usePolled(read, hasToken, { poll: (d) => d === null || !isFinishedRun(d.state) }); if (!hasToken) return ; const back = ( @@ -59,11 +307,11 @@ export function SprintDetailPage() { return ( <> {header(t("screens.sprintDetail"))} - void load()} /> + ); } - if (!loaded) { + if (detail === null) { return ( <> {header(t("screens.sprintDetail"))} @@ -71,29 +319,41 @@ export function SprintDetailPage() { ); } - const { run, repository } = loaded; - if (!run) { - return ( - <> - {header(t("screens.sprintDetail"))} - - - ); - } - const state = sprintState(run.state); + const state = sprintState(detail.state); return ( <> - {header(issueNumbers(run))} - }, - { term: t("sprintDetail.failureReason"), value: run.failure_reason ?? t("sprintDetail.none") }, - { term: t("sprints.createdAt"), value: format.dateTime(run.created_at) }, - ]} - /> + {header(issueNumbers(detail))} + +
+ {detail.repo}{" "} + + {t("sprintDetail.openGitHub")} + +
+
+ +
+ +
+
+ +
+
+ {selected === null ? ( + + ) : ( + setSelected(null)} /> + )} +
+
); } diff --git a/factory/web/src/pages/SprintsPage.tsx b/factory/web/src/pages/SprintsPage.tsx index 28a630a9e..a4f1129a1 100644 --- a/factory/web/src/pages/SprintsPage.tsx +++ b/factory/web/src/pages/SprintsPage.tsx @@ -1,10 +1,13 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Navigate, useParams } from "react-router"; import { useTranslation } from "react-i18next"; -import type { Repository, SprintRunSummary } from "../api.ts"; -import { useFailureHandler, useServices } from "../context.tsx"; +import type { Repository, SprintRunRow } from "../api.ts"; +import { useServices } from "../context.tsx"; import { useFormat } from "../i18n/index.ts"; +import { usePolled } from "../poll.ts"; +import { Button } from "../ui/Button.tsx"; import { DataList } from "../ui/DataList.tsx"; +import { DetailList } from "../ui/DetailList.tsx"; import { EmptyState } from "../ui/EmptyState.tsx"; import { ErrorState } from "../ui/ErrorState.tsx"; import { LoadingState } from "../ui/LoadingState.tsx"; @@ -12,8 +15,7 @@ import { PageHeader } from "../ui/PageHeader.tsx"; import { SearchField } from "../ui/SearchField.tsx"; import { StatusBadge } from "../ui/StatusBadge.tsx"; import { issueNumbers, useSprintState } from "./parts.tsx"; - -type Loaded = { runs: SprintRunSummary[]; repositories: Map }; +import { PhaseBars, StaleNotice } from "./sprintParts.tsx"; // リポジトリの ID から名前を引く表。名前が読めなくても一覧は出す export async function repositoryNames(list: () => Promise<{ repositories: Repository[] }>): Promise> { @@ -24,34 +26,34 @@ export async function repositoryNames(list: () => Promise<{ repositories: Reposi } } -// スプリントの一覧(見本の画面)。中身は #2172 が置き換える +type Tab = "all" | "active" | "waiting" | "finished"; +const TABS: readonly Tab[] = ["all", "active", "waiting", "finished"]; +const TAB_KEYS = { all: "sprints.tabAll", active: "sprints.tabActive", waiting: "sprints.tabWaiting", finished: "sprints.tabFinished" } as const; + +// 動いている(starting・running・利用上限の待ち)・対応待ち(awaiting_approval)・終わった(それ以外) +const ACTIVE = new Set(["starting", "running", "awaiting_reset"]); +function tabOf(state: string): Exclude { + if (ACTIVE.has(state)) return "active"; + if (state === "awaiting_approval") return "waiting"; + return "finished"; +} + +// スプリントの一覧(#2172)。並列に流れる実行の今のフェーズと状態を出し、開いている間は読み直す export function SprintsPage() { const { t } = useTranslation(); const format = useFormat(); const sprintState = useSprintState(); const { accountId = "" } = useParams(); const { auth, api } = useServices(); - const onFailure = useFailureHandler(); - const [loaded, setLoaded] = useState(null); - const [error, setError] = useState(null); const [query, setQuery] = useState(""); + const [tab, setTab] = useState("all"); const hasToken = auth.getToken() !== null; - const load = useCallback(async () => { - setError(null); - setLoaded(null); - try { - const [runs, repositories] = await Promise.all([api.sprintRuns(accountId), repositoryNames(() => api.repositories(accountId))]); - const sorted = [...runs.sprint_runs].sort((a, b) => b.created_at.localeCompare(a.created_at)); - setLoaded({ runs: sorted, repositories }); - } catch (err) { - setError(onFailure(err)); - } - }, [api, accountId, onFailure]); - - useEffect(() => { - if (hasToken) void load(); - }, [hasToken, load]); + const read = useCallback(async () => { + const runs = await api.sprintRuns(accountId); + return [...runs.sprint_runs].sort((a, b) => b.created_at.localeCompare(a.created_at)); + }, [api, accountId]); + const { data: runs, error, staleSince, reload } = usePolled(read, hasToken); if (!hasToken) return ; const header = ; @@ -59,11 +61,11 @@ export function SprintsPage() { return ( <> {header} - void load()} /> + ); } - if (!loaded) { + if (!runs) { return ( <> {header} @@ -72,25 +74,52 @@ export function SprintsPage() { ); } const needle = query.trim().replace(/^#/, ""); - const rows = needle === "" ? loaded.runs : loaded.runs.filter((r) => r.issues.some((n) => String(n).includes(needle))); + const byTab = tab === "all" ? runs : runs.filter((r) => tabOf(r.state) === tab); + const rows = needle === "" ? byTab : byTab.filter((r) => r.issues.some((n) => String(n).includes(needle))); return ( <> {header} - {loaded.runs.length === 0 ? ( + + {runs.length === 0 ? ( ) : ( <> + r.state === "starting" || r.state === "running").length) }, + { term: t("sprints.waitingCount"), value: format.number(runs.filter((r) => r.state === "awaiting_approval").length) }, + ]} + /> +
+ {TABS.map((x) => ( + + ))} +
{rows.length === 0 ? ( ) : ( - label={t("sprints.list")} rows={rows} rowKey={(r) => r.sprint_run_id} rowHref={(r) => `/accounts/${accountId}/sprints/${encodeURIComponent(r.sprint_run_id)}`} columns={[ { key: "issues", header: t("sprints.issues"), render: (r) => issueNumbers(r) }, + { key: "repository", header: t("sprints.repository"), render: (r) => r.repo || t("sprints.repositoryId", { id: r.repository_id }) }, + { + key: "phase", + header: t("sprints.phase"), + render: (r) => ( + <> + + {r.current_step !== null && {t("sprints.currentStep", { step: r.current_step })}} + + ), + }, { key: "state", header: t("sprints.state"), @@ -99,12 +128,8 @@ export function SprintsPage() { return ; }, }, - { - key: "repository", - header: t("sprints.repository"), - render: (r) => loaded.repositories.get(r.repository_id) ?? t("sprints.repositoryId", { id: r.repository_id }), - }, { key: "createdAt", header: t("sprints.createdAt"), render: (r) => format.dateTime(r.created_at) }, + { key: "lastEventAt", header: t("sprints.lastEventAt"), render: (r) => (r.last_event_at === null ? t("sprints.noEvent") : format.dateTime(r.last_event_at)) }, ]} /> )} diff --git a/factory/web/src/pages/loops.test.tsx b/factory/web/src/pages/loops.test.tsx index bc0bf1b71..777305142 100644 --- a/factory/web/src/pages/loops.test.tsx +++ b/factory/web/src/pages/loops.test.tsx @@ -144,7 +144,7 @@ describe("リポジトリの同時の実行の数(受け入れ条件 8)", () const update = vi.spyOn(api, "updateRepository"); const cards = await screen.findByRole("list", { name: "登録したリポジトリ" }); const first = within(cards).getAllByRole("listitem")[0]!; - const field = within(first).getByRole("textbox", { name: "同時の実行の数(空ならプランの値)" }); + const field = within(first).getByRole("textbox", { name: "同時の実行の数(空なら料金プランの値)" }); await userEvent.type(field, "101"); await userEvent.click(within(first).getByRole("button", { name: "宣言する" })); expect(within(first).getByText("1 から 100 の整数を入れてください")).toBeTruthy(); diff --git a/factory/web/src/pages/sample.test.tsx b/factory/web/src/pages/sample.test.tsx index d492dcfc2..ed556d4f0 100644 --- a/factory/web/src/pages/sample.test.tsx +++ b/factory/web/src/pages/sample.test.tsx @@ -65,9 +65,9 @@ describe("見本の画面(受け入れ条件 7・8)", () => { expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("スプリントの一覧"); }); - it("見つからない実行の詳細は空の状態を出す", async () => { + it("見つからない実行の詳細は、他人の実行と同じく権限が無い旨を出す(有無を明かさない)", async () => { show(`${sprints}/sr-none`); - expect(await screen.findByText("このスプリントの実行は見つかりません")).toBeTruthy(); + expect(await screen.findByText("このアカウントを見る権限がありません")).toBeTruthy(); }); }); @@ -112,7 +112,7 @@ describe("言語を足す(受け入れ条件 13・I6)", () => { expect(within(table).getByRole("columnheader", { name: "Issues" })).toBeTruthy(); // 訳の無いキーは日本語で出る expect(within(table).getByRole("columnheader", { name: "状態" })).toBeTruthy(); - expect(within(table).getByText(/11\/1\/2026/)).toBeTruthy(); + expect(within(table).getAllByText(/11\/1\/2026/).length).toBeGreaterThan(0); }); it("ブラウザの言語に翻訳が無ければ日本語で出る", async () => { diff --git a/factory/web/src/pages/sprintParts.tsx b/factory/web/src/pages/sprintParts.tsx new file mode 100644 index 000000000..69ac8af4d --- /dev/null +++ b/factory/web/src/pages/sprintParts.tsx @@ -0,0 +1,46 @@ +// スプリントの一覧と詳細が共有する部品(#2172)。フェーズの名前は契約の列挙(@ndf-factory/contracts/phases)を正にする +import { PHASE_NAMES, type PhaseName } from "@ndf-factory/contracts/phases"; +import { useTranslation } from "react-i18next"; +import { useFormat } from "../i18n/index.ts"; +import { Alert } from "../ui/Alert.tsx"; + +// 列挙の順と同じ並びの翻訳のキー +const PHASE_KEYS = ["design", "impl", "check", "integrate", "finish"] as const satisfies { length: (typeof PHASE_NAMES)["length"] }; + +// フェーズの名前の表示。null は始まる前、5 つに無い名前は「その他(<名前>)」 +export function usePhaseLabel(): (name: string | null) => string { + const { t } = useTranslation(); + return (name) => { + if (name === null) return t("phase.notStarted"); + const i = (PHASE_NAMES as readonly string[]).indexOf(name); + return i < 0 ? t("phase.other", { name }) : t(`phase.${PHASE_KEYS[i]!}`); + }; +} + +export function phaseIndex(name: string | null): number { + return name === null ? -1 : (PHASE_NAMES as readonly string[]).indexOf(name); +} + +// 5 つの棒。今より前は濃く、今は薄く。始まる前と 5 つに無い名前は文字だけで出す +export function PhaseBars({ current }: { current: string | null }) { + const { t } = useTranslation(); + const label = usePhaseLabel(); + const at = phaseIndex(current); + if (at < 0) return {label(current)}; + return ( + + {PHASE_NAMES.map((name: PhaseName, i) => ( + + ))} + {label(current)} + + ); +} + +// 読み直しが失敗したときの断り。前の表示は残す +export function StaleNotice({ since }: { since: string | null }) { + const { t } = useTranslation(); + const format = useFormat(); + if (since === null) return null; + return {t("poll.stale", { time: format.dateTime(since) })}; +} diff --git a/factory/web/src/pages/sprints.test.tsx b/factory/web/src/pages/sprints.test.tsx new file mode 100644 index 000000000..554515547 --- /dev/null +++ b/factory/web/src/pages/sprints.test.tsx @@ -0,0 +1,306 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, useLocation } from "react-router"; +import { App } from "../App.tsx"; +import { ApiFailure, createApi, type Api, type SprintRunRow, type StepDetail } from "../api.ts"; +import type { Auth } from "../auth.ts"; +import { fixedConnection } from "../connection.ts"; +import { createDummyApi, createDummyAuth } from "../dummy/index.ts"; +import { PERSONAL_ID, sprintRuns } from "../dummy/data.ts"; +import type { LocaleSettings } from "../i18n/settings.ts"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const tokyo: LocaleSettings = { language: "ja", locale: "ja-JP", timeZone: "Asia/Tokyo" }; +const online = fixedConnection({ online: true, lastReceivedAt: null, pendingOperations: 0 }); +const sprints = `/accounts/${PERSONAL_ID}/sprints`; + +function Where() { + const l = useLocation(); + return {l.pathname}; +} + +function show(path: string, api: Api = createDummyApi(), auth: Auth = createDummyAuth(), pollIntervalMs?: number) { + return render( + + + + , + ); +} + +describe("スプリントの一覧(受け入れ条件 1〜3)", () => { + it("各行に今のフェーズと今の工程を出し、phase.changed の無い実行は「始まる前」、5 つに無い名前は「その他(<名前>)」", async () => { + const api = createDummyApi(); + const rows: SprintRunRow[] = [ + { ...sprintRuns[0]!, current_phase: "設計" }, + { ...sprintRuns[1]!, sprint_run_id: "sr-x", current_phase: "検査", created_at: "2026-10-31T11:00:00Z" }, + { ...sprintRuns[2]!, current_phase: "仕上げ" }, + { ...sprintRuns[1]!, current_phase: null }, + { ...sprintRuns[1]!, sprint_run_id: "sr-y", current_phase: "準備", created_at: "2026-10-29T00:00:00Z" }, + ]; + api.sprintRuns = async () => ({ sprint_runs: rows }); + show(sprints, api); + const table = await screen.findByRole("table", { name: "スプリントの実行" }); + const phases = within(table) + .getAllByRole("row") + .slice(1) + .map((r) => r.querySelector("[data-label='フェーズ'] .phase-text")?.textContent); + expect(phases).toEqual(["設計", "検査", "始まる前", "仕上げ", "その他(準備)"]); + expect(within(table).getAllByText("今の工程: impl")).toHaveLength(1); + expect(within(table).getByRole("img", { name: "検査(今)" })).toBeTruthy(); + }); + + it("件数の札と、状態のタブ(動いている・対応待ち・終わった)で画面の中で絞る", async () => { + show(sprints); + const table = await screen.findByRole("table"); + const summary = within(screen.getByLabelText("件数")).getAllByRole("definition"); + expect(summary.map((d) => d.textContent)).toEqual(["0", "1"]); + await userEvent.click(screen.getByRole("button", { name: "対応待ち" })); + expect(within(screen.getByRole("table")).getAllByRole("link").map((a) => a.textContent)).toEqual(["#12 #15"]); + await userEvent.click(screen.getByRole("button", { name: "終わった" })); + expect(within(screen.getByRole("table")).getAllByRole("link").map((a) => a.textContent)).toEqual(["#8", "#3 #4 #5"]); + await userEvent.click(screen.getByRole("button", { name: "動いている" })); + expect(screen.getByText("絞り込みに合う実行はありません")).toBeTruthy(); + expect(table).toBeTruthy(); + }); +}); + +const detail = `${sprints}/sr-0003`; + +describe("スプリントの詳細(受け入れ条件 5〜9)", () => { + it("承認の欄はゲート 1・ゲート 2・判断の 3 つで、ゲート 1 が承認待ち。仕上げに振り返りと棚卸しが「まだ」で並ぶ", async () => { + show(detail); + const gates = await screen.findByRole("region", { name: "承認ゲート" }); + expect(within(gates).getAllByRole("term").map((x) => x.textContent)).toEqual(["ゲート 1", "ゲート 2", "判断"]); + expect(within(gates).getAllByRole("definition").map((x) => x.textContent)).toEqual(["承認待ち", "まだ", "無し"]); + const required = screen.getByLabelText("必須の工程"); + expect(within(required).getAllByRole("definition").map((x) => x.textContent)).toEqual(["まだ", "まだ"]); + }); + + it("仕上げの記録が届いた実行は、振り返りと棚卸しの終了コードと秒を出す", async () => { + show(`${sprints}/sr-0001`); + const required = await screen.findByLabelText("必須の工程"); + expect(within(required).getAllByRole("definition").map((x) => x.textContent)).toEqual(["終了コード 0・300 秒", "終了コード 0・240 秒"]); + }); + + it("課題の欄で工程の記録を絞り、新しい工程が上に並ぶ", async () => { + show(detail); + const log = await screen.findByRole("region", { name: "工程の記録" }); + const names = () => within(log).getAllByRole("button", { pressed: false }).filter((b) => b.className.includes("sprint-steps__row")).map((b) => b.querySelector("strong")!.textContent); + expect(names()).toEqual(["impl(実装)", "sprint-branch(実装)", "design(設計)", "design(設計)"]); + await userEvent.click(within(log).getByRole("button", { name: "#12" })); + expect(names()).toEqual(["impl(実装)", "design(設計)"]); + await userEvent.click(within(log).getByRole("button", { name: "スプリント全体" })); + expect(names()).toEqual(["sprint-branch(実装)"]); + }); + + it("工程を押すと右が詳細パネルになり、触ったファイル・決めたこと・作業の報告を出し、「概要に戻る」で承認の欄へ戻る", async () => { + show(detail); + const log = await screen.findByRole("region", { name: "工程の記録" }); + await userEvent.click(within(log).getByRole("button", { name: /^impl(実装)/ })); + const panel = await screen.findByRole("region", { name: "工程の詳細" }); + expect(screen.queryByRole("region", { name: "承認ゲート" })).toBeNull(); + expect(await within(panel).findByRole("link", { name: "src/service/handler.ts" })).toBeTruthy(); + expect(within(panel).getByText("+42 −7")).toBeTruthy(); + expect(within(panel).getByText(/見本の問い/)).toBeTruthy(); + expect(within(panel).getByRole("link", { name: "差分を GitHub で見る" }).getAttribute("href")).toContain("/compare/"); + await userEvent.click(within(panel).getByRole("button", { name: "作業の報告を開く" })); + expect(within(panel).getByText(/作業の報告(見本)/)).toBeTruthy(); + await userEvent.click(within(panel).getByRole("button", { name: "概要に戻る" })); + expect(screen.getByRole("region", { name: "承認ゲート" })).toBeTruthy(); + }); + + it("工程の中身を持たない工程は、各欄に「記録なし」を出す", async () => { + show(detail); + const log = await screen.findByRole("region", { name: "工程の記録" }); + await userEvent.click(within(log).getByRole("button", { name: /^sprint-branch/ })); + const panel = await screen.findByRole("region", { name: "工程の詳細" }); + expect(await within(panel).findAllByText("記録なし")).toHaveLength(3); + }); +}); + +// 現状固定テスト(I-007: StepPanel の分割の前に、詳細パネルの分岐を固定する) +describe("工程の詳細パネルの分岐(現状固定)", () => { + function withStep(patch: Partial): Api { + const api = createDummyApi(); + const original = api.stepDetails.bind(api); + api.stepDetails = async (id, sprintRunId, keys) => (await original(id, sprintRunId, keys)).map((s) => ({ ...s, ...patch })); + return api; + } + + async function openImpl(api: Api) { + show(detail, api); + const log = await screen.findByRole("region", { name: "工程の記録" }); + await userEvent.click(within(log).getByRole("button", { name: /^impl(実装)/ })); + return screen.findByRole("region", { name: "工程の詳細" }); + } + + it("読み込みに失敗すると、戻るボタンと失敗の知らせだけを出す", async () => { + const api = createDummyApi(); + api.stepDetails = async () => { + throw new ApiFailure(503, "unavailable", "x"); + }; + const panel = await openImpl(api); + expect(await within(panel).findByRole("alert")).toBeTruthy(); + expect(within(panel).getByRole("button", { name: "概要に戻る" })).toBeTruthy(); + expect(within(panel).queryByText("触ったファイル")).toBeNull(); + }); + + it("見出しの欄にステップ・終了コード・秒・費用・要約を並べ、費用が無いときは「—」", async () => { + const panel = await openImpl(withStep({ cost_usd: null, summary: "要約の見本", exit_code: 3, seconds: 1234 })); + await within(panel).findByText("要約の見本"); + expect(within(panel).getAllByRole("term").map((x) => x.textContent)).toEqual(["ステップ", "終了コード", "秒", "費用", "要約"]); + expect(within(panel).getAllByRole("definition").map((x) => x.textContent)).toEqual(["impl(実装)", "3", "1,234", "—", "要約の見本"]); + }); + + it("ファイルと決めたことが空のときは「ありません」を出し、差分のリンクが無ければ出さない", async () => { + const panel = await openImpl(withStep({ files: [], decisions: [], diff_url: null, output: null })); + expect(await within(panel).findByText("触ったファイルはありません")).toBeTruthy(); + expect(within(panel).getByText("決めたことはありません")).toBeTruthy(); + expect(within(panel).queryByRole("link")).toBeNull(); + expect(within(panel).getAllByText("記録なし")).toHaveLength(1); + }); + + it("URL の無いファイルは文字だけ、行数の無いファイルは「バイナリ」、省いた件数は「ほか N 件」", async () => { + const panel = await openImpl( + withStep({ + files: [ + { path: "a.bin", added: null, deleted: 1, url: null }, + { path: "b.ts", added: 1, deleted: 2, url: "https://example.test/b.ts" }, + ], + files_omitted: 5, + decisions: [{ question: "Q", answer: "A", by: "user" }], + }), + ); + expect(await within(panel).findByText("a.bin")).toBeTruthy(); + expect(within(panel).queryByRole("link", { name: "a.bin" })).toBeNull(); + expect(within(panel).getByText("バイナリ")).toBeTruthy(); + expect(within(panel).getByRole("link", { name: "b.ts" }).getAttribute("href")).toBe("https://example.test/b.ts"); + expect(within(panel).getByText("+1 −2")).toBeTruthy(); + expect(within(panel).getByText("ほか 5 件")).toBeTruthy(); + expect(within(panel).getByText(/Q → A/)).toBeTruthy(); + expect(within(panel).getByText("利用者")).toBeTruthy(); + }); + + it("ログの出力は「ログを開く」で開き、切った知らせを出し、「閉じる」で畳む", async () => { + const panel = await openImpl(withStep({ output: { kind: "log", text: "ログの本文", truncated: true } })); + const toggle = await within(panel).findByRole("button", { name: "ログを開く" }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(within(panel).queryByText("ログの本文")).toBeNull(); + await userEvent.click(toggle); + expect(within(panel).getByText("ログの本文")).toBeTruthy(); + expect(within(panel).getByText("長いため途中で切りました")).toBeTruthy(); + const close = within(panel).getByRole("button", { name: "閉じる" }); + expect(close.getAttribute("aria-expanded")).toBe("true"); + await userEvent.click(close); + expect(within(panel).queryByText("ログの本文")).toBeNull(); + expect(within(panel).getByRole("button", { name: "ログを開く" })).toBeTruthy(); + }); +}); + +describe("読み直し(受け入れ条件 10)", () => { + it("開いたまま新しい事象が来ると、読み直しの間隔の 2 倍以内に出る。読み直しが失敗しても前の表示は消えない", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const api = createDummyApi(); + let rows: SprintRunRow[] = [sprintRuns[0]!]; + let fail = false; + api.sprintRuns = async () => { + if (fail) throw new ApiFailure(503, "unavailable", "x"); + return { sprint_runs: rows }; + }; + show(sprints, api, createDummyAuth(), 1000); + expect(await screen.findByText("今の工程: impl")).toBeTruthy(); + rows = [{ ...sprintRuns[0]!, current_step: "base-sync" }]; + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(screen.getByText("今の工程: base-sync")).toBeTruthy(); + fail = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.getByText("今の工程: base-sync")).toBeTruthy(); + expect(screen.getByRole("alert").textContent).toMatch(/^読み直せませんでした(/); + }); + + it("詳細は動いている実行だけを間隔で読み直し、終わった実行は開いたときと「もう一度読む」だけで読む", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const api = createDummyApi(); + const details = api.sprintRunDetails.bind(api); + let calls = 0; + let state = "running"; + api.sprintRunDetails = async (...args: Parameters) => { + calls += 1; + return (await details(...args)).map((d) => ({ ...d, state })); + }; + show(detail, api, createDummyAuth(), 1000); + await screen.findAllByText(/sr-0003/); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + expect(calls).toBe(2); + state = "done"; + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(calls).toBe(3); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(calls).toBe(3); + }); + + it("バックログは間隔では読み直さず、開いたときと「もう一度読む」だけで読む", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const api = createDummyApi(); + const backlogs = api.backlogs.bind(api); + let calls = 0; + api.backlogs = async (...args: Parameters) => { + calls += 1; + return backlogs(...args); + }; + show(`/accounts/${PERSONAL_ID}/repositories/101/backlog`, api, createDummyAuth(), 1000); + expect(await screen.findByRole("table", { name: "着手の順位" })).toBeTruthy(); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(calls).toBe(1); + await act(async () => { + screen.getByRole("button", { name: "もう一度読む" }).click(); + await vi.advanceTimersByTimeAsync(10); + }); + expect(calls).toBe(2); + }); +}); + +describe("401 の扱い(受け入れ条件 12)", () => { + it.each([sprints, detail, `/accounts/${PERSONAL_ID}/repositories/101/backlog`])("%s で API が 401 を返すと token を消してログインへ移る", async (path) => { + let token: string | null = "t"; + const auth: Auth = { ...createDummyAuth(), getToken: () => token, clearToken: () => (token = null) }; + const api = createApi("https://api.example.test", auth, async () => Response.json({ error: { code: "unauthorized", message: "x" } }, { status: 401 })); + show(path, api, auth); + await vi.waitFor(() => expect(screen.getByTestId("where").textContent).toBe("/login")); + expect(token).toBeNull(); + }); +}); + +describe("バックログ(受け入れ条件 15・16)", () => { + it("順位の表と棚卸しの記録を出し、書き込む操作を持たない", async () => { + show(`/accounts/${PERSONAL_ID}/repositories/101/backlog`); + const ranking = await screen.findByRole("table", { name: "着手の順位" }); + expect(within(ranking).getByText("#21 見本の課題 1")).toBeTruthy(); + expect(within(screen.getByRole("table", { name: "棚卸しの記録" })).getByText("順位を見直した(見本)")).toBeTruthy(); + expect(screen.queryAllByRole("button").filter((b) => !b.closest("header,nav"))).toEqual([]); + }); + + it("順位の表が無いリポジトリは「順位の表がありません」", async () => { + show(`/accounts/${PERSONAL_ID}/repositories/102/backlog`); + expect(await screen.findByText("順位の表がありません")).toBeTruthy(); + expect(screen.getByText("棚卸しの記録はまだありません")).toBeTruthy(); + }); +}); diff --git a/factory/web/src/poll.ts b/factory/web/src/poll.ts new file mode 100644 index 000000000..de8300dc7 --- /dev/null +++ b/factory/web/src/poll.ts @@ -0,0 +1,85 @@ +// 読み直し(#2172 の前提 7・決定 12)。画面を開いている間、設定の間隔で読み直す。押し出しの通知は使わない。 +// 見えていない間は読み直さず、前の読み直しが終わる前に次を打たない。読み直しが失敗しても前の表示は残す +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFailureHandler, useServices } from "./context.tsx"; + +export const DEFAULT_POLL_INTERVAL_MS = 30_000; + +export interface Polled { + data: T | null; + // 最初の読み込みの失敗の文(401 では null のままログインへ移る) + error: string | null; + // 読み直しが失敗した時刻(前の表示を残している)。成功すれば null に戻る + staleSince: string | null; + reload: () => void; +} + +// read が変わると最初から読み直す。enabled が偽の間は読まない。poll が偽なら間隔では読み直さず、開いたときと reload だけで読む +// (変わることの少ない画面で、1 回の読み出しが重いもの。バックログなど)。poll が関数なら、間隔のたびに今の data で決める +// (終わった実行の詳細など、data がもう変わらないと分かったら読み直さない) +export function usePolled(read: () => Promise, enabled: boolean, { poll = true }: { poll?: boolean | ((data: T | null) => boolean) } = {}): Polled { + const { pollIntervalMs } = useServices(); + const interval = pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const onFailure = useFailureHandler(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [staleSince, setStaleSince] = useState(null); + const [generation, setGeneration] = useState(0); + const hasData = useRef(false); + const latest = useRef(null); + // 関数の poll は描画のたびに作り直されるため、読み直しの effect を起こし直さないよう ref で読む + const pollOf = useRef(poll); + pollOf.current = poll; + const polling = poll !== false; + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let running = false; + hasData.current = false; + latest.current = null; + setData(null); + setError(null); + setStaleSince(null); + const tick = async () => { + if (running) return; + running = true; + try { + const value = await read(); + if (cancelled) return; + hasData.current = true; + latest.current = value; + setData(value); + setError(null); + setStaleSince(null); + } catch (err) { + if (cancelled) return; + const message = onFailure(err); + if (message === null) return; + if (hasData.current) setStaleSince(new Date().toISOString()); + else setError(message); + } finally { + running = false; + } + }; + void tick(); + if (!polling) { + return () => { + cancelled = true; + }; + } + const timer = setInterval(() => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + const p = pollOf.current; + if (typeof p === "function" && !p(latest.current)) return; + void tick(); + }, interval); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [read, enabled, interval, onFailure, generation, polling]); + + const reload = useCallback(() => setGeneration((g) => g + 1), []); + return { data, error, staleSince, reload }; +} diff --git a/factory/web/src/screens.ts b/factory/web/src/screens.ts index e2c6e7c57..6bf1dec0c 100644 --- a/factory/web/src/screens.ts +++ b/factory/web/src/screens.ts @@ -46,7 +46,7 @@ export const SCREENS: readonly Screen[] = [ { id: "repositories", path: "/accounts/:accountId/repositories", roles: ALL_ROLES, nav: true, status: "available" }, { id: "modelCredential", path: "/accounts/:accountId/model-credential", roles: OWNERS, nav: true, status: "available" }, { id: "newSprint", path: "/accounts/:accountId/repositories/:repositoryId/sprints/new", roles: ALL_ROLES, nav: false, status: "available" }, - { id: "backlog", path: "/accounts/:accountId/repositories/:repositoryId/backlog", roles: ALL_ROLES, nav: false, status: "planned" }, + { id: "backlog", path: "/accounts/:accountId/repositories/:repositoryId/backlog", roles: ALL_ROLES, nav: false, status: "available" }, { id: "approvals", path: null, roles: ALL_ROLES, nav: false, status: "planned" }, { id: "billing", path: null, roles: OWNERS, nav: false, status: "planned" }, { id: "runners", path: null, roles: OWNERS, nav: false, status: "planned" }, diff --git a/factory/web/src/ui/ui.css b/factory/web/src/ui/ui.css index a05e71d20..3c2c71606 100644 --- a/factory/web/src/ui/ui.css +++ b/factory/web/src/ui/ui.css @@ -489,3 +489,76 @@ ul { content: none; } } + +/* スプリントの一覧と詳細(#2172) */ +.ui-muted { + display: block; + font-size: 0.875em; +} + +.phase-bars { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.phase-bars__bar { + display: inline-block; + width: 1.25rem; + height: 0.5rem; + border-radius: 2px; + border: 1px solid var(--color-primary); +} + +.phase-bars__bar--passed { + background: var(--color-primary); +} + +.phase-bars__bar--current { + background: var(--color-info-bg); +} + +.phase-text { + margin-inline-start: 0.5rem; +} + +.sprint-phases { + display: grid; + gap: 0.5rem; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); +} + +.sprint-detail { + display: grid; + gap: 1rem; +} + +@media (min-width: 1024px) { + .sprint-detail { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } +} + +.sprint-steps__row { + display: block; + width: 100%; + min-height: 44px; + padding: 0.5rem; + text-align: start; + background: var(--color-base); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 4px; + cursor: pointer; +} + +.sprint-steps__row--selected { + border-color: var(--color-primary); +} + +.sprint-panel__output { + max-height: 24rem; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/factory/web/vite.config.ts b/factory/web/vite.config.ts index 26077262c..2362df161 100644 --- a/factory/web/vite.config.ts +++ b/factory/web/vite.config.ts @@ -3,9 +3,14 @@ import { defineConfig } from "vitest/config"; import { loadEnv } from "vite"; import react from "@vitejs/plugin-react"; -// 画面へ埋め込む変数は次の 4 つだけ。ほかの変数を画面へ入れない(docs/specifications/ndf-factory-accounts-and-plans.md の「環境変数」) +// 画面へ埋め込む変数は次の 5 つだけ。ほかの変数を画面へ入れない(docs/specifications/ndf-factory-accounts-and-plans.md の「環境変数」) const FACTORY_ROOT = fileURLToPath(new URL("..", import.meta.url)); +function pollInterval(raw: string | undefined): number { + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : 30_000; +} + export default defineConfig(({ mode }) => { const env = loadEnv(mode, FACTORY_ROOT, ""); const config = { @@ -13,6 +18,8 @@ export default defineConfig(({ mode }) => { cognitoClientId: env.COGNITO_CLIENT_ID ?? "", webUrl: env.FACTORY_WEB_URL ?? "", apiUrl: env.FACTORY_API_URL ?? "", + // 一覧と詳細の読み直しの間隔。正の整数でなければ既定の 30 秒 + pollIntervalMs: pollInterval(env.FACTORY_POLL_INTERVAL_MS), }; return { plugins: [react()], diff --git a/issues/issue-2436-design-approvals.md b/issues/issue-2436-design-approvals.md deleted file mode 100644 index e51184d6f..000000000 --- a/issues/issue-2436-design-approvals.md +++ /dev/null @@ -1,157 +0,0 @@ -# 並列開発の設計: 問いをオーガナイザーへ集め、作業は持ち主のレーンと一時レーンへ渡す(#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 deleted file mode 100644 index a7414c5e5..000000000 --- a/issues/issue-2436-design-decisions.md +++ /dev/null @@ -1,247 +0,0 @@ -# 並列開発の設計の決定(#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 deleted file mode 100644 index e4991e0e4..000000000 --- a/issues/issue-2436-design-failures.md +++ /dev/null @@ -1,63 +0,0 @@ -# 並列開発の設計: 異常系(#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 deleted file mode 100644 index be5eb622d..000000000 --- a/issues/issue-2436-design-hierarchy.md +++ /dev/null @@ -1,72 +0,0 @@ -# 並列開発: 全体の階層(論理の階層と置き場)(#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 deleted file mode 100644 index f8c33c632..000000000 --- a/issues/issue-2436-design-lane-loop.md +++ /dev/null @@ -1,129 +0,0 @@ -# 並列開発の設計: レーンのループが答えを待たずに次の周回へ進み、答えの後の処理を周回の境で行う(#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 deleted file mode 100644 index 253cf0a6b..000000000 --- a/issues/issue-2436-design-release-rule.md +++ /dev/null @@ -1,127 +0,0 @@ -# 並列開発の設計: 本番チャネルへの 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 deleted file mode 100644 index b3120c64f..000000000 --- a/issues/issue-2436-design.md +++ /dev/null @@ -1,600 +0,0 @@ -# 並列開発: 並列の計画を 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 deleted file mode 100644 index 4d7ff472e..000000000 --- a/issues/issue-2436-requirements.md +++ /dev/null @@ -1,190 +0,0 @@ -# #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 の役割名として用語集へ足す。統括役と混ぜない | diff --git a/issues/issue-2439-design.md b/issues/issue-2439-design.md index 969caa73e..59765dc7a 100644 --- a/issues/issue-2439-design.md +++ b/issues/issue-2439-design.md @@ -19,7 +19,7 @@ | --- | --- | --- | | #2439 の「依頼(原文)」(2026-11-02) | 利用者の指示の原文 | Factory にもまとめ役が要る。「上位のオーガナイザー」ではなく、同じ役割を Factory の側で担う実装として定義する | | Pull Request 2420 を閉じた件 | 実測 | 本番の配布が `develop` の先頭を出すため、先に出した実行が他の実行の変更も出す | -| #2436 の設計(`design/issue-2436` の `issues/issue-2436-design*.md`。決定 13〜15・19・21) | 設計の正本 | 配布の中身を `develop` のコミットの範囲で決める。本番の Pull Request の head は `release/v<版>`。依頼と答えの形は NDF の契約 | +| #2436 の設計(確定仕様 `docs/specifications/ndf-parallel-organizer.md`) | 設計の正本 | 配布の中身を `develop` のコミットの範囲で決める。本番の Pull Request の head は `release/v<版>`。依頼と答えの形は NDF の契約 | | Factory の確定仕様 [ndf-factory-dev-loops.md](../docs/specifications/ndf-factory-dev-loops.md) の概要 | 設計の正本 | 常駐のプロセスを持たない。実行をまたぐ制御は API の handler と 5 分ごとの見回りに置く | | `factory/` の調査(2026-11-02) | 実測 | 承認の依頼の保存と一覧(#2173)、手元の受け手の登録の経路(#2189)はまだ無い。本番の配布はランナーの中の NDF が流し、Factory は関わらない | @@ -123,7 +123,7 @@ | --- | --- | --- | | 切り替え口 | NDF が本番の配布を自分で組まずに、外のオーガナイザーへ配布の依頼をファイルで出す経路。環境変数 `NDF_ORGANIZER_DIR` があるときだけ働く | この設計([issue-2439-design-ndf.md](issue-2439-design-ndf.md))。要求の「前提 2」 | | レーン | 並列の計画の下で 1 本のセッションが進める開発の流れ。Factory では、ループ 1 つか手動の実行 1 つが 1 つのレーンになる(名前は `loop-` / `run-`) | #2436 の設計の「用語」 | -| 一時レーン | #2436 で、持ち主のいない共有の作業(まとめた配布のプランの実行と、その失敗の対応)を受け持つ短命のレーン。この設計では配布の実行がその役を受け持つ | #2436 の `issue-2436-design-approvals.md` | +| 一時レーン | #2436 で、持ち主のいない共有の作業(まとめた配布のプランの実行と、その失敗の対応)を受け持つ短命のレーン。この設計では配布の実行がその役を受け持つ | #2436 の確定仕様 `docs/specifications/ndf-parallel-organizer.md` | | 1 台の中のオーガナイザー | #2436 の、1 台のマシンの中でレーンを起動・監視し、配布の依頼をまとめる conductor の役割 | #2436 の設計の「用語」(語は「オーガナイザー」) | | 承認ゲート 1・承認ゲート 2 | 人の承認を求める 2 つの点。1 は設計 Pull Request のマージ、2 は本番系へ届く操作(本番の配布) | 用語集 `ndf-workflow` の「承認ゲート」「ゲート 1」「ゲート 2」 | | MVV 判定 | 承認ゲートの資料がプロジェクトの MVV(Mission / Vision / Value)に従うかの判定。`.ndf/pace.json` が許すときは、従うと判定した承認ゲートを人の答えなしで通す | 用語集 `ndf-workflow` の「MVV 判定」 | @@ -292,7 +292,7 @@ sequenceDiagram 2. **受ける**: `finalize` は `release.requested` をジョブの冪等の鍵の範囲で読む。受けた依頼の行は、実行の移りと同じトランザクションで書く(I59)。通った後に `organizer.tick(repositoryId)` を呼ぶ(落ちてよい。見回りが続ける) 3. **組む(F2)**: `tick` は根を読み、まとめた配布が流れていなければ、未回答の依頼があるときだけ組む。先に I62 を照らし、当たれば待ちの理由を書いて終える。当たらなければ `composeRelease` で 5 つの行を 1 回で書き、`launch` する。5 つは根(`currentSeq`・`nextSeq`・`version`)・まとめた配布(`composed`)・配布の実行・ジョブ(`START=release`、`releaseStep=present`)・記録の行である。依頼が 1 件でも同じである(S8) 4. **提示(F3)**: 配布の実行は、ランナー向けの経路でまとめた配布の中身(レーンの一覧と依頼)を読み、NDF の配布の手順を流す。MVV 判定が通せば、`release.presented`(`auto` が真)を送って本番の配布まで続ける。このとき承認の依頼は作らず、配布のまとめは `decide` を通さずにまとめた配布を `confirmed`(`confirmedK=1`)にする(E12)。止まれば `release.presented`(提示の番号・コミット・範囲・加わった範囲)と `gate.reached`(`gate2`)を送って終わる。配布の実行は `awaiting_approval` になる。承認の依頼は #2173 が今の経路で作る -5. **答え(F4)**: #2173 が答えを保存して `requestResume` を呼ぶと、実行の制御は配布の実行を `organizer.decide` へ回す。提示 1 への承認では GitHub の口で `develop` の先頭を読み、提示したコミットと比べる。同じなら `confirm:1`、進んでいれば `represent` で再開する。提示 2 への答えは比べずに `confirm:2`(新しい範囲)か `confirm:1`(最初の範囲)で再開する(I60)。`confirm:` で出すのは提示 k のコミット(まとめた配布の `presentations` の k 番目の `sha`)で、本番のプランはそのコミットから `release/v<版>` を切る。答えの後に `develop` が進んでも、出すコミットは変わらない(#2436 の設計の「まとめた配布」と `issue-2436-design-release-rule.md` の「`release/v<版>` を切る起点」)。差し戻しはジョブを起こさない。まとめた配布を `rejected`、配布の実行を `stopped`(`release_rejected`)にし、最後の提示の範囲の依頼へ `rejected` を返す(I61)。範囲の実行ごとに `release.answered` を書き、手動の実行には `requestResume`(依頼の識別子つき)を呼ぶ。届け方は 6 の配布済みと同じである。続けて `tick` を呼び、範囲に入らない依頼で次のまとめた配布を組む +5. **答え(F4)**: #2173 が答えを保存して `requestResume` を呼ぶと、実行の制御は配布の実行を `organizer.decide` へ回す。提示 1 への承認では GitHub の口で `develop` の先頭を読み、提示したコミットと比べる。同じなら `confirm:1`、進んでいれば `represent` で再開する。提示 2 への答えは比べずに `confirm:2`(新しい範囲)か `confirm:1`(最初の範囲)で再開する(I60)。`confirm:` で出すのは提示 k のコミット(まとめた配布の `presentations` の k 番目の `sha`)で、本番のプランはそのコミットから `release/v<版>` を切る。答えの後に `develop` が進んでも、出すコミットは変わらない(#2436 の設計の「まとめた配布」(確定仕様 `docs/specifications/ndf-parallel-organizer.md`))。差し戻しはジョブを起こさない。まとめた配布を `rejected`、配布の実行を `stopped`(`release_rejected`)にし、最後の提示の範囲の依頼へ `rejected` を返す(I61)。範囲の実行ごとに `release.answered` を書き、手動の実行には `requestResume`(依頼の識別子つき)を呼ぶ。届け方は 6 の配布済みと同じである。続けて `tick` を呼び、範囲に入らない依頼で次のまとめた配布を組む 6. **結果(F5)**: 配布の実行の `finalize` が `release.settled` を読み、`settle` へ渡す。`settle` は根を空け、まとめた配布を `released` にし、範囲に Pull Request が入る依頼へ答えを書く(I58・I61)。範囲の実行ごとに `appendControlEvent` で `release.answered`(冪等の鍵 `:`)を書く。手動の実行には `requestResume`(依頼の識別子つき)を呼ぶ。依頼ごとに、この 2 つが通った後に `deliveredAt` を書いて見回りの索引から外す(答えを書いただけでは外さない。途中で止まっても見回りが届け直す)。続けて `tick` を呼び、残った依頼で次のまとめた配布を組む 7. **答えを届ける**: レーンの次のジョブは、ループの次の周回か手動の実行の再開である。その開始で、ランナーは答えと範囲を読んで置き場へ書き、答えの無い依頼のファイルを再送する(I59)。NDF は周回の境で答えを読み、`released` なら残りの工程を流す(#2436 のレーンのループ) diff --git a/issues/issue-2439-requirements.md b/issues/issue-2439-requirements.md index 4df83681b..b158d8de7 100644 --- a/issues/issue-2439-requirements.md +++ b/issues/issue-2439-requirements.md @@ -52,16 +52,17 @@ Factory では、実行ジョブ 1 本が #2436 のレーン 1 本に当たる ## 目的 -同じリポジトリで並行する Factory の実行どうしで、本番の配布をぶつけずに 1 本へまとめる。承認と問いを、リポジトリごとに 1 か所で答えられるようにする。あわせて、#2436 の階層の定義(`issues/issue-2436-design-hierarchy.md`)が Factory の側へ割り当てた語の衝突 2 つを解く。 +同じリポジトリで並行する Factory の実行どうしで、本番の配布をぶつけずに 1 本へまとめる。承認と問いを、リポジトリごとに 1 か所で答えられるようにする。あわせて、#2436 の階層の定義(確定仕様 https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-parallel-organizer.md )が Factory の側へ割り当てた語の衝突 2 つを解く。 ## 前提 -- 前提 1: #2436 が、レーンとオーガナイザーの間の依頼と答えの形を NDF の契約(`plugins/ndf/contracts/forms/parallel-request.json`)として公開し、`develop` へマージしている。この課題の実装は、その契約がマージされるまで始めない -- 前提 2: NDF の側に、本番の配布を自分で組まずに配布の依頼を出す切り替え口がある。切り替え口をどちらの課題が作るかは未決 2 で決める +- 前提 1: レーンとオーガナイザーの間の依頼と答えの形は、#2436(tmux の並列開発を計画から始める)が NDF の契約 `plugins/ndf/contracts/forms/parallel-request.json` として公開し、ndf v10.27.15 で配布した(`develop` に `plugins/ndf/scripts/parallel.py` と契約がある) +- 前提 2: NDF の側の切り替え口(本番の配布を自分で組まずに配布の依頼を出す)は、この課題が `plugins/ndf/` に作る。`NDF_ORGANIZER_DIR`・`NDF_ORGANIZER_LANE` で外のオーガナイザーのレーンと判定する(設計 `issues/issue-2439-design-ndf.md`) - 前提 3: 承認の依頼の保存・通知・答えの経路は #2173 のものを使う。この課題は、まとめた配布を 1 件の承認の依頼(種類 ゲート 2)として載せる - 前提 4: 衝突を解く改名は、`ndf-factory` の文脈の語だけを対象にする。`ndf-workflow` の「プラン」(`plan.json`)は変えない - 前提 5: 配布の実行ジョブは、リポジトリを登録したアカウントの実行として数える(S11)。利用量の表示と料金の上限での扱いは未決 1 で決める - 前提 6: 同じリポジトリで同時に動く実行の数は、今の上限(`concurrent_repo_runs`)のまま変えない +- 前提 7: 受け入れ条件 10 の「`parallel.py init`・`run` が `NDF_FACTORY_RUN` を見て断る」処理と、`NDF_FACTORY_RUN` の契約の宣言(`plugins/ndf/contracts/env/`)は、#2436 の配布(v10.27.15)に入っていない。確定仕様 ndf-parallel-organizer.md の「対象範囲」は「今の `parallel.py` は Factory の実行ジョブ(`NDF_FACTORY_RUN`)を見分けず、排他の検査は持たない」と書く。設計 `issues/issue-2439-design-ndf.md` は「`NDF_FACTORY_RUN` の宣言は #2436 が足す」としており、今の実装と食い違う。受け入れ条件 10 を満たすには、この 2 つを足す変更が要る(未決 5) ## 対象範囲 @@ -188,6 +189,7 @@ Factory では、実行ジョブ 1 本が #2436 のレーン 1 本に当たる | 項目 | 誰が決めるか | 期限 | | --- | --- | --- | | 1. 配布専用の実行ジョブの費用を、利用量の表示と料金の上限(同時の数を含む)でどう数えるか。前提 5 はアカウントの利用量に数えるとだけ置いた | 利用者(承認ゲート 1) | 設計の承認まで | -| 2. NDF の側の切り替え口(配布を自分で組まずに依頼を出す)を、#2436 の契約と同じ変更で作るか、この課題で作るか | #2436 と #2439 の設計 | 設計の承認まで | +| 2. NDF の側の切り替え口(配布を自分で組まずに依頼を出す)は、この課題で作ると設計で決めた(`issues/issue-2439-design-ndf.md`) | #2436 と #2439 の設計 | 決定済み | | 3. オーガナイザーの進行の状態を DynamoDB の行で持つか、AWS の耐久ワークフローの部品(Step Functions など)を使うか。#2436 と同じく既存の部品を先に比べ、費用が増えるなら理由と代わりの案を示す | 設計 | 設計の承認まで | | 4. マシン単位の手元の受け手の新しい名前 | 設計(用語集の語の選び方に従う) | 設計の承認まで | +| 5. `parallel.py init`・`run` の `NDF_FACTORY_RUN` の断りと `NDF_FACTORY_RUN` の契約の宣言(前提 7)を、この課題の NDF の側の変更に含めるか。「境界」の「#2436 の 1 台の中のオーガナイザーの変更は行わない」と合わせて決める | conductor(範囲の判断) | 実装の着手まで | diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 2e43cb5b3..4ab7a0419 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.15", - "description": "Claude Code plugin (v10.27.15): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "version": "10.27.16", + "description": "Claude Code plugin (v10.27.16): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json index 8f0dd69b1..7dcf58b5e 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.15", - "description": "Codex plugin (v10.27.15): 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", + "version": "10.27.16", + "description": "Codex plugin (v10.27.16): 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", "skills": [ "./skills/cherry-pick-pr", "./skills/cross-refactoring", diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index f51e517ab..38f75b682 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -350,7 +350,7 @@ agy models # 認証確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.27.15/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.27.16/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -372,14 +372,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.15/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.16/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.27.15 +# => ndf@ai-plugins installed, enabled 10.27.16 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json index 3fa013b33..5458e1dd6 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -1,7 +1,7 @@ { "version": 1, "path": "plugins/ndf/scripts/parallel.py", - "summary": "並列の計画(`.ndf/parallel/<計画名>/`)の下書き・検査・起動・進行の待ち・まとめた配布の提示と確定・レーンの配布の依頼と答え(#2436)。結果は統一 JSON レポートを 1 行で標準出力へ出す。", + "summary": "並列の計画(`.ndf/parallel/<計画名>/`)の下書き・検査・起動・進行の待ち・まとめた配布の提示と確定・レーンの配布の依頼と答え(#2436)。外のオーガナイザーのレーンと配布の実行は `--external` で置き場のファイルとやり取りする(#2439)。結果は統一 JSON レポートを 1 行で標準出力へ出す。", "args": { "options": [], "positionals": [], @@ -21,6 +21,7 @@ }, "await": { "options": [ + "--external", "--from", "--timeout" ], @@ -34,6 +35,12 @@ "name" ] }, + "compose": { + "options": [ + "--external" + ], + "positionals": [] + }, "confirm": { "options": [ "--take" @@ -59,7 +66,9 @@ }, "present": { "options": [ - "--approval" + "--approval", + "--external", + "--record" ], "positionals": [ "name", @@ -69,6 +78,7 @@ }, "request": { "options": [ + "--external", "--issues", "--lane", "--prs", @@ -105,6 +115,7 @@ "settle": { "options": [ "--done", + "--external", "--reason", "--rejected" ], @@ -151,7 +162,10 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_ESTIMATE_DIR", + "NDF_FACTORY_RUN", "NDF_MVV_STATE_DIR", + "NDF_ORGANIZER_DIR", + "NDF_ORGANIZER_LANE", "NDF_PARALLEL_MAX_PLANS", "NDF_PARALLEL_POLL", "NDF_PARALLEL_RELAY", @@ -166,6 +180,7 @@ "NDF_SUPERVISE_LIMIT_SLEEP", "NDF_SUPERVISE_MODEL", "NDF_SV_STATE_DIR", + "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json index 8bd45e6cb..d3019b025 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json @@ -25,6 +25,8 @@ "NDF_ESTIMATE_DIR", "NDF_LOOP_EXCLUDE", "NDF_MVV_STATE_DIR", + "NDF_ORGANIZER_DIR", + "NDF_ORGANIZER_LANE", "NDF_PARALLEL_MAX_PLANS", "NDF_PRESENTATION_DIR", "NDF_RELAY_DIR", @@ -37,6 +39,7 @@ "NDF_SUPERVISE_MODEL", "NDF_SV_STATE_DIR", "NDF_UPKEEP_STATE_DIR", + "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json index 97922ec6b..4d9ef14ef 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json @@ -19,6 +19,8 @@ "NDF_DEPS_VENV", "NDF_ESTIMATE_DIR", "NDF_MVV_STATE_DIR", + "NDF_ORGANIZER_DIR", + "NDF_ORGANIZER_LANE", "NDF_PARALLEL_MAX_PLANS", "NDF_PRESENTATION_DIR", "NDF_RELAY_MAX_NAMED", @@ -30,6 +32,7 @@ "NDF_SUPERVISE_LIMIT_SLEEP", "NDF_SUPERVISE_MODEL", "NDF_SV_STATE_DIR", + "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/env/NDF_FACTORY_RUN.json b/plugins/ndf/contracts/env/NDF_FACTORY_RUN.json new file mode 100644 index 000000000..97f46b4ae --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_FACTORY_RUN.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_FACTORY_RUN", + "set_by": "runtime", + "summary": "サービスの実行ジョブの中で 1 を立てる(#2439 の I68)。あるとき parallel.py init・start・run は 1 台の中のオーガナイザーを起動せずに断る(plugins/ndf/scripts/parallel.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_ORGANIZER_DIR.json b/plugins/ndf/contracts/env/NDF_ORGANIZER_DIR.json new file mode 100644 index 000000000..e3ca97b63 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_ORGANIZER_DIR.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_ORGANIZER_DIR", + "set_by": "runtime", + "summary": "外のオーガナイザーとファイルでやり取りする置き場(#2439)。あるとき、このセッションは外のオーガナイザーのレーンで、本番の配布を自分で組まずに配布の依頼を書く。形は contracts/forms/parallel-external.json(plugins/ndf/scripts/lib/parallel_external.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_ORGANIZER_LANE.json b/plugins/ndf/contracts/env/NDF_ORGANIZER_LANE.json new file mode 100644 index 000000000..da91b47a9 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_ORGANIZER_LANE.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_ORGANIZER_LANE", + "set_by": "runtime", + "summary": "外のオーガナイザーのレーンの名前(#2439)。配布の依頼の id の頭になる。NDF_ORGANIZER_DIR と組で渡す(plugins/ndf/scripts/lib/parallel_external.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_VERSION.json b/plugins/ndf/contracts/env/NDF_VERSION.json new file mode 100644 index 000000000..2837bc459 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_VERSION.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_VERSION", + "set_by": "runtime", + "summary": "サービスの実行ジョブが入れた NDF の版(開発版は @<コミットの SHA>。#2439)。plugin.json の version と同じなら、配布の依頼の ndf_version へこの値を書く(plugins/ndf/scripts/lib/parallel_external.py)" +} diff --git a/plugins/ndf/contracts/forms/parallel-external.json b/plugins/ndf/contracts/forms/parallel-external.json new file mode 100644 index 000000000..c0d70399d --- /dev/null +++ b/plugins/ndf/contracts/forms/parallel-external.json @@ -0,0 +1,261 @@ +{ + "name": "parallel-external", + "summary": "外のオーガナイザーと、環境変数 NDF_ORGANIZER_DIR の置き場のファイルでやり取りする形(#2439)。レーンは requests/ に配布の依頼を書き、外のオーガナイザーは answers/・ranges/・batch.json を書き、配布の実行は presented/・progress/・results/ を書く。どちらの書き手も一時ファイルへ書いて名前を付け替える。外のオーガナイザーの側の読み手と書き手(サービスのランナーなど)も同じ例の組を読む", + "rule": "message.file は置き場からの相対パス、message.body は中身。requests/.json は #2436 の配布の依頼(parallel-request)に ndf_version(空でない文字列)を足した形で、ファイル名は id。answers/.json の result は released / rejected。ranges/<連番>.json は {sha, version, prs, lanes}。batch.json の step は present / represent / confirm:1 / confirm:2 / continue、lanes は {lane, requests:[{id, prs}], issues}。presented/<連番>-<提示>.json の k は 1 か 2 で、auto は真偽。progress/<連番>.json の stages は release_branch / develop_merged / prod_pr_opened / main_merged / tagged の並び。results/<連番>.json の result は released / failed", + "writers": [ + "plugins/ndf/scripts/lib/parallel_external.py" + ], + "readers": [ + "plugins/ndf/scripts/lib/parallel_external.py" + ], + "cases": [ + { + "message": { + "file": "requests/loop-l20261102-r000000000042.json", + "body": { + "kind": "request", + "id": "loop-l20261102-r000000000042", + "lane": "loop-l20261102", + "prs": [ + 2440 + ], + "issues": [ + 2439 + ], + "sprint_state": "/work/repo/.ndf/tmp/s1/8-release-request-state", + "at": "2026-11-02T13:00:00Z", + "ndf_version": "10.27.16" + } + }, + "valid": true + }, + { + "message": { + "file": "requests/loop-l20261102-r000000000042.json", + "body": { + "kind": "request", + "id": "loop-l20261102-r000000000042", + "lane": "loop-l20261102", + "prs": [ + 2440 + ], + "issues": [ + 2439 + ], + "sprint_state": "/work/repo/.ndf/tmp/s1/8-release-request-state", + "at": "2026-11-02T13:00:00Z" + } + }, + "valid": false + }, + { + "message": { + "file": "requests/loop-x-r000000000042.json", + "body": { + "kind": "request", + "id": "loop-l20261102-r000000000042", + "lane": "loop-l20261102", + "prs": [ + 2440 + ], + "issues": [ + 2439 + ], + "sprint_state": "/work/repo/.ndf/tmp/s1/8-release-request-state", + "at": "2026-11-02T13:00:00Z", + "ndf_version": "10.27.16" + } + }, + "valid": false + }, + { + "message": { + "file": "answers/loop-l20261102-r000000000042.json", + "body": { + "result": "released", + "version": "10.28.0", + "release": "https://github.com/o/r/pull/900", + "reason": null, + "at": "2026-11-02T14:00:00Z" + } + }, + "valid": true + }, + { + "message": { + "file": "answers/loop-l20261102-r000000000042.json", + "body": { + "result": "failed", + "version": null, + "release": null, + "reason": null, + "at": "2026-11-02T14:00:00Z" + } + }, + "valid": false + }, + { + "message": { + "file": "ranges/1.json", + "body": { + "sha": "0123abcd", + "version": "10.28.0", + "prs": [ + 2440 + ], + "lanes": [ + "loop-l20261102" + ] + } + }, + "valid": true + }, + { + "message": { + "file": "batch.json", + "body": { + "seq": 1, + "step": "present", + "lanes": [ + { + "lane": "loop-l20261102", + "requests": [ + { + "id": "loop-l20261102-r000000000042", + "prs": [ + 2440 + ] + } + ], + "issues": [ + 2439 + ] + }, + { + "lane": "run-0123456789abcdef", + "requests": [], + "issues": [ + 2441 + ] + } + ], + "presentations": [], + "choices": [ + "continue", + "reject" + ] + } + }, + "valid": true + }, + { + "message": { + "file": "batch.json", + "body": { + "seq": 1, + "step": "confirm:3", + "lanes": [], + "presentations": [], + "choices": [] + } + }, + "valid": false + }, + { + "message": { + "file": "presented/1-1.json", + "body": { + "seq": 1, + "k": 1, + "sha": "0123abcd", + "version": "10.28.0", + "prs": [ + 2440, + 2442 + ], + "lanes": [ + { + "lane": "loop-l20261102", + "requested": true + }, + { + "lane": "run-0123456789abcdef", + "requested": false + } + ], + "added_prs": [], + "auto": false + } + }, + "valid": true + }, + { + "message": { + "file": "progress/1.json", + "body": { + "seq": 1, + "stages": [ + "release_branch", + "prod_pr_opened", + "main_merged" + ] + } + }, + "valid": true + }, + { + "message": { + "file": "progress/1.json", + "body": { + "seq": 1, + "stages": [ + "deployed" + ] + } + }, + "valid": false + }, + { + "message": { + "file": "results/1.json", + "body": { + "seq": 1, + "result": "released", + "version": "10.28.0", + "prod_pr_url": "https://github.com/o/r/pull/900", + "sha": "0123abcd", + "prs": [ + 2440, + 2442 + ], + "lanes": [ + "loop-l20261102", + "run-0123456789abcdef" + ], + "reason": null + } + }, + "valid": true + }, + { + "message": { + "file": "results/1.json", + "body": { + "seq": 1, + "result": "failed", + "version": "10.28.0", + "prod_pr_url": null, + "sha": "0123abcd", + "prs": [ + 2440 + ], + "lanes": [ + "loop-l20261102" + ], + "reason": "本番のプランが完了しなかった" + } + }, + "valid": true + } + ] +} diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index 5de596724..f2b79e74f 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -104,6 +104,7 @@ | `NDF_DEPS_VENV` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/instructions-check.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `NDF_ESTIMATE_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | | `NDF_EXTERNAL_AI_TMP_DIR` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | +| `NDF_FACTORY_RUN` | runtime | `plugins/ndf/scripts/parallel.py` | | `NDF_HOOK_ENV` | user | `plugins/ndf/scripts/worktree-session.sh` | | `NDF_HOOK_PYTHON` | user | `plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/token-guard.sh`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/scripts/worktree-guard.sh` | | `NDF_HOOK_REEXEC` | user | `plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/wait-notify.py` | @@ -112,6 +113,8 @@ | `NDF_LOOP_EXCLUDE` | runtime | `plugins/ndf/scripts/sprint-state.py` | | `NDF_MVV_CLAUDE` | user | `plugins/ndf/scripts/mvv-gate.py` | | `NDF_MVV_STATE_DIR` | user | `plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_ORGANIZER_DIR` | runtime | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_ORGANIZER_LANE` | runtime | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_PARALLEL_MAX_PLANS` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_PARALLEL_POLL` | user | `plugins/ndf/scripts/parallel.py` | | `NDF_PARALLEL_RELAY` | user | `plugins/ndf/scripts/parallel.py` | @@ -148,6 +151,7 @@ | `NDF_UPKEEP_STATE_DIR` | user | `plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | | `NDF_USAGE_KIND` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | | `NDF_USAGE_SOURCE` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | +| `NDF_VERSION` | runtime | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_WHOLE_RUNS_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | | `NDF_WORKTREE_BASE` | user | `plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py` | | `PATH` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-verification-steps.py` | diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py new file mode 100644 index 000000000..bdc1eb10f --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -0,0 +1,29 @@ +"""並列の計画の問いへ答えた後に、だれを起こすか(#2436 の 6c・6f)。`parallel.py answer` だけが使う。 + +`parallel_run`(耐久ワークフローの本体)を読むため、レーンの側の `parallel_lane` から分けて置く(レーンの側の入口が +`parallel_run` の環境変数を読まないように)。 +""" + +from __future__ import annotations + +from pathlib import Path + +import parallel_lane as pl + + +def owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None) -> str | None: + """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは答えを待って止まった持ち主の + restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。同じ原因としてまとめた問い(`merged`)は、 + 代表だけでなく全持ち主をそれぞれの依頼の id で調べ、起こす行を `; ` でつなぐ(異常系 5)。""" + import durable + import parallel_run as pr_run + + items = (durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {}).get("items") or [] + q = next((q for q in items if q.get("id") == rid), {}) + if q.get("kind") in ("stall", "helper", "spawn") or not q.get("lane"): + return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) + ls = lambda: pr_run.relay(main, "ls", "--json") # noqa: E731 + owners = [(q["lane"], rid), *((m.get("lane"), m.get("id")) for m in q.get("merged") or [] if m.get("lane"))] + lanes = [lane for lane, each in owners if pl.lane_waits(main, name, lane, str(each or rid), ls)] + lanes = list(dict.fromkeys(lanes)) + return "; ".join(f"python3 {pr_run.RELAY} restart {lane}" for lane in lanes) or None diff --git a/plugins/ndf/scripts/lib/parallel_external.py b/plugins/ndf/scripts/lib/parallel_external.py new file mode 100644 index 000000000..1dfe369dc --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_external.py @@ -0,0 +1,403 @@ +"""外のオーガナイザーとファイルでやり取りする切り替え口(#2439。契約 `contracts/forms/parallel-external.json`)。 + +環境変数 `NDF_ORGANIZER_DIR` があるとき、このセッションは外のオーガナイザーのレーン(名前は `NDF_ORGANIZER_LANE`)で、 +本番の配布を自分で組まずに置き場へ配布の依頼を書く。外のオーガナイザーが答え・配布済みの範囲・まとめた配布の中身を +置き場へ書き、配布の実行は提示・届いた段・結果を書き返す。どちらの書き手も一時ファイルへ書いて名前を付け替え、 +読み手は名前の付いたファイルだけを読む。 + +```text +/ +├── requests/<依頼の id>.json # レーンが書く +├── answers/<依頼の id>.json # 外のオーガナイザーが書く +├── ranges/<連番>.json # 外のオーガナイザーが書く +├── batch.json # 外のオーガナイザーが書く(配布の実行だけ) +├── presented/<連番>-<提示>.json # 配布の実行が書く +├── progress/<連番>.json # 配布の実行が書く +├── results/<連番>.json # 配布の実行が書く +└── releases/<連番>/<提示>/ # 配布の実行の提示のプランと本番のプラン +``` +""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Callable, Optional + +DIR_ENV = "NDF_ORGANIZER_DIR" +LANE_ENV = "NDF_ORGANIZER_LANE" +# 外のオーガナイザーのレーンの計画名の代わり。`parallel.py <副命令> --external` の形にそのままなる +EXTERNAL = "--external" +STEPS = ("present", "represent", "confirm:1", "confirm:2", "continue") +STAGES = ("release_branch", "develop_merged", "prod_pr_opened", "main_merged", "tagged") + + +def organizer_dir() -> Optional[Path]: + v = os.environ.get("NDF_ORGANIZER_DIR") + return Path(v) if v else None + + +def organizer_lane() -> Optional[str]: + return os.environ.get("NDF_ORGANIZER_LANE") or None + + +def is_external(name: Optional[str]) -> bool: + return name == EXTERNAL + + +def put_json(path: Path, data) -> Path: + """一時ファイルへ書いて名前を付け替える(読み手が書きかけを読まない)。""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=path.parent) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n") + os.replace(tmp, path) + return path + + +def read_json(path: Path, default=None): + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return default + + +def utc_stamp() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def session_ndf_version() -> str: + """このセッションの NDF の版(plugin.json)。読めなければ空。 + + サービスの実行ジョブは入れた版を `NDF_VERSION` で渡す。開発版は `@<コミットの SHA>` で、plugin.json の + version だけでは固定したコミットが落ち、配布の実行が無い開発版のタグを取りに行くため、version が同じならそちらを返す。""" + got = read_json(Path(__file__).resolve().parents[2] / ".claude-plugin" / "plugin.json", {}) or {} + version = str(got.get("version") or "") + pinned = os.environ.get("NDF_VERSION") or "" + if version and "@" in pinned and pinned.split("@", 1)[0] == version: + return pinned + return version + + +# ---- レーンの側 ---- + + +def write_request_file(d: Path, rid: str, lane: str, prs: list[int], issues: list[int], state: Path) -> dict: + """配布の依頼 `requests/.json`(#2436 の依頼の形に `ndf_version` を足す)。同じ id の打ち直しは上書きで同じ中身。""" + msg = { + "kind": "request", + "id": rid, + "lane": lane, + "prs": list(prs), + "issues": list(issues), + "sprint_state": str(state), + "at": utc_stamp(), + "ndf_version": session_ndf_version(), + } + old = read_json(Path(d) / "requests" / f"{rid}.json") + if isinstance(old, dict) and old.get("prs") == msg["prs"]: + return old # 打ち直しでは時刻を変えない(送り直しで同じ依頼になる) + put_json(Path(d) / "requests" / f"{rid}.json", msg) + return msg + + +def read_answer_file(d: Path, rid: str) -> Optional[dict]: + got = read_json(Path(d) / "answers" / f"{rid}.json") + return got if isinstance(got, dict) else None + + +def answer_files_of(d: Path, ids: list[str]) -> dict[str, dict]: + """`parallel_lane.answers_of` と同じ形({id: {"answer", "defer"}})。外のオーガナイザーは知らせを書かない。""" + return {i: {"answer": read_answer_file(d, i), "defer": None} for i in ids} + + +def range_version(d: Path, prs: list[int]) -> Optional[str]: + """`prs` を含む配布済みの範囲 `ranges/<連番>.json` の版(無ければ None)。""" + found = None + rd = Path(d) / "ranges" + for p in sorted(rd.glob("*.json"), key=lambda x: int(x.stem) if x.stem.isdigit() else 0) if rd.is_dir() else []: + got = read_json(p, {}) or {} + if set(prs) & set(got.get("prs") or []): + found = got.get("version") + return found + + +# ---- 配布の実行の側 ---- + + +def read_batch(d: Path) -> dict: + got = read_json(Path(d) / "batch.json") + if not isinstance(got, dict) or got.get("step") not in STEPS or not isinstance(got.get("seq"), int): + raise ValueError(f"batch.json が無いか形が違う(置き場: {d})") + return got + + +def requests_of(batch: dict) -> list[dict]: + """まとめた配布の依頼(`parallel_release.map_lanes` が読む形 {id, lane, prs})。""" + return [ + {"id": r["id"], "lane": ln["lane"], "prs": list(r.get("prs") or [])} + for ln in batch.get("lanes") or [] + for r in ln.get("requests") or [] + ] + + +def plan_of(batch: dict): + """まとめた配布のレーンを、`parallel_release.map_lanes` が読む並列の計画の形にする(依頼の無いレーンは課題で対応づける)。""" + import parallel_plan as pp + + lanes = [ + pp.Lane.model_construct( + name=ln["lane"], + input=pp.SprintInput.model_construct(kind="sprint", issues=list(ln.get("issues") or []), args=[]), + plans=1, + touches=[], + ) + for ln in batch.get("lanes") or [] + ] + return pp.ParallelPlan.model_construct(version=1, name=EXTERNAL, rules=[], lanes=lanes) + + +def batch_release_dir(d: Path, seq: int, k: int) -> Path: + return Path(d) / "releases" / str(seq) / str(k) + + +def step_k(step: str, batch: dict) -> int: + """手の提示の番号。present は 1、represent は 2、confirm: は k、continue は最後の提示。""" + if step == "present": + return 1 + if step == "represent": + return 2 + if step.startswith("confirm:"): + return int(step.split(":", 1)[1]) + ks = [int(p.get("k") or 0) for p in batch.get("presentations") or []] + return max(ks) if ks else 1 + + +def compose(main: Path, d: Path, batch: dict, supervise: str, parallel: str, version_of: Callable, builder=None) -> dict: + """まとめた配布の手(`batch.json` の `step`)ごとに流すものを返す(#2436 の一時レーンの `helper` と同じ形)。 + + - present / represent: 提示 k の提示のプランと本番のプランを `releases/<連番>//` に作り、`pipeline` は提示のプラン → 本番のプラン + - confirm:: 答えの記録 → 提示 k のプランを `approve` から → 提示 k の本番のプラン(確定していない提示の本番のプランは流さない) + - continue: 止まった本番のプランを再開の手順で続ける""" + import parallel_release as pr + + step, seq = batch["step"], batch["seq"] + k = step_k(step, batch) + rd = batch_release_dir(d, seq, k) + out = {"seq": seq, "k": k, "step": step, "dir": str(rd), "record": f"{parallel} present --external {seq} {k} --record"} + if step in ("present", "represent"): + version = version_of(Path(main)) + if not version: + raise ValueError("出す版を決められない(ベースブランチの配るプラグインの plugin.json の版を読めない)") + rd.mkdir(parents=True, exist_ok=True) + put_json(rd / "version.json", {"version": version}) + present_plan, release_plan = (builder or pr.plan_parallel_release)(main, EXTERNAL, rd, version) + put_json(rd / "present.json", present_plan) + put_json(rd / "release.json", release_plan) + if not (rd / "answer.json").exists(): + put_json(rd / "answer.json", pr.answer_state(Path(main))) + done = rd / "done.json" + return { + **out, + "pipeline": f"{pr.pipeline_cmd(supervise, rd)} && {supervise} wait {done}", + "settle": f"{parallel} settle --external {seq} --done {done}", + } + done = rd / "release-done.json" + if step.startswith("confirm:"): + gate = f"python3 {Path(__file__).resolve().parents[1] / 'sprint-state.py'} gate {rd / 'answer.json'} '関門 2' --what 'まとめた配布 {seq} の提示 {k}' --by user --outcome approved" + steps = [gate, f"{supervise} run {rd / 'present.json'} --from approve", f"{supervise} queue {rd / 'release.json'} --done {done}"] + return { + **out, + "pipeline": " && ".join([*steps, f"{supervise} wait {done}"]), + "settle": f"{parallel} settle --external {seq} --done {done}", + } + # continue は前に実際に起動したキューの記録から続ける。confirm は release-done.json に書き、present / represent を + # MVV 判定が通して本番まで流れたときは done.json にしか無い(confirm は present の後なので release-done.json を先に見る) + if not done.exists() and (rd / "done.json").exists(): + done = rd / "done.json" + return { + **out, + "pipeline": f"{supervise} queue --resume {done} && {supervise} wait {done}", + "settle": f"{parallel} settle --external {seq} --done {done}", + } + + +def present_batch(main: Path, d: Path, batch: dict, seq: int, k: int, approval: Path, closes_of=None) -> dict: + """提示のプランの中で、範囲の Pull Request をレーンへ対応づけて `releases/<連番>//presented.json` を書く(#2436 と同じ関数)。""" + import parallel_release as pr + + rd = batch_release_dir(d, seq, k) + first = read_json(batch_release_dir(d, seq, 1) / "presented.json") if k != 1 else None + kwargs = {"closes_of": closes_of} if closes_of else {} + return pr.present(main, plan_of(batch), approval, requests_of(batch), rd, first, **kwargs) + + +def record_presented(d: Path, seq: int, k: int, auto: bool) -> dict: + """提示を外のオーガナイザーへ書き返す `presented/<連番>-.json`。承認ゲート 2 で止まったら auto は偽、MVV 判定が通したら真。""" + rd = batch_release_dir(d, seq, k) + got = read_json(rd / "presented.json") + if not isinstance(got, dict) or not got.get("sha"): + raise ValueError(f"提示 {seq}-{k} の presented.json が無い") + version = (read_json(rd / "version.json", {}) or {}).get("version") or "" + added = (got.get("added") or {}).get("lanes") or {} + data = { + "seq": seq, + "k": k, + "sha": got["sha"], + "version": version, + "prs": list(got.get("prs") or []), + "lanes": [{"lane": n, "requested": bool(r.get("requested"))} for n, r in sorted((got.get("lanes") or {}).items())], + "added_prs": sorted({n for r in added.values() for n in r.get("prs") or []} | set((got.get("added") or {}).get("orphans") or [])), + "auto": bool(auto), + } + put_json(Path(d) / "presented" / f"{seq}-{k}.json", data) + return data + + +def stages_of(root: Path, version: str, run=subprocess.run) -> list[str]: + """本番の配布が届いた段を GitHub で読む(head が `release/v<版>` の `main` 宛ての Pull Request)。""" + if not version: + return [] + head = f"release/v{version}" + got = run( + ["gh", "pr", "list", "--head", head, "--base", "main", "--state", "all", "--json", "state,url"], + cwd=root, + capture_output=True, + text=True, + ) + try: + pulls = json.loads(got.stdout or "[]") if got.returncode == 0 else [] + except ValueError: + pulls = [] + if any(p.get("state") == "MERGED" for p in pulls): + return ["release_branch", "prod_pr_opened", "main_merged"] + if pulls: + return ["release_branch", "prod_pr_opened"] + br = run(["git", "ls-remote", "--exit-code", "--heads", "origin", head], cwd=root, capture_output=True, text=True) + return ["release_branch"] if br.returncode == 0 else [] + + +def prod_pr_url(root: Path, version: str, run=subprocess.run) -> Optional[str]: + got = run( + ["gh", "pr", "list", "--head", f"release/v{version}", "--base", "main", "--state", "merged", "--json", "url", "-q", ".[0].url"], + cwd=root, + capture_output=True, + text=True, + ) + url = (got.stdout or "").strip() + return url if got.returncode == 0 and url else None + + +def mvv_passed(rd: Path) -> bool: + """提示の答えの記録(`answer.json`)に、MVV 判定が承認ゲート 2 を通した記録(by: mvv)があるか。 + + presented.json を書いた後に MVV 判定そのものが落ちた提示を、自動で承認したと書き返さないために見る。""" + from gate_answer import GATE_2 + + gates = (read_json(Path(rd) / "answer.json", {}) or {}).get("gates") or [] + return any(isinstance(g, dict) and g.get("name") == GATE_2 and g.get("by") == "mvv" for g in gates) + + +def settle_batch(main: Path, d: Path, batch: dict, seq: int, done: dict, reason: Optional[str] = None, run=subprocess.run) -> dict: + """本番のプランの done とリリース記録から、届いた段 `progress/<連番>.json` と結果 `results/<連番>.json` を書く。 + + 結果は done の全項目が完了なら released、承認ゲートで止まっていれば ValueError(書かない)、ほかは failed。 + MVV 判定が通して承認ゲート 2 で止まらなかった提示は、ここで `presented/` を auto 真で書く。""" + results = [str(i.get("result")) for i in done.get("items") or [] if isinstance(i, dict)] + if "関門" in results: + raise ValueError("本番のプランが承認ゲートで止まっている") + k = step_k(batch.get("step") or "continue", batch) + rd = batch_release_dir(d, seq, k) + version = (read_json(rd / "version.json", {}) or {}).get("version") or done.get("version") or "" + if not (Path(d) / "presented" / f"{seq}-{k}.json").exists() and (rd / "presented.json").exists() and mvv_passed(rd): + record_presented(d, seq, k, auto=True) + presented = read_json(rd / "presented.json", {}) or {} + stages = stages_of(main, version, run) + put_json(Path(d) / "progress" / f"{seq}.json", {"seq": seq, "stages": stages}) + released = bool(results) and all(r == "完了" for r in results) + data = { + "seq": seq, + "result": "released" if released else "failed", + "version": version or None, + "prod_pr_url": prod_pr_url(main, version, run) if released else None, + "sha": presented.get("sha"), + "prs": list(presented.get("prs") or []), + "lanes": sorted((presented.get("lanes") or {}).keys()), + "reason": None if released else (reason or "本番のプランが完了しなかった"), + } + put_json(Path(d) / "results" / f"{seq}.json", data) + return data + + +# ---- 形の検査(契約 `contracts/forms/parallel-external.json` の例の組) ---- + +_INTS = lambda v: isinstance(v, list) and all(isinstance(x, int) and not isinstance(x, bool) for x in v) # noqa: E731 + + +def _request_errors(name: str, body: dict) -> list[str]: + import parallel_lane as pl + + errs = pl.request_errors(body) + if not isinstance(body.get("ndf_version"), str) or not body.get("ndf_version"): + errs.append("ndf_version が空でない文字列でない") + if name != f"{body.get('id')}.json": + errs.append("ファイル名が依頼の id でない") + return errs + + +def _answer_errors(body: dict) -> list[str]: + errs: list[str] = [] + if body.get("result") not in ("released", "rejected"): + errs.append("result が released / rejected でない") + if not isinstance(body.get("at"), str): + errs.append("at が文字列でない") + return errs + + +def _batch_errors(body: dict) -> list[str]: + errs: list[str] = [] + if body.get("step") not in STEPS or not isinstance(body.get("seq"), int): + errs.append("step か seq の形が違う") + for ln in body.get("lanes") or []: + if ( + not isinstance(ln.get("lane"), str) + or not _INTS(ln.get("issues")) + or not all(_INTS(r.get("prs")) for r in ln.get("requests") or []) + ): + errs.append("lanes の形が違う") + return errs + + +def file_errors(message: object) -> list[str]: + """置き場のファイル 1 つ({file, body})の形の誤り。空なら形に合う。""" + if not isinstance(message, dict) or not isinstance(message.get("body"), dict) or not isinstance(message.get("file"), str): + return ["{file, body} の形でない"] + path, body = message["file"], message["body"] + top, _, name = path.partition("/") + errs: list[str] = [] + if top == "requests": + return _request_errors(name, body) + if top == "answers": + errs.extend(_answer_errors(body)) + elif top == "ranges": + if not _INTS(body.get("prs")) or not isinstance(body.get("lanes"), list): + errs.append("prs が整数の配列でないか lanes が配列でない") + elif path == "batch.json": + errs.extend(_batch_errors(body)) + elif top == "presented": + if body.get("k") not in (1, 2) or not isinstance(body.get("auto"), bool) or not _INTS(body.get("prs")): + errs.append("k・auto・prs の形が違う") + elif top == "progress": + if not isinstance(body.get("stages"), list) or any(s not in STAGES for s in body["stages"]): + errs.append("stages に知らない段がある") + elif top == "results": + if body.get("result") not in ("released", "failed") or not _INTS(body.get("prs")): + errs.append("result か prs の形が違う") + else: + errs.append(f"知らないファイル: {path}") + return errs diff --git a/plugins/ndf/scripts/lib/parallel_external_cli.py b/plugins/ndf/scripts/lib/parallel_external_cli.py new file mode 100644 index 000000000..0d99c0407 --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_external_cli.py @@ -0,0 +1,114 @@ +"""`parallel.py` の `--external` の副命令の本体(#2439。置き場のファイルは `parallel_external.py`)。 + +外のオーガナイザーのレーンと配布の実行(環境変数 `NDF_ORGANIZER_DIR`)は、計画名の代わりに `--external` を付けて +置き場のファイルでやり取りする。 + +| 副命令 | 役割 | +| --- | --- | +| `request --external --lane <名> --state <パス> [--prs ...] [--issues ...]` | 配布の依頼を `requests/.json` へ書く(10)か、すでに `main` にあれば書かない(0) | +| `await --external --from <パス>` | `answers/.json` を読む(released 0・rejected 10・まだ無い 3) | +| `compose --external` | `batch.json` の手(`step`)ごとに流すもの(`pipeline`・`record`・`settle`)を返す | +| `present --external <連番> <提示> (--approval <承認資料> \\| --record)` | 範囲をレーンへ対応づける。`--record` は承認ゲート 2 で止まった提示を `presented/` へ書く | +| `settle --external <連番> --done ` | 届いた段を `progress/`、結果を `results/` へ書く | + +サービスの実行ジョブ(環境変数 `NDF_FACTORY_RUN`)では `init`・`start`・`run` を断る(1 台の中のオーガナイザーを起動しない)。 + +どの関数も、結果を出して終える `reply`(`parallel.py` の同名の関数)を受け取り、それで終える。 +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Callable + +import parallel_external as pe +import parallel_lane as pl + + +def refuse_in_factory_run(reply: Callable) -> None: + """サービスの実行ジョブ(`NDF_FACTORY_RUN`)の中では 1 台の中のオーガナイザーを起動しない(#2439 の I68。2 つのオーガナイザーは排他)。""" + if os.environ.get("NDF_FACTORY_RUN"): + reply( + "stopped", "サービスの実行ジョブの中では並列の計画を起動しない(外のオーガナイザーが配布をまとめる)", code=1, factory_run=True + ) + + +def external_dir(reply: Callable) -> Path: + d = pe.organizer_dir() + if d is None: + reply("stopped", f"--external には環境変数 {pe.DIR_ENV} が要る", code=1) + return d + + +def present_external(main: Path, a, reply: Callable) -> None: + d = external_dir(reply) + try: + batch = pe.read_batch(d) + if a.record: + got = pe.record_presented(d, a.n, a.k, auto=False) + reply("ok", f"提示 {a.n}-{a.k} を外のオーガナイザーへ書いた({got['sha'][:12]} まで)", sha=got["sha"], prs=got["prs"]) + if not a.approval: + reply("stopped", "--approval か --record が要る", code=1) + got = pe.present_batch(main, d, batch, a.n, a.k, Path(a.approval)) + except ValueError as exc: + reply("stopped", f"提示を作れない: {exc}", code=1) + except OSError as exc: + reply("stopped", f"読めない: {exc}", code=2) + reply( + "ok", + f"提示 {a.n}-{a.k}: {got['sha'][:12]} まで(Pull Request {len(got['prs'])} 本)", + [{"lane": n, **r} for n, r in got["lanes"].items()], + sha=got["sha"], + orphans=got["orphans"], + ) + + +def compose_external(main: Path, reply: Callable) -> None: + """配布の実行(`--parallel-helper --external`)が `batch.json` の手で流すもの(#2439)。""" + from parallel_run import dev_version + from supervise_lib.paths import PARALLEL_PY, SELF + + d = external_dir(reply) + try: + task = pe.compose(main, d, pe.read_batch(d), f"python3 {SELF}", PARALLEL_PY, dev_version) + except ValueError as exc: + reply("stopped", f"まとめた配布を組めない: {exc}", code=1) + except OSError as exc: + reply("stopped", f"書けない: {exc}", code=2) + reply("ok", f"まとめた配布 {task['seq']} の手 {task['step']}(提示 {task['k']})", **task) + + +def settle_external(main: Path, a, reply: Callable) -> None: + d = external_dir(reply) + if a.rejected or not a.done: + reply("stopped", "--external の settle は --done が要る(差し戻しは外のオーガナイザーが決める)", code=1) + try: + done = json.loads(Path(a.done).read_text(encoding="utf-8")) + got = pe.settle_batch(main, d, pe.read_batch(d), a.n, done, a.reason) + except ValueError as exc: + reply("stopped", f"結果を書かない: {exc}", code=4) + except OSError as exc: + reply("stopped", f"done を読めない: {exc}", code=2) + reply("ok", f"配布 {a.n} の結果 {got['result']} を外のオーガナイザーへ書いた", result=got["result"], version=got["version"]) + + +def request_external(main: Path, a, reply: Callable, parallel_py: Path) -> None: + d = external_dir(reply) + if a.prs and pl.merged_on_production(main, a.prs): + version = pe.range_version(d, a.prs) + pl.write_request(Path(a.state), {"result": "released", "version": version, "prs": a.prs}) + reply("ok", "自分の Pull Request はすでに本番チャネルにある。依頼を書かずに続きへ進む", result="released", version=version) + rid = pl.lane_request_id(Path(a.state), a.lane) + pe.write_request_file(d, rid, a.lane, a.prs, a.issues, Path(a.state)) + pl.write_request(Path(a.state), {"id": rid, "plan": pe.EXTERNAL, "lane": a.lane, "prs": a.prs}) + nxt = f"python3 {parallel_py} await --external --from {Path(a.state) / 'work' / 'request.txt'}" + reply( + "gate", + f"配布の依頼 {rid} を外のオーガナイザーへ書いた(配布待ち)。答えは parallel.py await --external で読む", + code=10, + next_step=nxt, + id=rid, + result="requested", + ) diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index fb7990853..f8f9a9da0 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -1,6 +1,10 @@ """並列の計画のレーンの側(#2436): 自分が属する計画の判定(I12)・すでに配布済みかの判定(I15)・配布の依頼を送る・ 答えを読む(待たずに読むのと待つのの両方)・プランの本数を返す。 +環境変数 `NDF_ORGANIZER_DIR` があれば、外のオーガナイザーのレーンとして判定し、計画名の代わりに `--external` を返す +(#2439。依頼と答えは置き場のファイルで受け渡す。`parallel_external.py`)。外のレーンは設計の承認と承認ゲートの外の問いを +依頼にせず、今どおり止まって人へ戻す。 + 耐久の記録は外のプロセスとして `durable.py` の `send_to`・`event_of`・`status_of` で開く(DBOS を起動しない)。 DBOS を読まずに済む判定(計画の有無・本数)は、耐久の記録のファイルが無ければ DBOS を import しない。 """ @@ -16,6 +20,7 @@ from pathlib import Path from typing import Optional +import parallel_external as pe import parallel_plan as pp from parallel_files import helper_plan_of, lane_name_in_plans, plans_in # noqa: F401 ファイルだけで読む判定(問いの hook も使う) @@ -48,8 +53,22 @@ def run_status(main: Path, name: str) -> Optional[str]: return durable.status_from(KIND, record_identity(main, name), workflow_id(name)) +def external_lane() -> Optional[tuple[str, pp.Lane]]: + """外のオーガナイザーのレーンなら (`--external`, レーン)。名前は `NDF_ORGANIZER_LANE`(#2439)。""" + if pe.organizer_dir() is None: + return None + name = pe.organizer_lane() or "external" + lane = pp.Lane.model_construct( + name=name, input=pp.SprintInput.model_construct(kind="sprint", issues=[], args=[]), plans=QUEUE_MAX, touches=[] + ) + return pe.EXTERNAL, lane + + def lane_of(main: Path, lane: Optional[str] = None, status=run_status) -> Optional[tuple[str, pp.Lane]]: - """`NDF_RELAY_NAME` の名前が、耐久ワークフローが終わっていない計画のレーンにあれば (計画名, レーン)。無ければ None(I12)。""" + """`NDF_RELAY_NAME` の名前が、耐久ワークフローが終わっていない計画のレーンにあれば (計画名, レーン)。無ければ None(I12)。 + 外のオーガナイザーのレーン(`NDF_ORGANIZER_DIR`)なら、名前を渡さない判定で (`--external`, レーン) を返す(#2439)。""" + if lane is None and (ext := external_lane()): + return ext lane = lane if lane is not None else os.environ.get("NDF_RELAY_NAME") if not lane: return None @@ -208,7 +227,7 @@ def send_release_request(main: Path, name: str, lane: str, prs: list[int], issue "prs": list(prs), "issues": list(issues), "sprint_state": str(state), - "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "at": pe.utc_stamp(), } durable.send_to(KIND, record_identity(main, name), wid, msg, "inbox", f"{wid}:inbox:{rid}") return rid @@ -240,6 +259,9 @@ def write_request(state: Path, data: dict) -> Path: def answers_of(main: Path, name: str, ids: list[str]) -> dict[str, dict]: """依頼ごとの {"answer": `answer-<依頼>`, "defer": `defer-<依頼>`} を待たずに読む(周回の境で読む。#2436 の 6a・6e)。 耐久の記録が無ければ、どれも答えの無いものとして返す(DBOS を import しない)。""" + if pe.is_external(name): + d = pe.organizer_dir() + return pe.answer_files_of(d, ids) if d else {i: {"answer": None, "defer": None} for i in ids} out = {i: {"answer": None, "defer": None} for i in ids} if not ids or not record_exists(main, name): return out @@ -253,12 +275,14 @@ def answers_of(main: Path, name: str, ids: list[str]) -> dict[str, dict]: return out -# ---- 問いの依頼と答え(#2436 の 6c・6d・6f。設計 issue-2436-design-approvals.md) ---- +# ---- 問いの依頼と答え(#2436 の 6c・6d・6f。確定仕様 docs/specifications/ndf-parallel-organizer.md) ---- def sender_of(main: Path, name: Optional[str], relay_dir: Optional[str], status=run_status) -> Optional[tuple[str, str]]: """問いを送ってよい送り手か: 動いている計画のレーン(`lane_of`)か、役割のファイルが `helper` で、その計画の - 耐久ワークフローが終わっていない一時レーン。(計画名, 送り手の名前) か None。""" + 耐久ワークフローが終わっていない一時レーン。(計画名, 送り手の名前) か None。外のオーガナイザーのレーンは問いを送らない(#2439)。""" + if pe.organizer_dir() is not None: + return None got = lane_of(main, name, status) if name else None if got: return got[0], name @@ -268,11 +292,10 @@ def sender_of(main: Path, name: Optional[str], relay_dir: Optional[str], status= return None -def _utc_stamp() -> str: - return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - - def _send_inbox(main: Path, name: str, msg: dict, key: str) -> None: + if pe.is_external(name): + # 外のオーガナイザーのレーンは設計の承認と問いを依頼にしない(今どおり止まって人へ戻す。#2439) + raise ValueError("外のオーガナイザーのレーンは問いを依頼にしない") import durable wid = workflow_id(name) @@ -300,7 +323,7 @@ def request_ask(main: Path, name: str, lane: str, tool_input: dict, cwd: str, le "cwd": cwd, "ledger_id": ledger_id, "stamp": _head_sha(cwd), - "at": _utc_stamp(), + "at": pe.utc_stamp(), } _send_inbox(main, name, msg, rid) return rid @@ -310,7 +333,7 @@ def request_gate1(main: Path, name: str, lane: str, issue: int, pr: int, report: """設計の承認の依頼(`kind: gate1`)。id は `<レーン>-g<設計 PR>-<先頭の SHA の 12 桁>`(同じ先頭の打ち直しは同じ id)。""" rid = f"{lane}-g{pr}-{stamp[:12]}" msg = {"kind": "gate1", "id": rid, "lane": lane, "issue": issue, "pr": pr, "report": report, "sprint_state": state, "stamp": stamp} - _send_inbox(main, name, {**msg, **extra, "at": _utc_stamp()}, rid) + _send_inbox(main, name, {**msg, **extra, "at": pe.utc_stamp()}, rid) return rid @@ -326,7 +349,7 @@ def request_gate2(main: Path, name: str, n: int, k: int, report: str) -> str: rid = f"{name}-p{n}-{k}" lane = prl.helper_name(name, n) _send_inbox( - main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": _utc_stamp()}, rid + main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": pe.utc_stamp()}, rid ) return rid @@ -341,5 +364,20 @@ def send_answer( stamp: Optional[str] = None, ) -> None: """オーガナイザーの答えを送る。耐久ワークフローが `answer-<依頼>` を 1 回だけ立て、問いの一覧から外す。""" - msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": _utc_stamp()} + msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": pe.utc_stamp()} _send_inbox(main, name, msg, f"answer:{rid}") + + +def requests_for(main: Path, name: str) -> list[dict]: + """オーガナイザーが読める依頼(`releases/requests.json`。耐久ワークフローが配布を組むたびに書く。無ければ空)。""" + p = pp.plan_dir(main, name) / "releases" / "requests.json" + try: + return json.loads(p.read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + + +def resume_plan(main: Path, name: str, msg: dict, key: str) -> None: + import durable + + durable.send_to(KIND, record_identity(main, name), workflow_id(name), msg, "resume", key) diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 47bca6f51..1952d1e44 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -19,6 +19,8 @@ | `answer <計画名> <依頼> (--approved \\| --revise --note <パス> \\| --rejected \\| --choice <答え>...)` | 問いの答えを送る(持ち主のレーンが止まっていれば `metrics.next` に起こす 1 行) | | `helper <計画名> <連番> <提示>` | 一時レーンが流すもの(`phase`・`pipeline`・`settle`・`gate2`)を返す | +外のオーガナイザーとの `--external` の副命令(#2439)は `lib/parallel_external_cli.py` にある。 + 置き場はメインディレクトリの `.ndf/parallel/<計画名>/`(コミットしない)。正本の手順は `skills/development-workflow/references/parallel.md` と `skills/issue-plan-strategy/references/parallel-plan.md` にある。 """ @@ -40,6 +42,9 @@ deps.require("durable", "schema", "md", "mdtable", "versions") import decl # noqa: E402 +import parallel_answer as pa # noqa: E402 +import parallel_external as pe # noqa: E402 +import parallel_external_cli as px # noqa: E402 import parallel_lane as pl # noqa: E402 import parallel_plan as pp # noqa: E402 from step_result import emit, result # noqa: E402 @@ -53,8 +58,14 @@ def main_of() -> Path: return decl.main_dir(cwd) or Path(cwd) -def reply(status: str, summary: str, items=None, code: int | None = None, **metrics): - emit(result(TOOL, status, summary, items, metrics), code) +def reply(status: str, summary: str, items=None, code: int | None = None, next_step: str | None = None, **metrics): + """結果を出して終える。gate は次の 1 手(next_step)を添える(統一 JSON レポートの形)。""" + emit(result(TOOL, status, summary, items, metrics, next=next_step), code) + + +def require_name(a) -> None: + if not a.external and not a.name: + reply("stopped", "計画名か --external が要る", code=1) def checked_plan(main: Path, name: str) -> tuple[pp.ParallelPlan | None, list[dict], dict]: @@ -68,6 +79,7 @@ def checked_plan(main: Path, name: str) -> tuple[pp.ParallelPlan | None, list[di def cmd_init(a) -> None: + px.refuse_in_factory_run(reply) main = main_of() try: raw = json.loads(sys.stdin.read()) @@ -132,6 +144,7 @@ def _wait_line(name: str) -> str: def cmd_run(a) -> None: + px.refuse_in_factory_run(reply) main = main_of() plan, items, metrics = checked_plan(main, a.name) if plan is None: @@ -155,6 +168,7 @@ def cmd_run(a) -> None: def cmd_start(a) -> None: + px.refuse_in_factory_run(reply) main = main_of() plan, items, metrics = checked_plan(main, a.name) if plan is None: @@ -208,14 +222,19 @@ def cmd_wait(a) -> None: def cmd_present(a) -> None: import parallel_release as pr + require_name(a) main = main_of() + if a.external: + px.present_external(main, a, reply) + if not a.approval: + reply("stopped", "--approval が要る", code=1) plan, items, metrics = checked_plan(main, a.name) if plan is None: reply("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) d = pr.release_dir(main, a.name, a.n, a.k) p1 = pr.release_dir(main, a.name, a.n, 1) / "presented.json" first = json.loads(p1.read_text(encoding="utf-8")) if a.k != 1 and p1.is_file() else None - reqs = _requests_for(main, a.name) + reqs = pl.requests_for(main, a.name) try: got = pr.present(main, plan, Path(a.approval), reqs, d, first) except ValueError as exc: @@ -231,19 +250,8 @@ def cmd_present(a) -> None: ) -def _requests_for(main: Path, name: str) -> list[dict]: - """オーガナイザーが読める依頼(`releases/requests.json`。耐久ワークフローが配布を組むたびに書く。無ければ空)。""" - p = pp.plan_dir(main, name) / "releases" / "requests.json" - try: - return json.loads(p.read_text(encoding="utf-8")) - except (OSError, ValueError): - return [] - - -def _resume_plan(main: Path, name: str, msg: dict, key: str) -> None: - import durable - - durable.send_to(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), msg, "resume", key) +def cmd_compose(a) -> None: + px.compose_external(main_of(), reply) def cmd_confirm(a) -> None: @@ -251,12 +259,15 @@ def cmd_confirm(a) -> None: if pl.run_status(main, a.name) not in pl.RUNNING: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) wid = pl.workflow_id(a.name) - _resume_plan(main, a.name, {"n": a.n, "phase": "confirm", "take": a.take}, f"{wid}:resume:confirm:{a.n}:{a.take or '-'}") + pl.resume_plan(main, a.name, {"n": a.n, "phase": "confirm", "take": a.take}, f"{wid}:resume:confirm:{a.n}:{a.take or '-'}") reply("ok", f"配布 {a.n} の承認を送った", n=a.n, take=a.take) def cmd_settle(a) -> None: + require_name(a) main = main_of() + if a.external: + px.settle_external(main, a, reply) if pl.run_status(main, a.name) not in pl.RUNNING: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) if a.rejected: @@ -272,9 +283,8 @@ def cmd_settle(a) -> None: res = "released" if results and all(r == "完了" for r in results) else "failed" version = done.get("version") or (done.get("metrics") or {}).get("version") wid = pl.workflow_id(a.name) - _resume_plan( - main, a.name, {"n": a.n, "phase": "settle", "result": res, "version": version, "reason": a.reason}, f"{wid}:resume:settle:{a.n}" - ) + msg = {"n": a.n, "phase": "settle", "result": res, "version": version, "reason": a.reason} + pl.resume_plan(main, a.name, msg, f"{wid}:resume:settle:{a.n}") reply("ok", f"配布 {a.n} の結果 {res} を送った", result=res, version=version) @@ -300,7 +310,10 @@ def cmd_stop(a) -> None: def cmd_request(a) -> None: + require_name(a) main = main_of() + if a.external: + px.request_external(main, a, reply, SELF) if pl.run_status(main, a.name) not in pl.RUNNING: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) if a.prs and pl.merged_on_production(main, a.prs): @@ -309,7 +322,9 @@ def cmd_request(a) -> None: reply("ok", "自分の Pull Request はすでに本番チャネルにある。依頼を送らずに続きへ進む", result="released", version=version) rid = pl.send_release_request(main, a.name, a.lane, a.prs, a.issues, Path(a.state)) pl.write_request(Path(a.state), {"id": rid, "plan": a.name, "lane": a.lane, "prs": a.prs}) - reply("gate", f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む", code=10, id=rid, result="requested") + nxt = f"python3 {SELF} await {a.name} --from {Path(a.state) / 'work' / 'request.txt'}" + summary = f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む" + reply("gate", summary, code=10, next_step=nxt, id=rid, result="requested") def cmd_await(a) -> None: @@ -320,7 +335,11 @@ def cmd_await(a) -> None: reply("stopped", f"依頼の記録を読めない: {exc}", code=2) if sent.get("result") == "released" and not sent.get("id"): reply("ok", "配布済み(依頼を送っていない)", result="released", version=sent.get("version"), deferred=None) - ans, dfr = pl.read_answer(main, a.name, sent["id"], a.timeout) + if a.external or pe.is_external(sent.get("plan")): + ans, dfr = pe.read_answer_file(px.external_dir(reply), sent["id"]), None + else: + require_name(a) + ans, dfr = pl.read_answer(main, a.name, sent["id"], a.timeout) if ans is None: reply("stopped", "答えがまだ無い", code=3, deferred=dfr) res = ans.get("result") @@ -330,6 +349,7 @@ def cmd_await(a) -> None: status, f"配布の依頼 {sent['id']} の答え: {res}", code=code, + next_step="今の承認ゲート 2 の差し戻しと同じに扱う" if code == 10 else None, result=res, version=ans.get("version"), release=ans.get("release"), @@ -364,24 +384,6 @@ def cmd_request_gate2(a) -> None: reply("ok", f"承認ゲート 2 の依頼 {rid} をオーガナイザーへ送った。答えを待つ間は止まる", id=rid) -def _owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None) -> str | None: - """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは答えを待って止まった持ち主の - restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。同じ原因としてまとめた問い(`merged`)は、 - 代表だけでなく全持ち主をそれぞれの依頼の id で調べ、起こす行を `; ` でつなぐ(異常系 5)。""" - import durable - import parallel_run as pr_run - - items = (durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {}).get("items") or [] - q = next((q for q in items if q.get("id") == rid), {}) - if q.get("kind") in ("stall", "helper", "spawn") or not q.get("lane"): - return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) - ls = lambda: pr_run.relay(main, "ls", "--json") # noqa: E731 - owners = [(q["lane"], rid), *((m.get("lane"), m.get("id")) for m in q.get("merged") or [] if m.get("lane"))] - lanes = [lane for lane, each in owners if pl.lane_waits(main, name, lane, str(each or rid), ls)] - lanes = list(dict.fromkeys(lanes)) - return "; ".join(f"python3 {pr_run.RELAY} restart {lane}" for lane in lanes) or None - - def cmd_answer(a) -> None: main = main_of() if pl.run_status(main, a.name) not in pl.RUNNING: @@ -395,7 +397,7 @@ def cmd_answer(a) -> None: except OSError as exc: reply("stopped", f"指摘ファイルを読めない: {exc}", code=2) result = "approved" if a.approved else "revise" if a.revise else "rejected" if a.rejected else "answered" - nxt = _owner_restart(main, a.name, a.id, a.choice) + nxt = pa.owner_restart(main, a.name, a.id, a.choice) pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice) reply("ok", f"依頼 {a.id} へ答え({result})を送った", id=a.id, result=result, next=nxt) @@ -419,6 +421,8 @@ def build_parser() -> argparse.ArgumentParser: chk.add_argument("name") sta = sub.add_parser("start") sta.add_argument("name") + cp = sub.add_parser("compose") + cp.add_argument("--external", action="store_true", required=True) run_ = sub.add_parser("run") run_.add_argument("name") stp = sub.add_parser("stop") @@ -429,29 +433,34 @@ def build_parser() -> argparse.ArgumentParser: w.add_argument("--after", type=int, default=0) w.add_argument("--timeout", type=float, default=3600) p = sub.add_parser("present") - p.add_argument("name") + p.add_argument("name", nargs="?") p.add_argument("n", type=int) p.add_argument("k", type=int) - p.add_argument("--approval", required=True) + p.add_argument("--approval") + p.add_argument("--external", action="store_true") + p.add_argument("--record", action="store_true", help="--external で、承認ゲート 2 で止まった提示を presented/ へ書く") c = sub.add_parser("confirm") c.add_argument("name") c.add_argument("n", type=int) c.add_argument("--take", choices=("new", "first")) s = sub.add_parser("settle") - s.add_argument("name") + s.add_argument("name", nargs="?") s.add_argument("n", type=int) + s.add_argument("--external", action="store_true") g = s.add_mutually_exclusive_group(required=True) g.add_argument("--done") g.add_argument("--rejected", action="store_true") s.add_argument("--reason") r = sub.add_parser("request") - r.add_argument("name") + r.add_argument("name", nargs="?") + r.add_argument("--external", action="store_true") r.add_argument("--lane", required=True) r.add_argument("--prs", type=int, nargs="*", default=[]) r.add_argument("--issues", type=int, nargs="*", default=[]) r.add_argument("--state", required=True) aw = sub.add_parser("await") - aw.add_argument("name") + aw.add_argument("name", nargs="?") + aw.add_argument("--external", action="store_true") aw.add_argument("--from", required=True) aw.add_argument("--timeout", type=float, default=0) ra = sub.add_parser("request-ask") diff --git a/plugins/ndf/scripts/tests/test_parallel_external.py b/plugins/ndf/scripts/tests/test_parallel_external.py new file mode 100644 index 000000000..c21629369 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_parallel_external.py @@ -0,0 +1,294 @@ +"""外のオーガナイザーとファイルでやり取りする切り替え口(#2439。`contracts/forms/parallel-external.json`)。""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +ROOT = SCRIPTS.parent +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import parallel_external as pe # noqa: E402 +import parallel_lane as pl # noqa: E402 + +FORM = json.loads((ROOT / "contracts" / "forms" / "parallel-external.json").read_text(encoding="utf-8")) +PARALLEL = SCRIPTS / "parallel.py" + + +@pytest.mark.parametrize("case", FORM["cases"], ids=lambda c: f"{c['message']['file']}-{c['valid']}") +def test_form_cases(case): + assert (pe.file_errors(case["message"]) == []) is case["valid"] + + +@pytest.fixture +def org(tmp_path, monkeypatch): + d = tmp_path / "organizer" + monkeypatch.setenv("NDF_ORGANIZER_DIR", str(d)) + monkeypatch.setenv("NDF_ORGANIZER_LANE", "loop-l20261102") + monkeypatch.delenv("NDF_RELAY_NAME", raising=False) + return d + + +def test_lane_of_is_external_only_with_organizer_dir(tmp_path, org, monkeypatch): + got = pl.lane_of(tmp_path) + assert got is not None and got[0] == pe.EXTERNAL and got[1].name == "loop-l20261102" + monkeypatch.delenv("NDF_ORGANIZER_DIR") + assert pl.lane_of(tmp_path) is None + + +def test_external_lane_does_not_send_questions(tmp_path, org): + # 設計の承認と承認ゲートの外の問いは依頼にしない(今どおり止まって人へ戻す) + assert pl.sender_of(tmp_path, "loop-l20261102", None) is None + with pytest.raises(ValueError): + pl.request_gate1(tmp_path, pe.EXTERNAL, "loop-l20261102", 1, 2, "r", "s", "0" * 12) + + +def _run(args, cwd, env_extra=None): + import os + + env = {**os.environ, **(env_extra or {})} + got = subprocess.run([sys.executable, str(PARALLEL), *args], cwd=cwd, capture_output=True, text=True, env=env) + return got.returncode, json.loads(got.stdout.strip().splitlines()[-1]) + + +def test_request_and_await_through_files(tmp_path, org): + state = tmp_path / "plan-state" + code, out = _run( + ["request", "--external", "--lane", "loop-l20261102", "--state", str(state), "--prs", "2440", "--issues", "2439"], tmp_path + ) + assert code == 10, out + rid = out["metrics"]["id"] + body = json.loads((org / "requests" / f"{rid}.json").read_text()) + assert pe.file_errors({"file": f"requests/{rid}.json", "body": body}) == [] + # 打ち直しても同じ id + code, again = _run( + ["request", "--external", "--lane", "loop-l20261102", "--state", str(state), "--prs", "2440", "--issues", "2439"], tmp_path + ) + assert again["metrics"]["id"] == rid + sent = state / "work" / "request.txt" + code, out = _run(["await", "--external", "--from", str(sent)], tmp_path) + assert code == 3 + pe.put_json( + org / "answers" / f"{rid}.json", + {"result": "released", "version": "10.28.0", "release": "https://x/pull/1", "reason": None, "at": "t"}, + ) + code, out = _run(["await", "--external", "--from", str(sent)], tmp_path) + assert code == 0 and out["metrics"]["version"] == "10.28.0" + # ループの周回の境は計画名 --external で答えを読む + assert pl.answers_of(tmp_path, pe.EXTERNAL, [rid])[rid]["answer"]["result"] == "released" + + +@pytest.mark.parametrize("cmd", [["init", "p1"], ["run", "p1"], ["start", "p1"]]) +def test_factory_run_refuses_local_organizer(tmp_path, cmd): + code, out = _run(cmd, tmp_path, {"NDF_FACTORY_RUN": "1"}) + assert code == 1 and out["metrics"]["factory_run"] is True + + +BATCH = { + "seq": 3, + "step": "present", + "lanes": [ + {"lane": "loop-l1", "requests": [{"id": "loop-l1-r000000000001", "prs": [11]}], "issues": [1]}, + {"lane": "run-ab", "requests": [], "issues": [2]}, + ], + "presentations": [], + "choices": ["continue", "reject"], +} + + +def _builder(main, name, d, version): + return {"steps": [], "version": version, "name": name}, {"steps": [], "dir": str(d)} + + +def test_compose_present_represent_and_confirm(tmp_path): + d = tmp_path / "org" + task = pe.compose(tmp_path, d, BATCH, "SV", "PAR", lambda _m: "10.28.0", builder=_builder) + assert task["k"] == 1 and (Path(task["dir"]) / "present.json").is_file() + assert "present.json --then" in task["pipeline"] and task["settle"].startswith("PAR settle --external 3") + task2 = pe.compose(tmp_path, d, {**BATCH, "step": "represent"}, "SV", "PAR", lambda _m: "10.28.0", builder=_builder) + assert task2["k"] == 2 and Path(task2["dir"]).name == "2" + # 確定した提示 1 の本番のプランだけを流す(提示 2 の本番のプランを流さない) + task3 = pe.compose(tmp_path, d, {**BATCH, "step": "confirm:1"}, "SV", "PAR", lambda _m: "10.28.0", builder=_builder) + assert str(pe.batch_release_dir(d, 3, 1) / "release.json") in task3["pipeline"] + assert str(pe.batch_release_dir(d, 3, 2)) not in task3["pipeline"] + # 続きは前の done を値に取る `queue --resume ` だけで流す(位置引数・--done と併せない) + task4 = pe.compose( + tmp_path, d, {**BATCH, "step": "continue", "presentations": [{"k": 1}]}, "SV", "PAR", lambda _m: "", builder=_builder + ) + done4 = pe.batch_release_dir(d, 3, 1) / "release-done.json" + assert task4["pipeline"] == f"SV queue --resume {done4} && SV wait {done4}" + with pytest.raises(ValueError): + pe.compose(tmp_path, d, BATCH, "SV", "PAR", lambda _m: "", builder=_builder) + + +def test_compose_continue_uses_done_of_launched_queue(tmp_path): + # MVV 判定が通した提示のまま本番まで流れて止まったときは、記録が done.json にしか無い + d = tmp_path / "org" + rd = pe.batch_release_dir(d, 3, 1) + rd.mkdir(parents=True) + (rd / "done.json").write_text("{}") + batch = {**BATCH, "step": "continue", "presentations": [{"k": 1}]} + task = pe.compose(tmp_path, d, batch, "SV", "PAR", lambda _m: "", builder=_builder) + assert task["pipeline"] == f"SV queue --resume {rd / 'done.json'} && SV wait {rd / 'done.json'}" + # confirm が起動した後は release-done.json から続ける + (rd / "release-done.json").write_text("{}") + task = pe.compose(tmp_path, d, batch, "SV", "PAR", lambda _m: "", builder=_builder) + assert task["settle"] == f"PAR settle --external 3 --done {rd / 'release-done.json'}" + + +def _fake_run(merged: bool): + def run(args, **_kw): + if args[:3] == ["gh", "pr", "list"]: + if "--state" in args and args[args.index("--state") + 1] == "merged": + return SimpleNamespace(returncode=0, stdout="https://github.com/o/r/pull/900\n") + return SimpleNamespace(returncode=0, stdout=json.dumps([{"state": "MERGED" if merged else "OPEN", "url": "u"}])) + return SimpleNamespace(returncode=0, stdout="") + + return run + + +def _presented(d: Path, k: int = 1) -> None: + rd = pe.batch_release_dir(d, 3, k) + pe.put_json(rd / "version.json", {"version": "10.28.0"}) + pe.put_json( + rd / "presented.json", + { + "sha": "0123abcd", + "prs": [11, 12], + "lanes": {"loop-l1": {"prs": [11], "requested": True}, "run-ab": {"prs": [12], "requested": False}}, + "orphans": [], + "added": None, + }, + ) + + +def test_settle_writes_main_merged_and_auto_presented(tmp_path): + d = tmp_path / "org" + _presented(d) + pe.put_json(pe.batch_release_dir(d, 3, 1) / "answer.json", {"gates": [{"name": "関門 2", "by": "mvv", "verdict": "approve"}]}) + got = pe.settle_batch(tmp_path, d, BATCH, 3, {"items": [{"result": "完了"}]}, run=_fake_run(True)) + assert got["result"] == "released" and got["prod_pr_url"] == "https://github.com/o/r/pull/900" + assert "main_merged" in pe.read_json(d / "progress" / "3.json")["stages"] + # MVV 判定が通して承認ゲート 2 で止まらなかった提示は auto 真で書き返す + assert pe.read_json(d / "presented" / "3-1.json")["auto"] is True + for f in ("progress/3.json", "results/3.json", "presented/3-1.json"): + assert pe.file_errors({"file": f, "body": pe.read_json(d / f)}) == [] + + +def test_settle_does_not_write_auto_without_mvv_pass(tmp_path): + """presented.json の後に MVV 判定そのものが落ちた提示は、自動の承認と書き返さない(再開で人の承認を記録させない)。""" + d = tmp_path / "org" + _presented(d) + got = pe.settle_batch(tmp_path, d, BATCH, 3, {"items": [{"result": "失敗"}]}, run=_fake_run(False)) + assert got["result"] == "failed" + assert not (d / "presented" / "3-1.json").exists() + + +def test_settle_refuses_when_release_plan_stopped_at_gate(tmp_path): + d = tmp_path / "org" + _presented(d) + with pytest.raises(ValueError): + pe.settle_batch(tmp_path, d, BATCH, 3, {"items": [{"result": "関門"}]}, run=_fake_run(False)) + assert not (d / "results" / "3.json").exists() + + +def test_record_presented_on_gate_stop(tmp_path): + d = tmp_path / "org" + _presented(d) + got = pe.record_presented(d, 3, 1, auto=False) + assert got["auto"] is False and got["lanes"] == [{"lane": "loop-l1", "requested": True}, {"lane": "run-ab", "requested": False}] + + +def test_released_version_reads_ranges(tmp_path): + pe.put_json(tmp_path / "ranges" / "1.json", {"sha": "a", "version": "10.27.0", "prs": [5], "lanes": []}) + pe.put_json(tmp_path / "ranges" / "2.json", {"sha": "b", "version": "10.28.0", "prs": [11], "lanes": []}) + assert pe.range_version(tmp_path, [11]) == "10.28.0" + assert pe.range_version(tmp_path, [99]) is None + + +@pytest.mark.parametrize( + "message, expected", + [ + (None, ["{file, body} の形でない"]), + ({"file": "answers/a.json"}, ["{file, body} の形でない"]), + ({"file": 1, "body": {}}, ["{file, body} の形でない"]), + ({"file": "answers/a.json", "body": {"result": "released", "at": "t"}}, []), + ( + {"file": "answers/a.json", "body": {"result": "x", "at": 1}}, + ["result が released / rejected でない", "at が文字列でない"], + ), + ({"file": "answers/a.json", "body": {"result": "rejected"}}, ["at が文字列でない"]), + ({"file": "ranges/r.json", "body": {"prs": [1], "lanes": []}}, []), + ( + {"file": "ranges/r.json", "body": {"prs": [True], "lanes": []}}, + ["prs が整数の配列でないか lanes が配列でない"], + ), + ( + {"file": "ranges/r.json", "body": {"prs": [1], "lanes": {}}}, + ["prs が整数の配列でないか lanes が配列でない"], + ), + ({"file": "batch.json", "body": {"step": "present", "seq": 1}}, []), + ({"file": "batch.json", "body": {"step": "x", "seq": 1}}, ["step か seq の形が違う"]), + ( + { + "file": "batch.json", + "body": { + "step": "confirm:1", + "seq": 2, + "lanes": [{"lane": "a", "issues": [1], "requests": [{"prs": [3]}]}], + }, + }, + [], + ), + ( + { + "file": "batch.json", + "body": { + "step": "x", + "seq": "1", + "lanes": [ + {"lane": 1, "issues": [1]}, + {"lane": "b", "issues": [1], "requests": [{"prs": ["x"]}]}, + ], + }, + }, + ["step か seq の形が違う", "lanes の形が違う", "lanes の形が違う"], + ), + ({"file": "presented/p.json", "body": {"k": 1, "auto": False, "prs": [1]}}, []), + ( + {"file": "presented/p.json", "body": {"k": 3, "auto": False, "prs": [1]}}, + ["k・auto・prs の形が違う"], + ), + ({"file": "progress/p.json", "body": {"stages": ["tagged"]}}, []), + ({"file": "progress/p.json", "body": {"stages": ["x"]}}, ["stages に知らない段がある"]), + ({"file": "progress/p.json", "body": {}}, ["stages に知らない段がある"]), + ({"file": "results/r.json", "body": {"result": "failed", "prs": []}}, []), + ({"file": "results/r.json", "body": {"result": "rejected", "prs": []}}, ["result か prs の形が違う"]), + ({"file": "other/x.json", "body": {}}, ["知らないファイル: other/x.json"]), + ({"file": "batch2.json", "body": {}}, ["知らないファイル: batch2.json"]), + ], +) +def test_file_errors_pins_messages_per_kind(message, expected): + assert pe.file_errors(message) == expected + + +def test_file_errors_delegates_requests_to_request_errors(): + body = {"anything": 1} + assert pe.file_errors({"file": "requests/r1.json", "body": body}) == pe._request_errors("r1.json", body) + + +def test_session_ndf_version_keeps_pinned_dev_commit(monkeypatch): + """開発版の実行ジョブが渡す `@` を落とさない。plugin.json と版が違う値は使わない。""" + monkeypatch.delenv("NDF_VERSION", raising=False) + version = pe.session_ndf_version() + assert version and "@" not in version + monkeypatch.setenv("NDF_VERSION", f"{version}@0123abcd") + assert pe.session_ndf_version() == f"{version}@0123abcd" + monkeypatch.setenv("NDF_VERSION", "0.0.1@0123abcd") + assert pe.session_ndf_version() == version diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index b33c180d5..782b7aa00 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(SCRIPTS)) import dbos # noqa: E402,F401 import durable # noqa: E402 +import parallel_answer as pa # noqa: E402 import parallel_lane as pl # noqa: E402 import parallel_plan as pp # noqa: E402 import parallel_release as prl # noqa: E402 @@ -466,7 +467,7 @@ def test_request_ask_cli_refuses_a_sender_outside_the_plan_with_4(env, monkeypat assert got.returncode == 4 and json.loads(got.stdout)["status"] == "stopped" -# ---- 異常系(設計 issue-2436-design-failures.md の 3・5・11・12・17 と一時レーンの停止) ---- +# ---- 異常系(確定仕様 docs/specifications/ndf-parallel-organizer.md のエラー処理と一時レーンの停止) ---- OLD = "2000-01-01T00:00:00Z" @@ -664,8 +665,8 @@ def test_answer_to_an_own_question_returns_the_chosen_command(monkeypatch, tmp_p spec.loader.exec_module(cli) item = {"id": "h1", "kind": "helper", "lane": "p1-rel1", "commands": {"続きから起こし直す": "SPAWN"}} monkeypatch.setattr(durable, "event_from", lambda *_a, **_k: {"items": [item]}) - assert cli._owner_restart(tmp_path, "p1", "h1", ["続きから起こし直す"]) == "SPAWN" - assert cli._owner_restart(tmp_path, "p1", "h1", ["止める"]) is None + assert pa.owner_restart(tmp_path, "p1", "h1", ["続きから起こし直す"]) == "SPAWN" + assert pa.owner_restart(tmp_path, "p1", "h1", ["止める"]) is None def test_owner_is_woken_by_the_lane_side_wait_record(monkeypatch, tmp_path): @@ -696,19 +697,19 @@ def test_owner_is_woken_by_the_lane_side_wait_record(monkeypatch, tmp_path): loop.mkdir(parents=True) handoff = tmp_path / ".ndf" / "handoff" handoff.mkdir(parents=True) - assert cli._owner_restart(tmp_path, "p1", "qa") is None and cli._owner_restart(tmp_path, "p1", "qb") is None + assert pa.owner_restart(tmp_path, "p1", "qa") is None and pa.owner_restart(tmp_path, "p1", "qb") is None (loop / "loop.json").write_text(json.dumps({"status": "running"})) (handoff / "issue-7.md").write_text("## 次にやること\n- 別の作業\n") - assert cli._owner_restart(tmp_path, "p1", "qa") is None and cli._owner_restart(tmp_path, "p1", "qb") is None + assert pa.owner_restart(tmp_path, "p1", "qa") is None and pa.owner_restart(tmp_path, "p1", "qb") is None (loop / "loop.json").write_text(json.dumps({"status": "stopped", "stop": {"condition": "waiting_only"}})) (handoff / "issue-7.md").write_text("## 次にやること\n- 依頼 qb の答えを読んで続ける\n") - assert cli._owner_restart(tmp_path, "p1", "qa") == f"python3 {prun.RELAY} restart la" - assert cli._owner_restart(tmp_path, "p1", "qb") == f"python3 {prun.RELAY} restart lb" + assert pa.owner_restart(tmp_path, "p1", "qa") == f"python3 {prun.RELAY} restart la" + assert pa.owner_restart(tmp_path, "p1", "qb") == f"python3 {prun.RELAY} restart lb" items.append({"id": "qh", "kind": "ask", "lane": "p1-rel1"}) # 記録を持たない一時レーンはラッパーが止まっているかで決める - assert cli._owner_restart(tmp_path, "p1", "qh") == f"python3 {prun.RELAY} restart p1-rel1" + assert pa.owner_restart(tmp_path, "p1", "qh") == f"python3 {prun.RELAY} restart p1-rel1" # 同じ原因としてまとめた問いは、merged の持ち主もそれぞれの依頼の id で調べて起こす(異常系 5) items.append({"id": "qm", "kind": "ask", "lane": "la", "merged": [{"id": "qb", "lane": "lb"}]}) - assert cli._owner_restart(tmp_path, "p1", "qm") == f"python3 {prun.RELAY} restart la; python3 {prun.RELAY} restart lb" + assert pa.owner_restart(tmp_path, "p1", "qm") == f"python3 {prun.RELAY} restart la; python3 {prun.RELAY} restart lb" def test_a_helper_ends_by_stopping_its_own_wrapper(): diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 3bf29f131..27d3c7d5a 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -113,6 +113,40 @@ CI の失敗・衝突・版数の衝突は、配布のプランの直しの手 承認ゲート 1・承認ゲート 2・承認ゲートの外の問いは、どのレーンでもオーガナイザーへ送られる([asking.md](asking.md) の「並列の計画のレーン」)。 +## 外のオーガナイザーのレーン(`NDF_ORGANIZER_DIR`) + +環境変数 `NDF_ORGANIZER_DIR` があるセッションは、外のオーガナイザー(サービスが実行ジョブの間で動かすまとめ役など)のレーンである。 +名前は `NDF_ORGANIZER_LANE` で、依頼と答えは置き場のファイルで受け渡す(形は `contracts/forms/parallel-external.json`)。 +並列の計画(`.ndf/parallel/`)は使わず、計画名の代わりに `--external` を付ける。 + +| 点 | 決まり | +| --- | --- | +| 本番のステージ | 配布の依頼のプラン(`parallel.py request --external`)になる。依頼を `requests/.json` へ書いて 10 で終わる。自分の Pull Request がすでに `main` にあれば書かずに 0 で終わる | +| 答え | `parallel.py await --external --from <プランの状態ディレクトリ>/work/request.txt` が `answers/.json` を読む。ループのレーンは周回の境で読む(上の「レーンの側」と同じ) | +| 問い | 設計の承認と承認ゲートの外の問いは依頼にしない。今どおり止まって人へ戻す | +| 1 台の中のオーガナイザー | 起動しない。`NDF_FACTORY_RUN` がある実行ジョブでは `parallel.py init`・`start`・`run` が 1 で断る | + +### 配布の実行(`--parallel-helper --external`) + +外のオーガナイザーがまとめた配布 1 つのために起こすセッションは、次の 1 行で始める。`batch.json` の手(`step`)ごとに 1 手を打って終える。 + +```text +/goal /ndf:development-workflow --parallel-helper --external -- 終わり: 作業の報告を出したとき +``` + +1. `python3 "$NDF_SCRIPTS/parallel.py" compose --external` を打つ。`metrics.pipeline` が流すものを決める +2. `metrics.pipeline` を流す(待ちは背景で打ち、完了の通知を待つ) + +| `step` | 流すもの | 終わり方 | +| --- | --- | --- | +| `present` / `represent` | 提示 1(`represent` は提示 2)の提示のプラン → 本番のプラン | 提示のプランが承認ゲート 2 で止まったら `metrics.record` の 1 行で提示を書き返し、「判断が要る」で終える。流れ切れば手順 3 | +| `confirm:` | 答えの記録 → 提示 k のプランを `approve` から → 提示 k の本番のプラン | 手順 3。確定しない提示の本番のプランは流さない | +| `continue` | 止まった本番のプランを `--resume` で続ける | 手順 3 か、直せない失敗を問いにして止まる | + +3. `metrics.settle` の 1 行(`parallel.py settle --external <連番> --done `)を打つ。届いた段と結果を置き場へ書き、作業の報告で終える + +CI の失敗・衝突・版数の衝突は一時レーンと同じく配布のプランの直しの手順で直す。直せない失敗は「判断が要る」で止まる。 + ## 止める `python3 "$NDF_SCRIPTS/parallel.py" stop p1` で計画の全レーンへ停止を打ち、耐久ワークフローを終える。配布の途中なら、配布が終わる