From d52747d331a0cdcd2c2fdd0f9312cb7391654dd8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 12:31:50 +0000 Subject: [PATCH 001/129] =?UTF-8?q?Docs:=20ndf-factory-dev-loops.md=20?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/glossary.md | 12 +- docs/glossary/glossary.json | 12 +- docs/specifications/README.md | 1 + docs/specifications/ndf-factory-dev-loops.md | 563 +++++++++++++++++++ issues/issue-2347-design-contracts.md | 165 ------ issues/issue-2347-design-decisions.md | 219 -------- issues/issue-2347-design.md | 502 ----------------- issues/issue-2347-requirements.md | 294 ---------- 8 files changed, 576 insertions(+), 1192 deletions(-) create mode 100644 docs/specifications/ndf-factory-dev-loops.md delete mode 100644 issues/issue-2347-design-contracts.md delete mode 100644 issues/issue-2347-design-decisions.md delete mode 100644 issues/issue-2347-design.md delete mode 100644 issues/issue-2347-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index 80d6456a2..267354e16 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -888,12 +888,12 @@ NDF を Web UI から操作できるサービスとして提供するときの | 工程の記録 | — | スプリントの詳細に並ぶ step.recorded の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる(#2172) | — | — | — | | 工程の中身 | — | 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す(#2172) | — | — | — | | スプリント全体の欄 | — | スプリントの詳細で、課題の番号を持たない工程の記録を並べる欄(#2172) | — | — | — | -| 区間 | — | 1 つの実行ジョブの中でランナーが起動する claude -p の 1 回分。conductor の ndf-next で次の区間へ移る | — | — | — | -| 区間の上限 | — | 1 つの実行ジョブで起動してよい区間の数の上限(既定 20)。超えたジョブは section_limit で終わる | — | — | — | -| 同時のジョブの数 | `max_jobs` | 手元のランナーが同時に動かす実行ジョブの数の上限。ランナーが登録と心拍で申告し、サービスはそれを超えて指示を渡さない | — | — | — | -| マイルストーンの持ち分 | — | 終わりの状態でない開発ループが、リポジトリのマイルストーン 1 つを選定の対象として占めていることを表す実行の枠の行。同じリポジトリのループどうしで重ならない | — | — | — | -| 課題の持ち分 | — | 終わりの状態でない手動のスプリントの実行が、リポジトリの課題 1 つを持っていることを表す実行の枠の行。同じ課題を 2 つの実行に入れない | — | — | — | -| 手元のランナーの枠 | — | 1 つの手元のランナーが running で持つ local の実行ジョブの数を数えた実行の枠の行。同時のジョブの数を超えて受け取らせない | — | — | — | +| 区間 | — | 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` | +| マイルストーンの持ち分 | — | 終わりの状態でない開発ループが、リポジトリのマイルストーン 1 つを選定の対象として占めていることを表す実行の枠の行。同じリポジトリのループどうしで重ならない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | +| 課題の持ち分 | — | 終わりの状態でない手動のスプリントの実行が、リポジトリの課題 1 つを持っていることを表す実行の枠の行。同じ課題を 2 つの実行に入れない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | +| 手元のランナーの枠 | — | 1 つの手元のランナーが running で持つ local の実行ジョブの数を数えた実行の枠の行。同時のジョブの数を超えて受け取らせない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 画面の地図 | — | Factory の全画面の経路・役割ごとの主な動線・確定仕様の機能との対応を 1 つにまとめた表 | — | — | — | | デザインのトークン | — | 色・文字・余白の値に名前を付け、画面の部品がその名前で参照する値の集合 | — | — | — | | ダミーの API クライアント | — | 画面が呼ぶ API クライアントのインタフェースを満たし、決まったデータを返す実装。本物の API クライアントと差し替えられる | — | — | — | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index dc23eac77..6168dd233 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4956,38 +4956,38 @@ "term": "区間", "context": "ndf-factory", "meaning": "1 つの実行ジョブの中でランナーが起動する claude -p の 1 回分。conductor の ndf-next で次の区間へ移る", - "pending_source": "issues/issue-2347-requirements.md" + "source": "docs/specifications/ndf-factory-dev-loops.md" }, { "term": "区間の上限", "context": "ndf-factory", "meaning": "1 つの実行ジョブで起動してよい区間の数の上限(既定 20)。超えたジョブは section_limit で終わる", - "pending_source": "issues/issue-2347-requirements.md" + "source": "docs/specifications/ndf-factory-dev-loops.md" }, { "term": "同時のジョブの数", "context": "ndf-factory", "code": "max_jobs", "meaning": "手元のランナーが同時に動かす実行ジョブの数の上限。ランナーが登録と心拍で申告し、サービスはそれを超えて指示を渡さない", - "pending_source": "issues/issue-2347-requirements.md" + "source": "docs/specifications/ndf-factory-dev-loops.md" }, { "term": "マイルストーンの持ち分", "context": "ndf-factory", "meaning": "終わりの状態でない開発ループが、リポジトリのマイルストーン 1 つを選定の対象として占めていることを表す実行の枠の行。同じリポジトリのループどうしで重ならない", - "pending_source": "issues/issue-2347-design.md" + "source": "docs/specifications/ndf-factory-dev-loops.md" }, { "term": "課題の持ち分", "context": "ndf-factory", "meaning": "終わりの状態でない手動のスプリントの実行が、リポジトリの課題 1 つを持っていることを表す実行の枠の行。同じ課題を 2 つの実行に入れない", - "pending_source": "issues/issue-2347-design.md" + "source": "docs/specifications/ndf-factory-dev-loops.md" }, { "term": "手元のランナーの枠", "context": "ndf-factory", "meaning": "1 つの手元のランナーが running で持つ local の実行ジョブの数を数えた実行の枠の行。同時のジョブの数を超えて受け取らせない", - "pending_source": "issues/issue-2347-design.md" + "source": "docs/specifications/ndf-factory-dev-loops.md" }, { "term": "画面の地図", diff --git a/docs/specifications/README.md b/docs/specifications/README.md index 21bf2906f..d9e601112 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -119,5 +119,6 @@ | [ndf-entrypoint-contracts.md](ndf-entrypoint-contracts.md) | NDF のエントリポイントの契約(plugins/ndf/contracts/ の契約の一覧・構造チェック check-ndf-contracts.py と例外リストのラチェット・.ndf/ の設定を読む木を lib/decl.py で解くこと・本番のブランチとテストのコマンドの 1 か所化と移行の読み。#2336) | | [ndf-worker-stage-skills.md](ndf-worker-stage-skills.md) | プランの worker と ndf:worker へ、ステップの工程に要る規則の Skill だけを届ける仕組み。Skill の対応表(stage-skills.json)と規則の Skill の一覧、プランの skills・skills_add 欄と流す前の検査、本文を system プロンプトへ入れる渡し方と起動指示の「読む Skill」、進捗ログの kind: skills の行、stage-skills.py lookup。規約は supervise_lib/plan.py と agent-layers.md が正 | | [ndf-supervise-verification-mark-after-ok.md](ndf-supervise-verification-mark-after-ok.md) | まとめのプランで「リリース後テスト」の進行の印を、記録を投稿したステップが ok で終わった後に付けること(STAGES_AFTER_OK・record_stage の after_ok・release-verify の worker は進捗記録を打たない)、ほかの工程は始めに付けること、印と記録が食い違いうる経路(#2288・#2356)。プランの外の印の時点は release-verification が正 | +| [ndf-factory-dev-loops.md](ndf-factory-dev-loops.md) | Neural Development Factory: 開発ループ(集約・状態の写し方・周回の開始)・同じリポジトリで並行する実行の取り合い(課題・マイルストーンの持ち分・NDF の版・同時の数)・移行の埋め戻し・ランナーの区間と ndf-next・形態 local の利用上限・手元のランナーの同時のジョブの数・loops.yaml の経路。#2347 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-factory-dev-loops.md b/docs/specifications/ndf-factory-dev-loops.md new file mode 100644 index 000000000..a0839b3e7 --- /dev/null +++ b/docs/specifications/ndf-factory-dev-loops.md @@ -0,0 +1,563 @@ +# Neural Development Factory: 開発ループと並行する実行の調整・ジョブの中の区間・利用上限・手元のランナーの同時のジョブの数 + +Neural Development Factory の利用者は、リポジトリごとに方針の違う開発ループを Web から始め・止め・予算を変え、一覧と詳細で周回と止まった理由を見る。同じリポジトリで並行する実行どうしの取り合い(課題・マイルストーン・NDF の版・同時の数)と、手元のランナーの同時のジョブの数は機械が守る。ランナーは 1 つの実行ジョブの中で `ndf-next` の区間をつなぎ、手元の relay.py と同じく続ける。 + +## 概要 + +- **実行をまたぐ制御は API と見回りに、ジョブの中の区切りはランナーに置く。** 次の周回は前の実行の終わり(停止の知らせ)と 5 分ごとの見回りで起こし、常駐のプロセスを持たない。常駐を置くと、ループが 1 本も動かないアカウントにも費用が掛かるためである +- **周回 1 回がスプリントの実行 1 本である。** ランナーは周回が締まった区間の後に次の区間を起動せず、次の周回は実行の制御が新しい実行で起こす。並行する実行の取り合いは実行を起こす時点でしか照らせないためである +- **課題の選定と周回の締めはランナーの中の NDF(`/ndf:development-workflow --loop`・`sprint-state.py loop`)が行う。** Factory は選定の規則を持たない +- 既存の手動のスプリントの実行(`loopId` が空)の振る舞いは変わらない + +## 用語 + +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`・`ndf-workflow`)。この文書で使う語は「開発ループ」「周回」「マイルストーンの持ち分」「課題の持ち分」「手元のランナーの枠」「区間」「区間の上限」「同時のジョブの数」「人の判断待ち」「リセット時刻」である。 + +## コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| 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 の契約の一覧が公開された言語として宣言し、ランナーは形を写す。 + +## 構成要素 + +| 要素 | 場所 | 責務 | +| --- | --- | --- | +| ループの制御 | `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/backfill.ts` | 既存の終わりの状態でない実行を古い版として `legacyCount` と課題の持ち分へ写し、移行の済みの行を書く | +| 上限の判定 | `factory/api/src/accounts/limits.ts`(`judgeLimits`) | 理由 `concurrent_repo_runs`(リポジトリの枠の数と登録の宣言) | +| 取り込み | `factory/api/src/repositories/service.ts` | 登録の `maxConcurrentRuns` の宣言 | +| GitHub の口 | `factory/api/src/github/port.ts`・`app.ts` | 課題の確かめの応答にマイルストーンの題を返す | +| 進捗の記録の読み出し | `factory/api/src/progress/read.ts`(`ProgressReader`) | ジョブの `loop.lap_closed`・`loop.stopped` と、ループの詳細の周回の行を読む | +| 画面向けの経路 | `factory/api/src/routes/factory.ts` | ループの 7 経路・登録の宣言の経路・開始の 409 | +| ランナー向けの経路 | `factory/api/src/runs/runner-api.ts` | ループの実行の状態の置き場の鍵を `loops//ndf-state.tar.gz` にする | +| ランナーの区間 | `factory/runner/src/sections.ts` | 区間をつなぐ繰り返し・区間の上限・周回の締めの検出・利用上限の検出 | +| `ndf-next` の読み方 | `factory/runner/src/next-block.ts` | 応答の文からブロックと「起動するか」を読む | +| ループの記録の翻訳 | `factory/runner/src/loop-file.ts` | `loop.json` を読み、`loop.lap_closed`・`loop.stopped` と答えの書き込みの要否を作る | +| 同時のジョブの数の目安 | `factory/runner/src/capacity.ts` | CPU の数とメモリから目安を出し、申告と比べる | +| ランナーの手順 | `factory/runner/src/main.ts`・`runtime.ts`・`state.ts`・`translate.ts` | `START=loop` の手順、答えの書き込み、`NDF_LOOP_EXCLUDE` の受け渡し、状態の `.ndf/handoff/loop-*`、`base_conflict` の翻訳 | +| 契約 | `factory/contracts/schemas/*.json`・`openapi/runs.yaml`・`openapi/loops.yaml`・`schemas/local-runner-declaration.schema.json` | 下の「入出力の契約」 | +| 画面 | `factory/web/src/pages/LoopsPage.tsx`・`LoopDetailPage.tsx`・`RepositoriesPage.tsx` | ループの一覧と始める・詳細と操作・同時の実行の数の宣言 | +| NDF の受け渡しの形 | `plugins/ndf/contracts/forms/ndf-next-block.json`・`scripts/contracts_lib/model.py` | `ndf-next` のブロックの読み方と例の組。構造チェックが形を確かめる | +| NDF のループの除外 | `plugins/ndf/scripts/lib/sprint_loop_select.py`・`plugins/ndf/contracts/env/NDF_LOOP_EXCLUDE.json` | `loop select` が環境変数の課題をその周回の除外へ足す | + +```mermaid +graph TD + subgraph sg1["画面(factory/web)"] + LP[ループの一覧] + LD[ループの詳細] + end + subgraph sg2["API(factory/api)"] + RT[画面向けの経路] + REPO[取り込み] + GH[GitHub の口] + LC[ループの制御] + RC[実行の制御] + LIM[上限の判定] + TBL[実行の表] + READ[進捗の記録の読み出し] + RAPI[ランナー向けの経路] + SW[停止の知らせと見回り] + end + subgraph sg3["実行ジョブ(ECS か手元)"] + MAIN[ランナーの手順] + SEC[ランナーの区間] + NB[ndf-next の読み方] + LF[ループの記録の翻訳] + CAP[同時のジョブの数の目安] + end + NDF["NDF(sprint-state.py loop・契約の一覧)"] + ING[進捗の受け口] + LP --> RT + LD --> RT + RT --> LC + RT --> REPO + RT --> RC + RC --> GH + LC --> RC + LC --> TBL + RC --> LIM + RC --> TBL + RC --> READ + SW --> RC + RC --> LC + MAIN --> SEC + SEC --> NB + SEC --> LF + MAIN --> NDF + LF --> NDF + MAIN --> RAPI + MAIN --> ING + CAP -.->|手元のランナーの登録と心拍| RC + READ --> ING +``` + +ループの制御は既存の API の handler と、停止の知らせと見回りの Lambda の中で動く。手元のランナーは利用者の環境で、`max_jobs` の数までジョブを別の作業場所で並べて動かす。 + +## 仕様 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | 値オブジェクト | +| --- | --- | --- | --- | +| 開発ループ | ループの制御(`LoopControl`) | ループ | 名前・選定の対象のマイルストーン・予算・状態・待ちの理由・止まった理由・人の判断待ちの写し・答え・締めた周回の和 | +| スプリントの実行 | 実行の制御 | スプリントの実行(エンティティ: 実行ジョブ) | 属するループ・周回の番号・終わりの理由・再開の時刻 | +| 実行の枠 | 実行の制御 | アカウントの枠 | リポジトリの枠の古い版の数・マイルストーンの持ち分・課題の持ち分・手元のランナーの枠 | +| リポジトリの登録 | 取り込み | 登録 | 同時の実行の数の宣言 | + +- ほかの集約は識別子で参照する。ループは登録を `repositoryId`、今の実行を `currentRunId` で指し、実行はループを `loopId` で指す +- 1 つの処理が書き換える集約は 1 つで、例外は「開発ループ・スプリントの実行・実行の枠」の 3 つを 1 つのトランザクションで書く周回の開始・実行の終わりへの移り・ループの取り消しである。ループの「今の実行」と実行の作成を別のトランザクションにすると、間で止まったときに見回りがもう 1 本を起こすためである。実行の制御は停止の後の処理でループの写しを同じトランザクションへ入れるが、書く中身はループの制御の関数が組む +- 持ち分と手元のランナーの枠は実行の枠の行である。取り合いの条件を、実行と同じトランザクションの条件つきの書き込みで守るためである(読んでから書くと同時の 2 本がどちらも通る) +- DynamoDB のトランザクションは 100 件まで書け、最も多い手動の開始(実行 1・ジョブ 1・枠 2・課題の持ち分 20・マイルストーンの持ち分の照合が題ごとに 1 件で多くて 20 の 44 件)でも足りる + +### 常に成り立つ条件 + +| # | 集約 | 条件 | 破れそうなときの扱い | +| --- | --- | --- | --- | +| I44 | 開発ループ | 1 つのループで終わりの状態でないスプリントの実行は 1 つまで(`currentRunId`) | 周回の開始は `currentRunId` が空で `lapRuns` が読んだ値のときだけ書く。条件で落ちれば何もしない | +| I45 | 実行の枠 | 同じリポジトリの終わりの状態でない 2 つのループの選定の対象のマイルストーンは重ならない | 持ち分の行を `attribute_not_exists` で書き、落ちれば 409 `milestone_in_use` | +| 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`(渡さない) | +| I51 | スプリントの実行 | 1 つの実行ジョブが起動する区間は区間の上限以下 | ランナーが次の区間を起動せず `job.finished`(`stopped`・`section_limit`)で終える | +| I52 | 開発ループ | `local` のループの次の周回は、利用者の「次の周回を始める」の操作か、設定 `LOCAL_LOOP_AUTOSTART=true` のときだけ起こる | 自動の起動は `waitReason` が `user_start` のループを飛ばす | +| I53 | 開発ループ | ループの状態は、属する実行の終わり方と `loop.stopped` の条件から、下の「ループの状態の写し方」の表のとおりにだけ決まる | 表に無い組は `stopped`(`run_failed`)にする | +| I54 | スプリントの実行 | `usage_limit` で終わるジョブと `awaiting_reset` の実行は、形態 `local` にだけある | ランナーは `local` 以外で利用上限を検出しない。制御は `local` 以外の `usage_limit` を `failed` として扱う | +| I55 | ランナー | ランナーと NDF の relay.py は、同じ応答の文から同じ `ndf-next` のブロックと同じ「起動するか」を読む | 契約の一覧の例の組を両方のテストが読み、どちらかが違えば落ちる | + +I8・I9・I32〜I43 は [スプリントの実行と実行ジョブの状態](ndf-factory-runs-state-and-data.md) と [リポジトリの登録・実行の制御・ランナー](ndf-factory-runs-and-runner.md) のまま変わらない。 + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 開発ループを始めた | 実行の制御(画面の操作) | 実行の制御(E2) | +| E2 | 周回の実行を起こした(`START=loop`) | 実行の制御(`startNextLap`) | ランナー・実行の枠 | +| E3 | 周回の課題を選んだ | ランナーの中の NDF(`loop select`) | ランナー(`phase.changed` の課題で画面へ) | +| E4 | ジョブの中で次の区間を起動した(`section.started`) | ランナー | 進捗の受け口 → 画面の詳細 | +| E5 | 承認ゲート 2 で止まった | ランナー(`gate.reached`・`loop.stopped`(`gate2_returned`)) | 承認の依頼・実行の制御(ループを `awaiting_approval`) | +| E6 | 人の判断待ちの課題ができた | ランナー(`loop.lap_closed`・`loop.stopped` の `pending`) | 実行の制御(ループの写し)→ 画面の詳細・知らせ | +| E7 | ループが止まった(`loop.stopped`) | ランナー | 実行の制御(停止の後の処理で読む) | +| E8 | 周回の実行が終わった | 実行の制御(停止の後の処理) | 実行の制御(`startNextLap`・待っているループを起こす) | +| E9 | 取り込みで衝突を解けなかった(`attention`・`base_conflict`) | ランナー(NDF の `progress.jsonl` の行の翻訳) | 画面・知らせ | +| E10 | 次の周回を始める操作を待つ(`local`) | 実行の制御(E8 の後) | 画面(一覧の待ちの理由)・知らせ | +| E11 | 利用上限でジョブを終えた(`usage_limit`) | ランナー | 実行の制御(実行を `awaiting_reset`) | +| E12 | リセット時刻の後に再開を起こした | 実行の制御(見回り) | ランナー(`START=resume`) | +| E13 | 利用者がループを止めた(`cancelled`) | 実行の制御(画面の操作) | 実行の制御(`cancelSprintRun` と同じ形で今の実行を止める) | +| E14 | 手元のランナーが同時のジョブの数を申告した | 手元のランナー(登録と心拍) | 実行の制御(`claimLocalRunJob` の条件) | + +### ループの状態 + +```mermaid +stateDiagram-v2 + [*] --> running: 始めた(最初の周回が通った) + [*] --> between_laps: 始めた(枠・版で待つ) + running --> between_laps: 実行が done で loop.stopped が無い + running --> awaiting_approval: 実行が awaiting_approval + running --> stopped: loop.stopped(gate2_returned 以外)・実行が failed / stopped + running --> cancelled: 止めた(実行の停止の後) + awaiting_approval --> running: 答え(承認か差し戻し)で再開した + awaiting_approval --> cancelled: 止めた + between_laps --> running: 周回の開始が通った + between_laps --> stopped: 周回の開始が断られた(月の token・キー・導入) + between_laps --> cancelled: 止めた + stopped --> [*] + cancelled --> [*] +``` + +利用上限の `awaiting_reset` の間、ループは `running` のままである(周回は進行中)。 + +**ループの状態の写し方**(I53): + +| 実行の終わり方 | `loop.stopped` | ループ | 止まった理由 | +| --- | --- | --- | --- | +| `awaiting_approval` | `gate2_returned` か無し | `awaiting_approval` | — | +| `awaiting_reset` | — | `running` のまま | — | +| `done` | 無し | `between_laps` → `startNextLap` | — | +| `done` か `stopped` | `gate2_returned` 以外の条件 | `stopped` | 条件と `detail` | +| `failed` | 無し | `stopped` | `run_failed`(実行の `failureReason` を `detail` に) | +| `stopped` | 無し | `stopped` | `run_stopped`(`finishReason` を `detail` に) | +| `cancelled` | — | `cancelled` | — | + +- `loop.stopped` の条件は NDF の止まる条件の 10 個(`gate2_returned`・`user_stop`・`mvv_changed`・`same_failure`・`budget_laps`・`budget_usd`・`budget_hours`・`no_issue`・`outside_mvv`・`refused`)である。`gate2_returned` だけが周回の途中で止まる条件で、`awaiting_approval` へ写し、答えで `resume` が続く +- NDF が周回を失敗で締めた場合(`loop lap --failed`)はジョブが `done` で終わり、`same_failure` の数え方は NDF に残る。ジョブそのものが落ちた(`failed`)ときは NDF が周回を締めておらず、起こし続けると同じ失敗を数えないまま繰り返すため、ループを止める +- **止まった・取り消したループは終わりで、戻る遷移を持たない。** 続けたい利用者は同じマイルストーンで新しいループを始める(持ち分は止まった時点で消えている)。戻る遷移を持つと、どの条件が戻れるかの表が要るためである +- **ループを止めるときに NDF の `loop stop` を打たない。** 動いている実行は今の取り消し(`cancelSprintRun`)で止まり、ランナーは `SIGTERM` で何も送らずに終わる。取り消したループには次のジョブが起きず、止める印を書いても読む者がいないため、知らせはループの `cancelled` そのものとする +- 予算の変更は終わりの状態でないループにだけ効き、次のジョブの指示から NDF の `--budget-*` へ渡る + +### 周回の開始(`startNextLap`) + +```mermaid +sequenceDiagram + participant S as 呼ぶ側(始める・停止の後・見回り・操作) + participant L as ループの制御 + participant C as 実行の制御 + participant T as 実行の表 + S->>L: startNextLap(loopId) + L->>T: ループを読む(between_laps・currentRunId 空) + alt local で自動が許されず、操作でない + L->>T: waitReason = user_start + else + L->>C: 導入・キー・NDF の版を決める + C->>T: 枠・リポジトリの枠・課題の持ち分を読む + C->>C: judgeLimits(concurrent_* / monthly_tokens)と版の照合 + alt 同時の数か古い版で断られた + L->>T: waitReason = slots / legacy_version(GSI へ) + else 月の token・キー・導入で断られた + L->>T: stopped(limits_exceeded / launch_refused)・持ち分を消す + else 許された + L->>T: 1 回のトランザクション: ループ(条件 lapRuns・currentRunId 空)・実行・ジョブ・枠 + alt 条件で落ちた + L->>L: 読み直して 3 回まで。ループが変わっていれば何もしない + else + L->>C: launch(ECS か渡す口) + end + end + end +``` + +- **`EXCLUDE_ISSUES` は、トランザクションの直前に強い整合(`ConsistentRead`)で読んだ同じリポジトリの課題の持ち分の番号である。** 読みと手動の開始の間は、マイルストーンの持ち分の行で直列になる。ループの作成がマイルストーンの持ち分を書いた後に通る手動の開始はその行の照合で落ち、作成より前に通った手動の開始の持ち分はこの読みに必ず現れる +- **起こすのは 4 か所だけである**: 始めた直後・停止の後の処理(そのループ)・停止の後の処理で枠が減ったとき(同じアカウントの待っているループを `wakeWaitingLoops` が古い順に 1 本ずつ)・5 分ごとの見回り(`WAITING_LOOPS` をすべて)。`local` の `user_start` は操作だけが起こす(I52) +- **`local` の次の周回と人の判断待ちは、承認の依頼にしない。** 承認の依頼の種類はゲート 1・2 に限られ、サービスの側で承認を足さないためである。`local` のループは `between_laps`(`waitReason: user_start`)で待ち、一覧の行と詳細に「次の周回を始める」を出す。人の判断待ちの答えはループの行に持ち、次のジョブが `loop answer` で書く(今の実行を再開しない) + +### 課題とマイルストーンの取り合い + +- 手動の開始は課題ごとに、ループの開始はマイルストーンごとに持ち分の行を `attribute_not_exists` で書き、実行・ループと同じトランザクションで消す。ループの周回が選ぶ課題は持ち分の行を書かない。ループどうしはマイルストーンで分かれ、手動の実行とは `EXCLUDE_ISSUES` と `issue_in_loop` で分かれる +- 手動の開始は、動いているループのマイルストーンに属する課題を断る(409 `issue_in_loop`)。マイルストーンをループに持たせた以上、外から課題を差すと周回の選定と同じ課題を取り合うためである +- 手動の開始のトランザクションは、課題の属するマイルストーンの持ち分の行の照合(`ConditionCheck`)を、課題ごとでなく題ごとに重複を除いて 1 件にする。DynamoDB のトランザクションは同じ項目への 2 つの操作を `ValidationException` で拒むためである。マイルストーンの無い課題は照合の行を持たない +- **開始の確かめの順**(既存の 8 段のうち): 3 の課題の確かめでマイルストーンの題を引き、`issue_in_loop` を 3 の直後に断る。`issue_in_use` と `parallel_unsupported_version` は 8 のトランザクションの条件で落ち、読み直して理由を返す。8 のトランザクションは題ごとのマイルストーンの持ち分の照合も持ち、3 の直後の照合を通った後に作られたループとの取り合いで落ちれば 409 `issue_in_loop` を返す +- **ループの開始**は、登録の確かめ・名前の重なり・マイルストーンの持ち分と、ループの行を 1 回のトランザクションで書き、続けて `startNextLap` を呼ぶ。最初の周回が枠で待つときも 201(`between_laps`)を返す +- 選定から外す課題は、NDF の `loop select` が環境変数 `NDF_LOOP_EXCLUDE` の番号をその周回の除外(`excluded`、理由「別の実行が持つ課題」)へ足して受ける。`loop exclude` は選定の後にしか打てず、ランナーはその間に割り込めないためである。手元の relay.py で複数のループを並べるときも同じ口を使える + +### NDF の版の照合 + +- リポジトリの枠の `legacyCount` に、並行を許す版の下限より古い版の終わりの状態でない実行の数を持つ。古い版の実行は `count` が 0 のとき、新しい版の実行は `legacyCount` が 0 のときだけ書く +- 比べるのは版の `@` より前を semver として読んだ値で、開発版の接尾辞は semver の前置きの版として小さく読む。下限は設定 `NDF_PARALLEL_MIN_VERSION` が持ち(コードに埋め込まない)、無ければすべての版を古い版として扱い、同じリポジトリの実行を 1 本に絞る +- 実行の行を読んで版を数えない。読んでから書く間に別の開始が通るためである + +### 停止の後の処理(`finalize`) + +既存の順(`job.finished` を読む → 実行と枠を移す → 最後にジョブを終わりへ)は変わらず、実行と枠を移すトランザクションに次が入る。 + +| 書くもの | いつ | +| --- | --- | +| 実行を `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` がある) | +| ループの写し(上の表)・`lapsClosed` などの和・`pending`・持ち分の削除 | 実行が `loopId` を持つ。`loop.lap_closed` と `loop.stopped` をジョブの冪等の鍵の範囲で読む | + +トランザクションが通った後に、ループが `between_laps` なら `startNextLap`、枠が減ったなら `wakeWaitingLoops` を呼ぶ。どちらも落ちてよく、次の見回りが同じ結果で起こし直す。 + +### 利用上限(形態 `local`) + +- `local` で利用上限に当たったジョブは `job.finished`(`stopped`・`usage_limit`・`resets_at`)で終え、実行は `awaiting_reset` になる。`awaiting_reset` は終わりの状態でなく、枠を数え、承認待ちと同じく今のジョブは空である +- 見回りがリセット時刻を過ぎた実行に `START=resume` のジョブを作る。`APPROVAL_REQUEST_ID` の無い `resume` は答えを書かずに続け、ランナーは conductor へ「前回の続きから進めてください。」だけを渡す。新しい `START` の値を足さないのは、状態の復元と conductor の起動が `resume` と同じで、違いが答えの有無だけのためである +- リセット時刻を読めないときの待ちは `USAGE_LIMIT_RETRY_SECONDS`(既定 900 秒)である。NDF の `supervise_lib/claude.py` の `LIMIT_RETRY` と同じ値にし、同じ利用者の機械で同じ上限に当たったときの振る舞いを揃える +- 検出は NDF と同じ文言の規則(`usage limit reached|`・`resets <時刻> (<時間帯>)`)と、`result` の `is_error` で読む +- `managed`・`byok` では同じ文が来ても今の `failed` の扱いになる。アカウントの切り替えは手元で行い、サービスはアカウントを知らない +- リセット時刻の後の自動の再開は、`local` でサービスがジョブを自動で起こす唯一の例外である。次の周回の自動の起動は `LOCAL_LOOP_AUTOSTART`(既定 `false`)が許すときだけである + +### ランナーの区間 + +`START` の 3 つ(`new`・`resume`・`loop`)に共通である。 + +```mermaid +sequenceDiagram + participant M as ランナーの手順 + participant S as ランナーの区間 + participant Cl as claude -p + participant N as NDF(loop.json) + participant I as 進捗の受け口 + M->>S: 最初の指示(new・resume・loop) + loop 区間ごと + S->>I: section.started(番号・コマンド) + S->>Cl: claude -p <指示> + Cl-->>S: 最後の result + alt local で利用上限 + S->>M: usage_limit(リセット時刻) + else ループの周回が締まった・止まる条件が書かれた + S->>M: 終える + else ndf-next が 1 つで結果が判断が要るでない + alt 区間の上限に達している + S->>M: section_limit + else + S->>S: ブロックの中身を次の指示にする + end + else + S->>M: 終える(今の判定のまま) + end + end + M->>N: loop.json を読む(ループの実行だけ) + M->>I: loop.lap_closed・loop.stopped + M->>M: 状態を保存(gate・usage_limit・ループの実行) + M->>I: gate.reached(あれば)・job.finished(結果・理由・リセット時刻) +``` + +- 区間の上限は `RUNNER_SECTION_LIMIT`(既定 20)で、最初の起動を数える。20 回目の `claude -p` の後に次を起動しない。既定 20 は、手元の relay の記録 14 本(2026-10-25〜11-02)の区間の数の最大 9 の 2 倍を超え、通常のジョブを打ち切らない値である +- 周回の締めの判定は区間ごとに行う。ジョブの始めの `loop.json` の締めた周回の数より増えたか、`stop` がジョブの始めより後に書かれていれば、`ndf-next` があっても次の区間を起動しない +- 使用量(`usage.recorded`)は区間ごとに送る。打ち切りの時間はジョブの全体に掛かり、区間で延びない +- `progress.jsonl` の `attention` の行のうち `step` が `base-sync` のものは、翻訳が `reason` を `base_conflict` へ写す。ほかの行の理由は変えない。Factory の語へ変えるのは腐敗防止層の仕事で、NDF の側の理由の語は増やさない + +### `START=loop` のランナーの手順 + +既存の手順 1〜4 と 8 は変わらず、5〜7 が次になる。`START=resume` でも `LOOP_NAME` があれば 5・6 の鍵と書き込みは同じである。 + +| 手順 | 中身 | +| --- | --- | +| 5. 状態の復元 | `POST /run-jobs/state`(`get`)。鍵はループの `loops//ndf-state.tar.gz`。404 は最初の周回として空から始める。戻すのは `.ndf/tmp`・`.ndf/loop`・`.ndf/handoff/loop-.md`(追跡するファイルは断る) | +| 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 行の警告を出し、申告はそのまま送る。手元の機械の資源を知るのは手元だけで、サービスは申告を切り詰めない + +### 予算の残りと費用 + +一覧の「費用」「周回の数」「予算の残り」は `loop.lap_closed` の和(ループの行の `lapsClosed`・`closedCostUsd`・`closedSeconds`)で出す。止まる条件を判定する NDF は締めた周回の費用と秒の和を予算と比べるため、`usage.recorded` の和で出すと画面の残りと止まる判定が食い違う。今の周回の分は、詳細の画面で実行ごとの `usage.recorded` の和として別に出す。 + +### 画面 + +| 画面 | 経路 | 何をする場所か | +| --- | --- | --- | +| ループ | `/accounts/:accountId/repositories/:repositoryId/loops` | リポジトリのループの一覧・始める欄 | +| ループの詳細 | `/accounts/:accountId/loops/:loopId` | 周回・実行と区間・人の判断待ちと答え・予算の変更・止める・次の周回を始める | +| リポジトリ | 既存 | 行に「ループ」への移りと、持ち主に同時の実行の数の宣言の欄 | + +経路と役割の一覧は [画面の地図](ndf-factory-screen-map.md) にある。 + +| 画面 | 項目 | 型 | 必須 | 上限 | 満たさないときの表示 | +| --- | --- | --- | --- | --- | --- | +| ループ | 名前 | 文字列 | 必須 | 64 字・英数字と `-` `_` | 「英数字と - _ で 64 字までにしてください」 | +| ループ | マイルストーン | 開いているマイルストーンの複数選択 | 必須 | 10 件 | 「マイルストーンを 1 つ以上選んでください」 | +| ループ・詳細 | 予算(周回の数・費用(USD)・時間) | 正の数 | 任意 | — | 「0 より大きい数を入れてください」 | +| 詳細 | 答え | 承認・差し戻し、設計 PR の番号(承認のとき任意) | 必須 | — | — | +| リポジトリ | 同時の実行の数 | 整数か空 | 任意 | 1〜100 | 「1 から 100 の整数を入れてください」 | + +| 状態 | 出すもの | +| --- | --- | +| 読み込み中・403・5xx | 既存の画面と同じ | +| ループが 0 件 | 「まだループがありません」と始める欄 | +| `between_laps` | 待ちの理由: `slots`「同時に動かせる実行の空きを待っています」・`legacy_version`「並行できない版の実行が終わるのを待っています」・`user_start`「次の周回を始める」のボタン | +| `stopped` | 止まった理由(条件の名前と `detail` の要点)。`budget_*` は「新しいループで続けられます」 | +| 実行が `awaiting_reset` | 「利用上限のため <時刻(利用者の時間帯)> に再開します」 | +| 409 `milestone_in_use` | 「<題> はループ <名前> が選んでいます」 | +| 人の判断待ちが 0 件 | 節を出さない | + +権限: 一覧・詳細・操作は席を持つ人、同時の実行の数の欄は持ち主だけに出す(API も 403)。 + +## データ・設定 + +### 実行の表の行 + +実行の表(`RUNS_TABLE_NAME`)の行である。時系列の扱いは既存と同じ「今の値の上書き」で、周回ごとの課題・結果・版・費用は進捗の記録の `loop.lap_closed` が追記で持ち、ループの行の和はそこから作り直せる。 + +```mermaid +erDiagram + "リポジトリの登録" ||--o{ "開発ループ" : "持つ" + "開発ループ" ||--o{ "スプリントの実行" : "周回ごとに起こす" + "開発ループ" ||--|{ "マイルストーンの持ち分" : "占める" + "スプリントの実行" ||--o{ "課題の持ち分" : "占める" + "手元のランナーの枠" ||--o{ "実行ジョブ" : "数える" +``` + +| 行 | `pk` | `sk` | `gsi1pk` / `gsi1sk` | +| --- | --- | --- | --- | +| 開発ループ | `ACCOUNT#` | `LOOP#` | `WAITING_LOOPS` / `#`(`between_laps` で `waitReason` が `user_start` でない間だけ) | +| ループの実行(周回の開始の書き込みで足す。列は `sprintRunId`) | `ACCOUNT#` | `LOOPRUN##` | — | +| マイルストーンの持ち分 | `ACCOUNT#` | `MILESTONE##<題の sha256 の先頭 32 字>` | — | +| 課題の持ち分 | `ACCOUNT#` | `ISSUE##<番号>` | — | +| 手元のランナーの枠 | `ACCOUNT#` | `RUNNER#` | — | +| 移行の済み(表に 1 つ) | `MIGRATION` | `MIGRATED#2347` | — | + +| 行 | 列 | 型 | 空 | 意味 | +| --- | --- | --- | --- | --- | +| ループ | `loopId` | 文字列 | 不可 | 32 字(実行と同じ作り方) | +| ループ | `repositoryId`・`name` | 数・文字列 | 不可 | 属する登録と、NDF のループ名(`^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$`) | +| ループ | `milestones` | 文字列の並び | 不可 | 1〜10 件。題に `,` を含まない(NDF の `--milestones` の区切り) | +| ループ | `budget` | `{laps, usd, hours}` | 各値は可 | 空は「宣言しない(NDF の `.ndf/pace.json` の既定)」 | +| ループ | `state` | 文字列 | 不可 | `running` / `between_laps` / `awaiting_approval` / `stopped` / `cancelled`。後ろの 2 つが終わりの状態 | +| ループ | `waitReason` | 文字列 | 可 | `between_laps` の理由: `slots`(枠)・`legacy_version`・`user_start`(`local`)。空は「起こす途中」 | +| ループ | `currentRunId` | 文字列 | 可 | 終わりの状態でない実行(I44)。空は「動く実行が無い」 | +| ループ | `lapRuns` | 数 | 不可 | 起こした実行の数。周回の開始の条件(I44)と、実行の `lap` | +| ループ | `lapsClosed`・`closedCostUsd`・`closedSeconds` | 数 | 不可 | `loop.lap_closed` の和 | +| ループ | `stopCondition`・`stopDetail` | 文字列・JSON | 可 | 止まった理由。NDF の条件か、Factory の `run_failed`・`run_stopped`・`limits_exceeded`・`launch_refused` | +| ループ | `pending` | `{number, reason, lap}` の並び | 不可 | 最後に届いた人の判断待ちの写し | +| ループ | `answers` | `{number, decision, pr}` の並び | 不可 | 画面の答え。次のジョブの `LOOP_ANSWERS` に載り、`pending` から消えた番号は外す | +| ループ | `cancelRequested`・`createdBy`・`createdAt`・`updatedAt`・`lastRunAt` | — | `lastRunAt` だけ可 | 取り消しの要求と時刻(`lastRunAt` は最後の実行の `updatedAt`) | +| 持ち分 | `loopId`・`title` / `sprintRunId` | 文字列 | 不可 | 占めるループと題 / 占める実行 | +| ランナーの枠 | `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` で受け取ったランナー。手元のランナーの枠を減らす先 | +| リポジトリの枠 | `legacyCount` | 数 | 不可 | 並行を許す版の下限より古い版の、終わりの状態でない実行の数(I48)。無いのは 0 | +| 登録 | `maxConcurrentRuns` | 数 | 可 | 空は「宣言しない(プランの値)」 | + +実行の状態には `awaiting_reset` がある(終わりの状態でない)。`maxConcurrentRuns`・`loopId` などの無い行は空として読む。 + +| 機能 | ループ | 実行 | ジョブ | 枠・持ち分・ランナーの枠 | 登録 | 進捗の記録 | +| --- | --- | --- | --- | --- | --- | --- | +| 始める | C | C | C | C・U | R | — | +| 止める | U | U | — | U・D | — | R | +| 予算 | U | — | — | — | — | — | +| 次の周回 | U | C | C | U・C | R | — | +| 一覧と詳細 | R | R | — | — | — | R | +| 答える | U | — | — | — | — | — | +| 手動の開始 | R | C | C | C・U | R | — | +| 宣言 | — | — | — | — | U | — | +| 停止の後の処理 | U | U | U | U・D | — | R・C | +| 見回り(再開・待ち) | U | U | C | U・C | R | — | +| 受け取り(`local`) | — | U | U | U | — | — | + +### 移行の済みの行と埋め戻し + +並行の開始の材料(課題の持ち分・`legacyCount`)は、既存の終わりの状態でない実行から埋め戻してから使う。どの時点で止まっても I47・I48 を破らない。 + +1. 表に移行の済みの行が無い間、開始はすべての版を古い版として扱い、リポジトリの枠の `count` が 0 のときだけ書く。作成する実行は版によらず `legacy` を真にして `legacyCount` を 1 増やす(偽を書くと、この間に始めた古い版の実行が移行の後に数えられず I48 を破る) +2. 埋め戻しのコマンド(`factory/api/src/runs/backfill.ts`。何度流しても同じ結果になる)が、すべてのアカウントの `legacy` の空の終わりの状態でない実行を 1 本ずつ扱う。実行の課題の持ち分の行を強い整合で読んで他の実行が持つ課題を除き、1 回のトランザクションで実行の `legacy` を真に(条件は `attribute_not_exists(legacy)` と終わりの状態でないこと)・`legacyCount` を 1 増やし・残った課題の持ち分を `attribute_not_exists` で書く。落ちたら(`TransactionCanceledException`)その実行を読み直してやり直す +3. `legacy` の空の終わりの状態でない実行が 0 件になったら、同じコマンドが移行の済みの行を書く。開始はこの行がある間だけ `NDF_PARALLEL_MIN_VERSION` を読み、下限より古い版なら `legacy` を真にして `legacyCount` を 1 増やし、新しい版なら偽にする + +- 開始と周回の開始は、移行の済みの行をトランザクションの前に強い整合で 1 回読むだけにし、行の照合(`attribute_exists`)をトランザクションへ入れない。全アカウントで共有の 1 項目を照合に入れると、同時の開始どうしが `TransactionConflict` で取り消し合うためである。行は一度書けば消さないため、読んで「ある」なら以後もあり、「無い」なら 1 の保守側の扱いになる +- 移行の済みの行はアカウントごとに置かず表に 1 つにする。埋め戻しの後に作られたアカウントの実行はすべて作成時に `legacy` を書いており、埋め戻すものが無いためである +- 既存の実行を古い版と見なすのは、その実行の NDF の版を照合していないためである。無い値を 0 と読むのは 1 の間の `legacyCount` だけである + +### 環境変数 + +`factory/.env.example` に名前だけを置く。 + +| 名前 | 使う側 | 意味 | +| --- | --- | --- | +| `NDF_PARALLEL_MIN_VERSION` | 制御 | 並行を許す NDF の版の下限。無ければ並行を許さない | +| `LOCAL_LOOP_AUTOSTART` | 制御 | `true` のとき `local` のループも自動で次の周回を起こす。既定 `false` | +| `USAGE_LIMIT_RETRY_SECONDS` | 制御 | リセット時刻を読めないときの待ち。既定 900 | +| `RUNNER_SECTION_LIMIT` | ランナー | 区間の上限。既定 20 | + +取り込むリポジトリの選定の規則・予算の既定・止まる条件の閾値は、ランナーの中の NDF がそのリポジトリの `.ndf/pace.json` から読む。Factory が持つのは利用者が画面で入れた値(マイルストーン・予算・同時の数の宣言)と、上の設定だけである。 + +## 外部連携 + +### 実行ジョブの指示(`run-job-instruction.schema.json`) + +| 入力 | 型・必須 | 意味 | +| --- | --- | --- | +| `START` | `new` / `resume` / `loop`・必須 | `loop` は開発ループの周回を新しく始める | +| `ISSUES` | 番号の並び・`new` で必須 | `loop` では持たない | +| `APPROVAL_REQUEST_ID` | 文字列・任意 | 無い `resume` は答えを書かずに続ける(利用上限の後の再開) | +| `LOOP_NAME` | `^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$`・`loop` で必須 | NDF のループ名。ループの実行の `resume` にも載せる | +| `LOOP_MILESTONES` | 文字列・`loop` で必須 | マイルストーンの題を `,` で結ぶ | +| `LOOP_BUDGET` | `^(laps=\d+(\.\d+)?)?(,?usd=\d+(\.\d+)?)?(,?hours=\d+(\.\d+)?)?$`・任意 | 宣言した予算だけを並べる。無ければ NDF の既定 | +| `EXCLUDE_ISSUES` | 番号の並び・任意 | 同じリポジトリの課題の持ち分。無ければ外さない | +| `LOOP_ANSWERS` | `<番号>=(approved\|rejected)(#)?` を `,` で結ぶ・任意 | 人の判断待ちへの答え | + +- `START=loop` は `LOOP_NAME`・`LOOP_MILESTONES` を要り、`ISSUES` を許さない。`LOOP_*`・`EXCLUDE_ISSUES` は `loop` と、ループの実行の `resume` のほかでは許さない +- RunTask の `overrides` の 8,192 字に収めるため、マイルストーンは 10 件・各 100 字まで(ループの開始の入力で断る) + +### 進捗の事象(`progress-event.schema.json`) + +`schema_version` は 1 である。冪等の鍵は `:` で、`loop.*` はジョブの終わりに `job.finished` より前に送り、実行の制御は停止の後に同じジョブの事象として読む。 + +| 種類 | 出所 | `payload` | 受け手 | +| --- | --- | --- | --- | +| `section.started` | ランナー | `section`(1 から)・`command`(2,000 字で切る) | 画面の詳細 | +| `loop.lap_closed` | ランナー(`loop.json` の締めた周回) | `loop`・`lap`・`issues`(番号の並び)・`result`(`完了` / `失敗` / `人へ戻した`)・`version`(開発版と本番の版、無ければ `null`)・`cost_usd`・`seconds`・`pending`(`{number, reason, lap}` の並び) | 実行の制御(停止の後に読む)・画面の詳細 | +| `loop.stopped` | ランナー(`loop.json` の `stop`) | `loop`・`condition`(止まる条件の 10 個)・`detail`(`stop` の残りの値の JSON。4,000 字まで)・`pending` | 実行の制御・画面 | +| `job.started` | ランナー | `start` に `loop` を持てる | — | +| `job.finished` | ランナー | `reason`(`section_limit` / `usage_limit`、任意)・`resets_at`(ISO 8601、`usage_limit` のときだけ。読めなければ `null`) | 実行の制御 | +| `gate.reached` | ランナー | `loop`(`{name, lap}`、ループの実行だけ) | 承認の依頼の一覧 | + +利用上限の事象はリセット時刻だけを持ち、アカウントの名前を載せない。 + +### NDF の受け渡しの形(`plugins/ndf/contracts/forms/ndf-next-block.json`) + +| 鍵 | 中身 | +| --- | --- | +| `name` | `ndf-next-block` | +| `summary` | 応答の文の最後に置く、次の区間で起動するコマンド | +| `rule` | CommonMark の 3 つのバッククォートの囲みで、情報文字列を前後の空白を除いて `ndf-next` と比べる。ほかの囲みの中の囲みは拾わない。中身は前後の改行を除く。囲みが 2 つ以上なら起動しない。`- 結果:` の行の値が `判断が要る` なら起動しない | +| `writers` / `readers` | `sprint-state.py`(`ndf_next`)・conductor の Skill の本文 / `relay.py`(`relay_lib/mark.py`) | +| `cases` | `{text, blocks, launch}` の並び。囲みが 1 つ・無し・2 つ・別の囲みの中・`~~~` の囲み・情報文字列の前後の空白・`判断が要る`・`完了` を含む | + +- 規則の持ち主は 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`) + +| 鍵 | 型・必須 | 意味 | +| --- | --- | --- | +| `runner_id` | 32 字の英数字・必須 | 登録のたびに発行する | +| `max_jobs` | 1〜16 の整数・任意(無ければ 1) | 同時のジョブの数 | +| `capacity_hint` | 1 以上の整数・任意 | 目安。表示だけに使う | + +手元のランナーの登録と心拍の本文はこの形を含む。 + +### 画面向けの経路(`openapi/loops.yaml`・`openapi/runs.yaml`) + +どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。ループの経路はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found`。エラーの本文は `{"error": {"code", "message", "details"?}}`。 + +| 経路 | 誰が | 入力 | 成功 | 失敗 | +| --- | --- | --- | --- | --- | +| `POST /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | `{name, milestones, budget?: {laps?, usd?, hours?}}` | 201 `{loop_id, state}`(`running` か `between_laps`) | 400 `invalid_request`(名前・題の形、1〜10 件、予算が正の数でない)・403・404 `repository_not_found`・409 `milestone_in_use`(`details.milestones`・`details.loop_name`)・409 `loop_name_in_use`(同じ登録の終わりの状態でないループ) | +| `GET /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | — | 200 `{loops: [{loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail}]}`(新しい順 50 件) | 400・403 | +| `GET /accounts/{a}/loops/{id}` | 席を持つ人 | — | 200 `{loop, laps: [{lap, issues, result, version, cost_usd, seconds}], runs: [{sprint_run_id, lap, state, failure_reason, finish_reason, resume_at, cost_usd, sections}], pending: [{number, reason, lap, answer}]}` | 403・404 | +| `PATCH /accounts/{a}/loops/{id}` | 席を持つ人 | `{budget}` | 200 ループの 1 行 | 400・403・404・409 `already_finished` | +| `POST /accounts/{a}/loops/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、実行の停止を待つ `running`) | 403・404・409 `already_finished` | +| `POST /accounts/{a}/loops/{id}/next-lap` | 席を持つ人 | — | 202 `{state}` | 403・404・409 `not_awaiting_user_start` | +| `POST /accounts/{a}/loops/{id}/answers` | 席を持つ人 | `{issue, decision: approved \| rejected, pr?}` | 200 `{pending}` | 400・403・404・409 `not_pending`(`pending` に無い番号) | +| `PATCH /accounts/{a}/repositories/{r}`(`runs.yaml`) | 持ち主 | `{max_concurrent_runs: 1〜100 の整数か null}` | 200 登録の 1 行 | 400・403・404 | +| `POST /accounts/{a}/sprint-runs`(`runs.yaml`) | 席を持つ人 | 既存のまま | 既存のまま | 既存の失敗に加えて 409 `issue_in_use`(`details.numbers`)・409 `issue_in_loop`(`details.numbers`・`details.loop_name`)・409 `parallel_unsupported_version`、`limits_exceeded` の `reasons` に `concurrent_repo_runs` | + +## エラー処理 + +- 周回の開始・ループの写し・終わりへの移りは条件つきの 1 回のトランザクションで書き、実行の制御のどの処理が途中で止まっても、見回りか知らせの再送で同じ結果へやり直せる(1 つの周回の実行を 2 本起こさない) +- 周回の開始の条件で落ちたときは読み直して 3 回までやり直し、ループが変わっていれば何もしない +- 同時の数か古い版で断られた周回はループを `between_laps`(`slots` / `legacy_version`)で待たせ、月の token・キー・導入で断られた周回はループを `stopped`(`limits_exceeded` / `launch_refused`)にして持ち分を消す +- `loop.stopped` が届かずにジョブが終わったループは、実行の終わり方から `run_failed` か `run_stopped` で `stopped` になる + +## セキュリティ + +- サービスは `local` のアカウントと認証を知らない。利用上限の事象はリセット時刻だけを持つ +- ループの操作は、そのリポジトリの登録を持つアカウントだけが行える。ループの経路は `resolveFromRequest` の後にループの `accountId` と登録を照らし、他人は 403、無ければ 404 になる +- 同時の実行の数の宣言は持ち主だけが変えられる + +## テスト観点 + +- ループを始め・止め・予算を変える経路と、周回のジョブの指示が `START=loop`・`LOOP_NAME` を持つこと。`factory/api/src/` に選定の規則が無いこと +- 「ループの状態の写し方」の表の各行の組で、ループの状態と止まった理由が表のとおりになり、10 個の条件のどれも `loop.stopped` として受かること +- 枠が満ちたアカウントでループが `between_laps`(`slots`)で待ち、枠の行の数が実行の数と等しく、別の実行が終わると待っていたループの周回が起きること +- 重なるマイルストーンの 2 本目が 409 `milestone_in_use`、重ならない 2 本目が 201 で、同時の 2 つの開始で片方だけが通ること +- 動いている実行の課題を指す手動の開始が 409 `issue_in_use`、ループのマイルストーンの課題が 409 `issue_in_loop` になり、周回の指示の `EXCLUDE_ISSUES` に持ち分の課題が並び、NDF の `loop select` が `NDF_LOOP_EXCLUDE` の課題をその周回の除外にすること +- 下限より古い版の実行があるリポジトリで 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` で断られ、宣言が無ければプランの値で断られること +- `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` は待たないこと +- 偽の `claude` が `ndf-next` を 1 つ返すと次の区間が起き、`判断が要る`・ブロック無し・2 つでは起きず、区間ごとに `section.started` が届くこと。21 回目を起動せず `section_limit` で終わり、打ち切りの時間は区間の合計に掛かること +- 契約の一覧の例の組を NDF の pytest と Factory の vitest が読んで同じ答えを返し、`check-service-boundary.py` が通ること +- `local` の偽の `claude` が上限の文を返すと `usage_limit` で終わって実行が `awaiting_reset` になり、時刻の後の見回りで `APPROVAL_REQUEST_ID` の無い `resume` が積まれ、時刻が読めなければ 900 秒後になること。`managed`・`byok` では `failed` になること +- 契約の検査が新しい欄と種類を通して生成した型に現れ、`loop` に `ISSUES` を持つ指示が断られること +- 手動の開始・一覧・取り消し・再開の既存のテストが変えずに通ること +- 周回の開始のトランザクションの後で止めても、2 つの呼び手が同時に `startNextLap` を呼んでも、実行が 1 本だけになること + +## 関連リンク + +- [サービスの境界と全体の契約](ndf-factory-service-boundary.md) +- [リポジトリの登録・実行の制御・ランナー](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-screen-map.md) +- [NDF の入口の契約](ndf-entrypoint-contracts.md) diff --git a/issues/issue-2347-design-contracts.md b/issues/issue-2347-design-contracts.md deleted file mode 100644 index d47f35968..000000000 --- a/issues/issue-2347-design-contracts.md +++ /dev/null @@ -1,165 +0,0 @@ -# #2347 の設計: 入出力の契約・画面・テスト設計 - -設計文書の本体は [issue-2347-design.md](issue-2347-design.md) 、決定の記録は -[issue-2347-design-decisions.md](issue-2347-design-decisions.md) にある。不変条件(I44〜I55)と構成要素の名前は本体のものを使う。 - -## 入出力の契約 - -**機械可読な正は `factory/contracts/` に置く。** 実装の最初のタスクで、下の表のとおりにスキーマと OpenAPI を書き、 -`npm run lint:contracts` と生成した型を通してから API とランナーを書く。どの欄も足すだけで、既存の書き手と読み手はそのまま通る。 - -### 実行ジョブの指示(`run-job-instruction.schema.json`) - -| 入力 | 型・必須 | 意味 | -| --- | --- | --- | -| `START` | `new` / `resume` / `loop`・必須(`loop` を足す) | `loop` は開発ループの周回を新しく始める | -| `ISSUES` | 番号の並び・`new` で必須 | 変えない。`loop` では持たない | -| `APPROVAL_REQUEST_ID` | 文字列・任意(`resume` で必須を外す) | 無い `resume` は答えを書かずに続ける(決定 7) | -| `LOOP_NAME` | `^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$`・`loop` で必須 | NDF のループ名。ループの実行の `resume` にも載せる | -| `LOOP_MILESTONES` | 文字列・`loop` で必須 | マイルストーンの題を `,` で結ぶ | -| `LOOP_BUDGET` | `^(laps=\d+(\.\d+)?)?(,?usd=\d+(\.\d+)?)?(,?hours=\d+(\.\d+)?)?$`・任意 | 宣言した予算だけを並べる。無ければ NDF の既定 | -| `EXCLUDE_ISSUES` | 番号の並び・任意 | 同じリポジトリの課題の持ち分。無ければ外さない | -| `LOOP_ANSWERS` | `<番号>=(approved\|rejected)(#)?` を `,` で結ぶ・任意 | 人の判断待ちへの答え | - -- `START=loop` は `LOOP_NAME`・`LOOP_MILESTONES` を要り、`ISSUES` を許さない。`LOOP_*`・`EXCLUDE_ISSUES` は `loop` と、ループの実行の `resume` のほかでは許さない -- RunTask の `overrides` の 8,192 字に収めるため、マイルストーンは 10 件・各 100 字まで(ループの開始の入力で断る) - -### 進捗の事象(`progress-event.schema.json`) - -`schema_version` は 1 のまま(足すだけで互換を壊さない)。 - -| 種類 | 出所 | `payload` | 受け手 | -| --- | --- | --- | --- | -| `section.started`(新設) | ランナー | `section`(1 から)・`command`(2,000 字で切る) | 画面の詳細 | -| `loop.lap_closed`(新設) | ランナー(`loop.json` の締めた周回) | `loop`・`lap`・`issues`(番号の並び)・`result`(`完了` / `失敗` / `人へ戻した`)・`version`(開発版と本番の版、無ければ `null`)・`cost_usd`・`seconds`・`pending`(`{number, reason, lap}` の並び) | 実行の制御(停止の後に読む)・画面の詳細 | -| `loop.stopped`(新設) | ランナー(`loop.json` の `stop`) | `loop`・`condition`(前提 4 の 10 個)・`detail`(`stop` の残りの値の JSON。4,000 字まで)・`pending` | 実行の制御・画面 | -| `job.started`(変更) | ランナー | `start` に `loop` を足す | — | -| `job.finished`(変更) | ランナー | `reason`(`section_limit` / `usage_limit`、任意)・`resets_at`(ISO 8601、`usage_limit` のときだけ。読めなければ `null`)を足す | 実行の制御 | -| `gate.reached`(変更) | ランナー | `loop`(`{name, lap}`、ループの実行だけ)を足す | #2173 の一覧 | - -**冪等の鍵は今の形(`:`)のままである。** `loop.*` はジョブの終わりに `job.finished` より前に送り、実行の制御は -停止の後に同じジョブの事象として読む。 - -### NDF の受け渡しの形(`plugins/ndf/contracts/forms/ndf-next-block.json`) - -| 鍵 | 中身 | -| --- | --- | -| `name` | `ndf-next-block` | -| `summary` | 応答の文の最後に置く、次の区間で起動するコマンド | -| `rule` | CommonMark の 3 つのバッククォートの囲みで、情報文字列を前後の空白を除いて `ndf-next` と比べる。ほかの囲みの中の囲みは拾わない。中身は前後の改行を除く。囲みが 2 つ以上なら起動しない。`- 結果:` の行の値が `判断が要る` なら起動しない | -| `writers` / `readers` | `sprint-state.py`(`ndf_next`)・conductor の Skill の本文 / `relay.py`(`relay_lib/mark.py`) | -| `cases` | `{text, blocks, launch}` の並び。囲みが 1 つ・無し・2 つ・別の囲みの中・`~~~` の囲み・情報文字列の前後の空白・`判断が要る`・`完了` を含む | - -`scripts/contracts_lib/model.py` は `forms/*.json` を読み、上の鍵の型を確かめる(知らない鍵は今の項目と同じく断る)。 -Factory のランナーは実行時にこのファイルを読まず、テストだけが `../plugins/ndf/contracts/forms/ndf-next-block.json` を読む。 - -### 手元のランナーの申告(`local-runner-declaration.schema.json`、新設) - -| 鍵 | 型・必須 | 意味 | -| --- | --- | --- | -| `runner_id` | 32 字の英数字・必須 | 登録のたびに #2189 が発行する | -| `max_jobs` | 1〜16 の整数・任意(無ければ 1) | 同時のジョブの数 | -| `capacity_hint` | 1 以上の整数・任意 | 目安(決定 13)。表示だけに使う | - -#2189 の登録と心拍の本文はこの形を含む。`claimLocalRunJob(accountId, {runnerId, maxJobs})` は、そのランナーの手元のランナーの枠が -`maxJobs` 未満のときだけ、ジョブを `running` にする書き込みと枠を 1 増やす書き込みを 1 回のトランザクションで行う(I50)。 -引数の 2 つ目が無い呼び出しは今のまま(`max_jobs` 1、ランナーの識別子なし)として扱う。 - -### 画面向けの経路(`openapi/loops.yaml`、新設。`runs.yaml` は変更) - -どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。ループの経路はループの `accountId` が -一致しなければ 403、無ければ 404 `loop_not_found`。エラーの本文は今の形 `{"error": {"code", "message", "details"?}}`。 - -| 経路 | 誰が | 入力 | 成功 | 失敗 | -| --- | --- | --- | --- | --- | -| `POST /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | `{name, milestones, budget?: {laps?, usd?, hours?}}` | 201 `{loop_id, state}`(`running` か `between_laps`) | 400 `invalid_request`(名前・題の形、1〜10 件、予算が正の数でない)・403・404 `repository_not_found`・409 `milestone_in_use`(`details.milestones`・`details.loop_name`)・409 `loop_name_in_use`(同じ登録の終わりの状態でないループ) | -| `GET /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | — | 200 `{loops: [{loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail}]}`(新しい順 50 件) | 400・403 | -| `GET /accounts/{a}/loops/{id}` | 席を持つ人 | — | 200 `{loop, laps: [{lap, issues, result, version, cost_usd, seconds}], runs: [{sprint_run_id, lap, state, failure_reason, finish_reason, resume_at, cost_usd, sections}], pending: [{number, reason, lap, answer}]}` | 403・404 | -| `PATCH /accounts/{a}/loops/{id}` | 席を持つ人 | `{budget}` | 200 ループの 1 行 | 400・403・404・409 `already_finished` | -| `POST /accounts/{a}/loops/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、実行の停止を待つ `running`) | 403・404・409 `already_finished` | -| `POST /accounts/{a}/loops/{id}/next-lap` | 席を持つ人 | — | 202 `{state}` | 403・404・409 `not_awaiting_user_start` | -| `POST /accounts/{a}/loops/{id}/answers` | 席を持つ人 | `{issue, decision: approved \| rejected, pr?}` | 200 `{pending}` | 400・403・404・409 `not_pending`(`pending` に無い番号) | -| `PATCH /accounts/{a}/repositories/{r}`(`runs.yaml`) | 持ち主 | `{max_concurrent_runs: 1〜100 の整数か null}` | 200 登録の 1 行 | 400・403・404 | -| `POST /accounts/{a}/sprint-runs`(`runs.yaml`) | 席を持つ人 | 変えない | 変えない | 既存に 409 `issue_in_use`(`details.numbers`)・409 `issue_in_loop`(`details.numbers`・`details.loop_name`)・409 `parallel_unsupported_version`、`limits_exceeded` の `reasons` に `concurrent_repo_runs` を足す | - -**開始の確かめの順**(既存の 8 段のうち): 3 の課題の確かめでマイルストーンの題を引き、`issue_in_loop` を 3 の直後に断る。 -`issue_in_use` と `parallel_unsupported_version` は 8 のトランザクションの条件で落ち、読み直して理由を返す。 -8 のトランザクションは、3 で引いた題ごとのマイルストーンの持ち分の行の `attribute_not_exists` の照合も持ち、照合で落ちれば 409 `issue_in_loop` -(3 の直後の照合を通った後に作られたループとの取り合い。決定 11)。 - -**ループの開始**は、登録の確かめ・名前の重なり・マイルストーンの持ち分と、ループの行を 1 回のトランザクションで書き、続けて -`startNextLap` を呼ぶ。最初の周回が枠で待つときも 201(`between_laps`)を返す。 - -### 環境変数(`factory/.env.example` に名前だけを足す) - -| 名前 | 使う側 | 意味 | -| --- | --- | --- | -| `NDF_PARALLEL_MIN_VERSION` | 制御 | 並行を許す NDF の版の下限(決定 12)。無ければ並行を許さない | -| `LOCAL_LOOP_AUTOSTART` | 制御 | `true` のとき `local` のループも自動で次の周回を起こす。既定 `false` | -| `USAGE_LIMIT_RETRY_SECONDS` | 制御 | リセット時刻を読めないときの待ち。既定 900 | -| `RUNNER_SECTION_LIMIT` | ランナー | 区間の上限。既定 20 | - -## 画面 - -| 画面 | 経路 | 何をする場所か | -| --- | --- | --- | -| ループ(新設) | `/accounts/:accountId/repositories/:repositoryId/loops` | リポジトリのループの一覧・始める欄 | -| ループの詳細(新設) | `/accounts/:accountId/loops/:loopId` | 周回・実行と区間・人の判断待ちと答え・予算の変更・止める・次の周回を始める | -| リポジトリ(変更) | 既存 | 行に「ループ」への移りと、持ち主に同時の実行の数の宣言の欄 | -| 承認の依頼の一覧(#2173) | #2173 が決める | ループの実行の依頼の行に、ループの名前と周回の番号の列 | - -```mermaid -graph LR - R[リポジトリ] -->|ループ| L[ループ] - L -->|始める| L - L -->|行| D[ループの詳細] - D -->|実行| S["スプリントの詳細(#2172)"] - A["承認の依頼の一覧(#2173)"] -->|ループの名前| D -``` - -| 画面 | 項目 | 型 | 必須 | 上限 | 満たさないときの表示 | -| --- | --- | --- | --- | --- | --- | -| ループ | 名前 | 文字列 | 必須 | 64 字・英数字と `-` `_` | 「英数字と - _ で 64 字までにしてください」 | -| ループ | マイルストーン | 開いているマイルストーンの複数選択 | 必須 | 10 件 | 「マイルストーンを 1 つ以上選んでください」 | -| ループ・詳細 | 予算(周回の数・費用(USD)・時間) | 正の数 | 任意 | — | 「0 より大きい数を入れてください」 | -| 詳細 | 答え | 承認・差し戻し、設計 PR の番号(承認のとき任意) | 必須 | — | — | -| リポジトリ | 同時の実行の数 | 整数か空 | 任意 | 1〜100 | 「1 から 100 の整数を入れてください」 | - -| 状態 | 出すもの | -| --- | --- | -| 読み込み中・403・5xx | 既存の画面と同じ | -| ループが 0 件 | 「まだループがありません」と始める欄 | -| `between_laps` | 待ちの理由: `slots`「同時に動かせる実行の空きを待っています」・`legacy_version`「並行できない版の実行が終わるのを待っています」・`user_start`「次の周回を始める」のボタン | -| `stopped` | 止まった理由(条件の名前と `detail` の要点)。`budget_*` は「新しいループで続けられます」 | -| 実行が `awaiting_reset` | 「利用上限のため <時刻(利用者の時間帯)> に再開します」 | -| 409 `milestone_in_use` | 「<題> はループ <名前> が選んでいます」 | -| 人の判断待ちが 0 件 | 節を出さない | - -権限: 一覧・詳細・操作は席を持つ人、同時の実行の数の欄は持ち主だけに出す(API も 403)。 - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1 | ループを始め・止め・予算を変える経路と、周回のジョブの指示が `START=loop`・`LOOP_NAME` を持つこと。`factory/api/src/` に選定の規則(順位・マイルストーンの候補の選び方)が無いこと | 指示から `LOOP_NAME` を落とす・予算の変更を次の指示へ写さない | -| 受け入れ条件 2・I53 | 決定 4 の表の各行の組で、ループの状態と止まった理由が表のとおりになる。10 個の条件のどれも `loop.stopped` として受かる | `gate2_returned` を `stopped` へ写す・`failed` で `between_laps` にする | -| 受け入れ条件 3・I46 | 枠が満ちたアカウントでループが `between_laps`(`slots`)で待ち、枠の行の数が実行の数と等しい。別の実行が終わると待っていたループの周回が起きる | ループの待ちで枠を増やす・`wakeWaitingLoops` を呼ばない | -| 受け入れ条件 4・I45 | 重なるマイルストーンの 2 本目が 409 `milestone_in_use`(題と相手の名前)、重ならない 2 本目は 201。同時の 2 つの開始で片方だけが通る | 持ち分を条件にせず読むだけにする | -| 受け入れ条件 5・I47 | 動いている実行の課題を指す手動の開始が 409 `issue_in_use`、ループのマイルストーンの課題が 409 `issue_in_loop`。ループの周回の指示の `EXCLUDE_ISSUES` に持ち分の課題が並ぶ。NDF の `loop select` が `NDF_LOOP_EXCLUDE` の課題をその周回の除外にする | 持ち分を書かない・`EXCLUDE_ISSUES` を空で渡す・`loop select` が環境変数を読まない | -| 受け入れ条件 6・I48 | 下限より古い版の実行があるリポジトリで 2 本目が 409 `parallel_unsupported_version`。下限が設定に無いと 2 本目が断られ、下限以上の版どうしは並ぶ | `legacyCount` を条件にしない・下限をコードに置く | -| 移行・I47・I48 | 移行の済みの行が無いアカウントで、動いている既存の実行(`legacy` が空)があるリポジトリの 2 本目が下限以上の版でも 409 `parallel_unsupported_version`。埋め戻しの後に既存の実行の課題を指す手動の開始が 409 `issue_in_use`、下限以上の版の 2 本目が 409 `parallel_unsupported_version`。埋め戻しを 2 回流しても `legacyCount` が増えない。埋め戻す前に終わった実行で `legacyCount` が負にならない | 移行の済みの行が無くても下限を読む・埋め戻しが `legacy` を条件にしない・`legacy` によらず `legacyCount` を減らす | -| 受け入れ条件 7 | `step` が `base-sync` の `attention` の行が `reason: base_conflict` の事象になり、ほかの行の理由は変わらない | すべての `attention` を `base_conflict` にする | -| 受け入れ条件 8・I49 | プランの値 3 で宣言 1 のリポジトリで 2 本目が `concurrent_repo_runs` で断られる。宣言が無ければプランの値で断られる | 宣言を読まない・大きい方を使う | -| 受け入れ条件 9・I50 | `max_jobs` 1 のランナーへ 2 本目を渡さず、1 本目が終わると渡す。申告が無ければ 1 | 枠を条件にせず数えるだけにする・終わりで枠を減らさない | -| 受け入れ条件 10 | 8 コア・30 GiB で目安 6、2 コア・4 GiB で 1。目安を超える申告で標準エラーに警告の 1 行が出て、申告は変わらない | 申告を目安へ切り詰める | -| 受け入れ条件 11・12 | 一覧の列と、詳細の周回・人の判断待ち。答えると次のジョブの `LOOP_ANSWERS` に載り、ランナーが `loop answer` を打つ。答えの済んだ番号は打たない | 答えを指示へ写さない・同じ答えを 2 回打つ | -| 受け入れ条件 13 | ループの実行の `gate.reached` が `loop`(名前と周回)を持つ。#2173 の一覧が 2 つのループの依頼を並べる | `loop` を落とす | -| 受け入れ条件 14・I52 | `local` の周回の後にループが `user_start` で待ち、`next-lap` で起きる。`LOCAL_LOOP_AUTOSTART=true` なら待たない。`managed`・`byok` は待たない | 見回りが `user_start` を起こす | -| 受け入れ条件 15・I55 | 偽の `claude` が `ndf-next` を 1 つ返すと次の区間が起き、`判断が要る`・ブロック無し・2 つでは起きない。区間ごとに `section.started` が届く | 2 つ目のブロックを使う・`判断が要る` を見ない | -| 受け入れ条件 16・I51 | 21 回目を起動せず `job.finished`(`stopped`・`section_limit`)で終わる。打ち切りの時間は区間の合計に掛かる | 区間ごとに打ち切りの時間を数え直す | -| 受け入れ条件 17・I55 | 契約の一覧の例の組を NDF の pytest(`next_blocks` と `_mark_action`)と Factory の vitest(`next-block.ts`)が読み、同じ答えを返す。`check-service-boundary.py` が通る | 片方の読み方だけを変える・`plugins/` から `factory/` を読む | -| 受け入れ条件 18・I54 | `local` の偽の `claude` が上限の文を返すと `job.finished`(`stopped`・`usage_limit`・リセット時刻)で終わり、実行が `awaiting_reset`、時刻の後の見回りで `APPROVAL_REQUEST_ID` の無い `resume` が積まれる。時刻が読めなければ 900 秒後 | `local` 以外でも `usage_limit` を出す・時刻の前に再開する | -| 受け入れ条件 19 | `managed`・`byok` で同じ上限の文が来ても今の `failed` の扱いになる。既存のテストが通る | 形態を見ずに `awaiting_reset` にする | -| 受け入れ条件 20 | 契約の検査が新しい欄と種類を通し、生成した型に現れる。`loop` に `ISSUES` を持つ指示が断られる | スキーマを足さずに型だけを書く | -| 受け入れ条件 21 | 手動の開始・一覧・取り消し・再開の既存のテストが変えずに通る | 既存の 409 の順を変える | -| I44 | 周回の開始のトランザクションの後で止めても、2 つの呼び手が同時に `startNextLap` を呼んでも、実行が 1 本だけ | `lapRuns` を条件から外す | diff --git a/issues/issue-2347-design-decisions.md b/issues/issue-2347-design-decisions.md deleted file mode 100644 index 82299a4d9..000000000 --- a/issues/issue-2347-design-decisions.md +++ /dev/null @@ -1,219 +0,0 @@ -# #2347 の設計: 決定の記録 - -設計文書の本体は [issue-2347-design.md](issue-2347-design.md) 、入出力の契約とテスト設計は -[issue-2347-design-contracts.md](issue-2347-design-contracts.md) にある。 - -## 決定の記録 - -### 決定 1: 常駐の費用を増やさないため、実行をまたぐ制御は API と見回りに、ジョブの中の区切りはランナーに置く - -利用者の原文は「relay.py に相当するオーケストレーターが低コストで常に動いている」構想を問うた。relay.py の役割は 2 つに分かれる。 -1 つは周回をまたいで次を起こすことで、これは前の実行の終わり(停止の知らせ)と 5 分ごとの見回りという、既に呼ばれている契機で足りる。 -もう 1 つは 1 つの会話の中の区切りで claude を起動し直すことで、ジョブの間だけ動くランナーがその場で行える。どちらも新しい常駐の -プロセスを要らない。 - -常駐の制御の Lambda や Step Functions の状態機械を置く案は採らない。待ちの大半は人の承認と枠の空きで、時間で起きる必要があるのは -リセット時刻だけであり、5 分の見回りで足りる。常駐を置くと、ループが 1 本も動かないアカウントにも費用が掛かる。 - -根拠: Value 4 / Value 10(MVV 版 4) - -### 決定 2: 周回の実行を 2 本起こさないため、開発ループをループの制御が持ち、ループ・実行・枠を 1 つのトランザクションで書く - -ループの「今の実行」と実行の作成を別のトランザクションにすると、間で止まったときに見回りがもう 1 本を起こす。要求の非機能の -可用性はこれを禁じる。開始と終わりの状態への移りを 1 つのトランザクションで書く既存の例外(実行と枠)に、ループを 3 つ目として -足す。持ち主はループの制御(`factory/api/src/runs/loops.ts`、`LoopControl`)の 1 つにし、別の構成要素がループの行を書かない。 -実行の制御(`control.ts`、`RunControl`)は停止の後の処理でループの写しを同じトランザクションへ入れるが、書く中身はループの制御の関数が組む。 -DynamoDB のトランザクションは 100 件まで書け、最も多い書き込み(手動の開始で、実行 1・ジョブ 1・枠 2・課題の持ち分 20・マイルストーンの持ち分の照合(題ごとに 1 件で多くて 20)の 44 件。移行の済みの行はトランザクションの外で読み、照合に入れない。決定 12)でも足りる。 - -ループを別の持ち主(新しい `loops/` のサービス)にし、実行の終わりを事象で受けて結果整合で揃える案は採らない。事象の取りこぼしを -見回りで補う間に、同じ周回の実行が 2 本になる経路が残る。 - -根拠: Value 6(MVV 版 4) - -### 決定 3: 選定から外す課題を機械で渡すため、NDF の `loop select` が環境変数 `NDF_LOOP_EXCLUDE` を読む - -`loop exclude` は選定の後・`loop sprint` の前にしか打てず(`sprint_loop_select.py` の `cmd_exclude`)、ランナーはその間に -割り込めない。conductor への指示の文で「この課題を外す」と頼むと、決まった手順が LLM の判断へ回る。`loop select` が -環境変数の番号をその周回の除外(`excluded`、理由「別の実行が持つ課題」)へ足せば、手元の relay.py で複数のループを並べるときにも -同じ口が使える。環境変数は契約の一覧(`plugins/ndf/contracts/env/`)に宣言する。 - -根拠: Value 4 / Value 5(MVV 版 4) - -### 決定 4: ループの状態を 1 つの表で決めるため、実行の終わり方と `loop.stopped` の組からだけ写す - -| 実行の終わり方 | `loop.stopped` | ループ | 止まった理由 | -| --- | --- | --- | --- | -| `awaiting_approval` | `gate2_returned` か無し | `awaiting_approval` | — | -| `awaiting_reset` | — | `running` のまま | — | -| `done` | 無し | `between_laps` → `startNextLap` | — | -| `done` か `stopped` | `gate2_returned` 以外の条件 | `stopped` | 条件と `detail` | -| `failed` | 無し | `stopped` | `run_failed`(実行の `failureReason` を `detail` に) | -| `stopped` | 無し | `stopped` | `run_stopped`(`finishReason` を `detail` に) | -| `cancelled` | — | `cancelled` | — | - -要求の E8 は失敗した周回の後も次を起こすとし、受け入れ条件 2 は `loop.stopped` の無いまま終わったループを `stopped` にする。 -NDF が周回を失敗で締めた場合(`loop lap --failed`)、ジョブは `done` で終わり、`same_failure` の数え方は NDF に残る。 -ジョブそのものが落ちた場合は NDF が周回を締めておらず、次の実行は同じ周回を `in_lap` で続けるため、起こし続けると同じ失敗を -数えないまま繰り返す。そこで実行の `failed` は受け入れ条件 2 のとおり止める。差し戻しで `gate2_returned` の周回が「人へ戻した」で -締まった後の `done` は、`loop.stopped` が無ければ次の周回へ進む。 - -根拠: Value 6(MVV 版 4) - -### 決定 5: 周回 1 回をスプリントの実行 1 本に保つため、ランナーは周回が締まった区間の後に次の区間を起動しない - -NDF の `loop lap` は周回を締めると、次の周回を始める `ndf-next` を返す。ランナーがそれに従うと、1 つのジョブが次の周回へ進み、 -実行の枠・持ち分・費用の区切りがずれる。ランナーは区間ごとに `loop.json` の締めた周回の数と `stop` を読み、増えていれば終える。 -次の周回は実行の制御が新しい実行で起こし、その時点の枠と取り合いを照らす。 - -ジョブの中で周回を続け、実行を周回の束にする案は採らない。並行する実行の取り合い(課題の持ち分・`EXCLUDE_ISSUES`)は実行を -起こす時点でしか照らせない。 - -根拠: Value 6(MVV 版 4) - -### 決定 6: 承認の依頼の種類を増やさないため、`local` の次の周回と人の判断待ちは、ループの操作と知らせにする - -全体の契約の I11 は承認の種類をゲート 1・2 に限り、決定 15 はサービスの側で承認を足さないとする。`local` の次の周回(未決 1)は、 -今の手動の開始と同じ「利用者が始める」操作であり、承認の依頼ではない。ループは `between_laps`(`waitReason: user_start`)で -待ち、一覧の行と詳細に「次の周回を始める」を出し、#2173 は知らせ(承認の依頼にしない)を送る。 - -人の判断待ちの課題(未決 2)は、NDF の開発ループが周回を止めずに残す課題で、答えは今の実行を再開しない(次のジョブが -`loop answer` で書く)。承認の依頼にすると、#2173 の答えが `requestResume` を呼び、承認待ちでない実行に断られる。ループの詳細に -並べ、答えをループの行に持ち、知らせだけを #2173 が送る。 - -どちらも承認の依頼の新しい種類(例 `lap_start`)にする案は採らない。承認の工程を足すことになり、設計の規約にも I11 にも当たる。 - -根拠: Value 2(MVV 版 4) - -### 決定 7: 利用上限の後に同じ周回を続けるため、実行を `awaiting_reset` にし、承認の依頼の識別子の無い `resume` で起こす - -`stopped` は実行の終わりの状態で、そのまま写すと周回が途中で終わる。承認待ちと同じく、今のジョブを空にし枠を持ったまま待つ -状態 `awaiting_reset` を実行に足し、見回りがリセット時刻を過ぎたものに `START=resume` のジョブを作る。指示の -`APPROVAL_REQUEST_ID` は `resume` で必須だったが、答えを書かずに続ける再開のために省けるようにする(ランナーは手順 6 を飛ばし、 -conductor へ「前回の続きから進めてください。」だけを渡す)。指示の形は緩めるだけで、今の書き手はそのまま通る。 - -新しい `START` の値(`continue`)を足す案は採らない。状態の復元と conductor の起動は `resume` と同じで、違いは答えの有無だけである。 - -根拠: Value 6(MVV 版 4) - -### 決定 8: 2 つの読み手を食い違わせないため、`ndf-next` の読み方を NDF の契約の一覧に「受け渡しの形」として宣言し、例の組を両方のテストが読む - -relay.py の規則(`relay_lib/mark.py` の `next_blocks`・`_mark_action`)は、CommonMark の 3 つのバッククォートの囲みで情報文字列が -`ndf-next` のもの、2 つ以上あれば書かない、である。ランナーは `plugins/` を参照しないため(全体の契約の I1 の逆向きは許されるが、 -ランナーは実行時に NDF をマーケットプレイスから入れる)、規則を写すしかない。写した規則が古くなるのを防ぐため、契約の一覧に -新しい区分 `forms/`(ファイルでなく文の形の受け渡し)を足し、規則の文と例の組(入力の文・取り出すブロック・起動するか)を -置く。NDF の pytest と Factory の vitest が同じ例の組を読み、どちらかの答えが違えば落ちる(I55)。構造チェックは `forms/` の -項目の形を確かめる。 - -例の組を `factory/` に置く案は採らない。規則の持ち主は NDF で、NDF の変更が例を直す。 - -根拠: Value 6 / Value 7(MVV 版 4) - -### 決定 9: リセット時刻を読めないときの待ち(未決 3)を、NDF の `supervise` と同じ 900 秒にする - -`supervise_lib/claude.py` の `LIMIT_RETRY` は 900 秒で、NDF の中で同じ問題に既に使っている値である。Factory だけ別の値を持つと、 -同じ利用者の機械で同じ上限に当たったときの振る舞いが分かれる。`local` の待ちはコンテナの費用を払わないため、短く刻んでも費用は -増えない。値はサービスの設定 `USAGE_LIMIT_RETRY_SECONDS`(既定 900)で変えられる。 - -5 時間(`five_hour` の窓の長さ)にする案は採らない。窓の終わりがいつかは分からず、多くの場合は待ちすぎる。 - -根拠: Value 3 / Value 6(MVV 版 4) - -### 決定 10: 取り込みの衝突を `base_conflict` で出すため、ランナーの翻訳が `attention` の行の `step` から写す - -NDF の base-sync は衝突で止まると `attention` の行を書くが、理由の語は工程の共通の語(止まった・同じ失敗など)で、`base_conflict` -を持たない。Factory の語へ変えるのは腐敗防止層の仕事で、翻訳を持つのはランナーだけである(全体の契約の決定 6)。`step` が -`base-sync` の行だけを写し、ほかの行は今の理由のまま送る。 - -NDF の supervise に新しい理由の語を足す案は採らない。NDF の側で `attention` の理由を読むのは conductor の文で、語を増やしても -NDF の振る舞いは変わらない。 - -根拠: Value 6(MVV 版 4) - -### 決定 11: 課題とマイルストーンの取り合いを条件つきの書き込みで守るため、持ち分を実行の枠の行で持つ - -同じ課題が 2 つの実行に入らないこと(I47)と、ループどうしのマイルストーンが重ならないこと(I45)は、読んでから書くと同時の -2 本がどちらも通る。手動の開始は課題ごとに、ループの開始はマイルストーンごとに行を `attribute_not_exists` で書き、実行・ループと -同じトランザクションで消す。手動の開始は、動いているループのマイルストーンに属する課題も断る(409 `issue_in_loop`)。マイルストーンを -ループに持たせた以上、外から課題を差すと周回の選定と同じ課題を取り合うためである。ループの周回が選ぶ課題は持ち分の行を書かない。 -ループどうしはマイルストーンで分かれ、手動の実行とは `EXCLUDE_ISSUES` と `issue_in_loop` で分かれる。 - -`issue_in_loop` の照合と `EXCLUDE_ISSUES` の読みは、マイルストーンの持ち分の行で直列にする。開始の確かめで読んで断るだけでは、手動の開始が -照合を通った後にループが作られ、最初の周回が空の `EXCLUDE_ISSUES` を読み、その後で手動の開始が課題の持ち分を書けて、両方が同じ課題を -実行する。そこで手動の開始のトランザクションに、課題の属するマイルストーンの持ち分の行(`MILESTONE##<題の sha256 の先頭 32 字>`) -の `attribute_not_exists` の照合(`ConditionCheck`)を課題の持ち分の書き込みと並べて入れ、照合で落ちれば読み直して 409 `issue_in_loop` を返す。 -照合は課題ごとでなく、指した課題のマイルストーンの題ごとに重複を除いて 1 件にする。DynamoDB のトランザクションは同じ項目への 2 つの操作を -`ValidationException` で拒むため、同じマイルストーンの課題を 2 つ以上指す手動の開始が必ず落ちる。 -ループの作成はマイルストーンの持ち分を書いてから最初の周回を起こし、周回は課題の持ち分を強い整合で読む。これで、作成より後に通る -手動の開始は照合で落ち、作成より前に通った手動の開始の持ち分は周回の読みに必ず現れる。マイルストーンの無い課題は照合の行を持たない -(ループはマイルストーンで選ぶため取り合わない)。 - -周回の選定の結果(`phase.changed` の課題)を事象から持ち分へ書く案は採らない。受け口が実行の制御を呼ぶことになり、全体の契約の -向きに反する。 - -根拠: Value 6(MVV 版 4) - -### 決定 12: 古い版どうしの衝突を避けるため、リポジトリの枠に古い版の数を持ち、下限は設定で持つ - -並行を許すかは、新しく起こす実行と動いている実行の両方の版で決まる。リポジトリの枠の行に `legacyCount` を足し、古い版の実行は -`count` が 0 のとき、新しい版の実行は `legacyCount` が 0 のときだけ書く。比べるのは版の `@` より前を semver として読んだ値で、 -開発版の接尾辞は semver の前置きの版として小さく読む。下限は `NDF_PARALLEL_MIN_VERSION`(コードに埋め込まない。前提 6)で、 -無ければすべての版を古い版として扱う。#2164 の最初の開発版を許したいときは、その接尾辞つきの版を下限に置く。 - -実行の行を読んで版を数える案は採らない。読んでから書く間に別の開始が通る。 - -**この版より前に作られた終わりの状態でない実行は、課題の持ち分も `legacyCount` も持たない。** 無い値を 0 と読むと、その実行の課題が -持ち主の無いものになり、古い版の実行も数えられず、新しい開始が I47・I48 を破って通る。そこで、表に 1 つの移行の済みの行が -できるまでは下限を読まずにすべての版を古い版として扱い(`count` が 0 のときだけ書き、`legacy` を真にして `legacyCount` を増やす)、 -埋め戻しのコマンドがすべてのアカウントの既存の実行を古い版として `legacyCount` と課題の持ち分へ写してから移行の済みの行を書く -(手順は設計の「データ構造」の「移行」)。終わりへの移りは実行の `legacy` が真のときだけ `legacyCount` を減らし、埋め戻す前に終わった -実行で数が負にならない。既存の実行を古い版と見なすのは、その実行の NDF の版を照合していないためである。 - -移行の済みの行をアカウントごとに置く案は採らない。埋め戻しは一度だけ流すため、その後に作られたアカウントは行を持たず、並行の開始が -永久に通らない。表に 1 つの行なら、埋め戻しの後に作られたアカウントの実行はすべてこの版が `legacy` を書いたもので、埋め戻すものが無い。 - -開始と周回の開始は、移行の済みの行をトランザクションの前に強い整合(`ConsistentRead`)で 1 回読むだけにし、行の照合 -(`attribute_exists`)をトランザクションへ入れない。行は全アカウントで共有の 1 項目のため、照合に入れると同時に走るすべての -アカウントの開始が同じ項目を含み、DynamoDB が互いを `TransactionConflict` で取り消す。行は一度書けば消さないので、読んで -「ある」なら以後も必ずあり、「無い」なら `legacy` を真にして `count` が 0 のときだけ書く保守側の扱いになるため、照合を外しても -I47・I48 は破れない。 - -移行の済みの行を置かず、運用者が既存の実行が終わるのを待って下限を設定する案は採らない。待ったかどうかを機械が確かめられず、 -設定の順を誤ると I47・I48 を破る。 - -根拠: Value 5 / Value 6(MVV 版 4) - -### 決定 13: 手元の機械を守るため、`max_jobs` の申告は #2189 の登録が持ち、受け取りの条件と目安の関数をこの課題が持つ - -申告の値の保存は手元のランナーの登録(#2189)の集約に属する。この課題は `claimLocalRunJob` の入力にランナーの識別子と申告を -足し、手元のランナーの枠を条件にした書き込みで `max_jobs` を超えて渡さない(I50)。目安は 2026-09-25 の実測(8 コア・30 GB で -6 本)から 1 本あたり 4/3 コア・5 GiB とし、`max(1, min(floor(CPU の数 × 3 / 4), floor(メモリの GiB / 5)))` で出す。申告が目安を -超えれば手元のランナーが標準エラーへ 1 行の警告を出し、申告はそのまま送る。 - -サービスが目安で申告を切り詰める案は採らない。手元の機械の資源を知るのは手元だけで、サービスは利用者の環境を知らない。 - -根拠: Value 3 / Value 5(MVV 版 4) - -### 決定 14: 状態の数を増やさないため、止まった・取り消したループは終わりとし、続きは新しいループで始める - -NDF の止まる条件の多くは再開で解ける(`budget_*` は予算の上書き、`no_issue` は選定のやり直し)。Factory でそれを受けると、 -`stopped` から戻る遷移と、どの条件が戻れるかの表が要る。止まった後に続けたい利用者は、同じマイルストーンで新しいループを -始める(持ち分は止まった時点で消えている)。予算の変更は終わりの状態でないループにだけ効き、次のジョブの指示から NDF の -`--budget-*` へ渡る。 - -根拠: Value 1(MVV 版 4) - -### 決定 15: 取り消しの経路を 1 つにするため、ループを止めるときに NDF の `loop stop` を打たない - -要求の前提 5 は、止めたループのランナーへ `loop stop` に当たる知らせを渡すとする。動いている実行は今の取り消し(`cancelSprintRun`)で -止まり、ランナーは `SIGTERM` で何も送らずに終わる。取り消したループには次のジョブが起きないため、NDF の記録に止める印を -書く先が無く、書いても読む者がいない。知らせはループの `cancelled` そのものとする。 - -根拠: Value 1(MVV 版 4) - -### 決定 16: 予算の残りが止まる判定と食い違わないため、周回の数・費用・時間は NDF が締めた周回の値で出す - -止まる条件を判定するのは NDF で、NDF は締めた周回の費用と秒を和にして予算と比べる。Factory の `usage.recorded` の和で残りを出すと、 -画面の残りが 0 でも止まらない、またはその逆が起きる。一覧の「費用」「周回の数」「予算の残り」は `loop.lap_closed` の和で出し、 -今の周回の分は詳細の画面で実行ごとの `usage.recorded` の和として別に出す。 - -根拠: Value 3(MVV 版 4) diff --git a/issues/issue-2347-design.md b/issues/issue-2347-design.md deleted file mode 100644 index b5bd2db6a..000000000 --- a/issues/issue-2347-design.md +++ /dev/null @@ -1,502 +0,0 @@ -# NDF Factory: 方針の違う開発ループを同じリポジトリで並べ、Web から始め・止め・見て、ジョブの中で区切りを越えて続ける(#2347) - -## 目的 - -- **何が壊れているか**: Factory はスプリントの実行を課題の番号で 1 本ずつ始められるだけで、開発ループの入口・同じリポジトリで並行する実行どうしの取り合い(課題・版)の扱い・手元のランナーの同時のジョブの数・1 つのジョブの中で conductor を起動し直す仕組みが無い -- **誰が困るか**: 2026-11-01 にこのリポジトリで行った「方針の違う開発ループを 3 本並べる」運用を、Factory の利用者が Web から同じ形で行えない。8 時間のジョブの途中で conductor の context window が埋まると、ジョブがそこで終わる -- **直すと何が成り立つか**: 利用者はリポジトリごとにループを始め・止め・予算を変え、一覧と詳細で周回と止まった理由を見る。課題・版・同時の数・手元の機械の上限を機械が守り、ランナーは `ndf-next` で区間をつないで手元の relay.py と同じく続ける - -## 適用範囲 - -- **働く範囲**: このリポジトリの `factory/`(API・ランナー・画面・契約)と、NDF の配布物のうち `ndf-next` のブロックの読み方の宣言と、ループの選定から外す課題を受け取る環境変数(`plugins/ndf/contracts/`・`sprint-state.py loop select`)。NDF の側の変更は配布先のリポジトリでも働く -- **プロジェクトごとに違うもの**: 取り込むリポジトリの選定の規則・予算の既定・止まる条件の閾値は、ランナーの中の NDF がそのリポジトリの `.ndf/pace.json` から読む。Factory が持つのは利用者が画面で入れた値(マイルストーン・予算・同時の数の宣言)と、サービスの設定(並行を許す NDF の版の下限・区間の上限・`local` の自動の周回の可否)だけである -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| 「決まっていないこと、は全て定義する必要があります」「factory の場合 relay.py に相当するオーケストレーターが低コストで常に動いているのではないでしょうか」(利用者、2026-11-01) | 利用者の指示の原文 | 未決を残さないこと。常駐の費用を増やさずに relay.py の役割を置くこと(決定 1) | -| 手元の relay の記録 14 本(`~/.local/state/ndf/relay/*/log.jsonl`、2026-10-25〜11-02)の区間の数: 1 が 8 本、4 が 2 本、2・3・6・9 が各 1 本。最大 9 | 実測(2026-11-02) | 区間の上限の既定 20 は最大の 2 倍を超え、通常のジョブを打ち切らないこと(前提 9 の置き換え) | -| 8 コア・30 GB で全体テストの同時実行 6 本が上限(2026-09-25) | 実測(記録) | 同時のジョブの数の目安を 1 本あたり 4/3 コア・5 GB で出すこと | -| `plugins/ndf/scripts/supervise_lib/claude.py` の `LIMIT_RETRY = 900`(リセット時刻を読めないときの待ち) | 既存の形 | 未決 3 の既定の待ちを NDF と同じ 900 秒にすること(決定 9) | -| Factory の全体の契約の I11「承認を求める種類はゲート 1 とゲート 2 の 2 つだけ」と決定 15「サービスの側で承認を足したり省いたりしない」 | 既存の形 | 未決 1・2 を承認の依頼の種類へ足さないこと(決定 6) | - -要求と受け入れ条件は #2347 の本文にある(写しは [issue-2347-requirements.md](issue-2347-requirements.md) )。この文書は「どう作るか」だけを扱う。 -入出力の契約・画面・テスト設計は [issue-2347-design-contracts.md](issue-2347-design-contracts.md) 、決定の記録は -[issue-2347-design-decisions.md](issue-2347-design-decisions.md) にある。 - -**要求の未決への答え**: 未決 1・2 は決定 6、未決 3 は決定 9、未決 4 は決定 2、未決 5・6 は「未確認のまま残ること」にある。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| Neural Development Factory(`ndf-factory`) | 開発ループ(集約)・スプリントの実行・実行の枠・マイルストーンの持ち分・課題の持ち分・手元のランナーの枠・区間・区間の上限・同時のジョブの数 | -| NDF の開発ワークフロー(`ndf-workflow`) | 開発ループ(`loop.json`)・周回・ループの予算・止まる条件・人の判断待ち・`ndf-next`・リセット時刻 | - -**`ndf-workflow` が上流、`ndf-factory` が下流の腐敗防止層である**(全体の契約のまま)。Factory の開発ループは NDF の -ループの記録を読まず、ランナーが翻訳した進捗の事象(`loop.lap_closed`・`loop.stopped`)だけを読む。翻訳はランナーの -`factory/runner/src/loop-file.ts` と `translate.ts` だけが持つ。`ndf-next` のブロックの読み方は、NDF の契約の一覧が -**公開された言語**として宣言し、ランナーは形を写す(決定 8)。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 開発ループ(新設) | ループの制御(`factory/api/src/runs/loops.ts`、`LoopControl`) | ループ | — | 名前・選定の対象のマイルストーン・予算・状態・待ちの理由・止まった理由・人の判断待ちの写し・答え・締めた周回の和 | -| スプリントの実行(変更) | 実行の制御(`factory/api/src/runs/control.ts`) | スプリントの実行 | 実行ジョブ | 既存に、属するループ・周回の番号・終わりの理由・再開の時刻を足す | -| 実行の枠(変更) | 実行の制御 | アカウントの枠 | — | 既存に、リポジトリの枠の古い版の数・マイルストーンの持ち分・課題の持ち分・手元のランナーの枠を足す | -| リポジトリの登録(変更) | 取り込み(`factory/api/src/repositories/`) | 登録 | — | 既存に、同時の実行の数の宣言を足す | - -- **ほかの集約は識別子で参照する。** ループは登録を `repositoryId`、今の実行を `currentRunId` で指す。実行はループを `loopId` で指す -- **1 つの処理が書き換える集約は 1 つで、例外を「スプリントの実行・実行の枠」から「開発ループ・スプリントの実行・実行の枠」の 3 つへ広げる**(決定 2)。周回の開始・実行の終わりへの移り・ループの取り消しを 1 つのトランザクションで書く -- **持ち分と手元のランナーの枠は実行の枠の行である。** 取り合いの条件を、実行と同じトランザクションの条件つきの書き込みで守るためである - -### 不変条件 - -I8・I9・I32〜I43 は既存のまま変えない。続けて番号を振る。 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I44 | 開発ループ | 1 つのループで終わりの状態でないスプリントの実行は 1 つまで(`currentRunId`) | 周回の開始は `currentRunId` が空で `lapRuns` が読んだ値のときだけ書く。条件で落ちれば何もしない(2 本目を起こさない) | -| I45 | 実行の枠 | 同じリポジトリの終わりの状態でない 2 つのループの選定の対象のマイルストーンは重ならない | 持ち分の行を `attribute_not_exists` で書き、落ちれば 409 `milestone_in_use`(重なった題と相手のループの名前) | -| I46 | 実行の枠 | 枠の数は終わりの状態でない実行の数に等しい(I34)。`between_laps` のループは枠の行を持たない | ループは枠の行を書かない。枠は実行の作成と終わりへの移りでだけ増減する | -| I47 | 実行の枠 | 同じリポジトリで、1 つの課題を持つ終わりの状態でない実行は 1 つまで | 手動の開始が、課題の持ち分の行の `attribute_not_exists` の書き込みと、課題のマイルストーンの持ち分の行の `attribute_not_exists` の照合(題ごとに重複を除いて 1 件)を同じトランザクションで行い、前者で落ちれば 409 `issue_in_use`、後者で落ちれば 409 `issue_in_loop`。ループの周回へは、強い整合で読んだ持ち分の課題を `EXCLUDE_ISSUES` で渡す(決定 11) | -| I48 | 実行の枠 | 並行を許す版の下限より古い NDF の版の実行がリポジトリにあれば、その実行は唯一である | リポジトリの枠の `legacyCount` と `count` を条件にし、落ちれば 409 `parallel_unsupported_version`(ループは待つ)。移行の済みの行が無い間は、すべての版を古い版として扱う(決定 12) | -| 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` の条件から、決定 4 の表のとおりにだけ決まる | 表に無い組は `stopped`(`run_failed`)にする | -| I54 | スプリントの実行 | `usage_limit` で終わるジョブと `awaiting_reset` の実行は、形態 `local` にだけある | ランナーは `local` 以外で利用上限を検出しない。制御は `local` 以外の `usage_limit` を `failed` として扱う | -| I55 | — (ランナー) | ランナーと NDF の relay.py は、同じ応答の文から同じ `ndf-next` のブロックと同じ「起動するか」を読む | 契約の一覧の例の組を両方のテストが読み、どちらかが違えば落ちる | - -### ドメインイベント - -要求の番号を引き継ぐ。 - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 開発ループを始めた | 実行の制御(画面の操作) | E2(実行の制御) | -| E2 | 周回の実行を起こした(`START=loop`) | 実行の制御(`startNextLap`) | ランナー・実行の枠 | -| E3 | 周回の課題を選んだ | ランナーの中の NDF(`loop select`) | ランナー(`phase.changed` の課題で画面へ) | -| E4 | ジョブの中で次の区間を起動した(`section.started`) | ランナー | 進捗の受け口 → 画面の詳細 | -| E5 | 承認ゲート 2 で止まった | ランナー(`gate.reached`・`loop.stopped`(`gate2_returned`)) | #2173・実行の制御(ループを `awaiting_approval`) | -| E6 | 人の判断待ちの課題ができた | ランナー(`loop.lap_closed`・`loop.stopped` の `pending`) | 実行の制御(ループの写し)→ 画面の詳細・#2173 の知らせ | -| E7 | ループが止まった(`loop.stopped`) | ランナー | 実行の制御(停止の後の処理で読む) | -| E8 | 周回の実行が終わった | 実行の制御(停止の後の処理) | 実行の制御(`startNextLap`・待っているループを起こす) | -| E9 | 取り込みで衝突を解けなかった(`attention`・`base_conflict`) | ランナー(NDF の `progress.jsonl` の行の翻訳のまま) | 画面・#2173 | -| E10 | 次の周回を始める操作を待つ(`local`) | 実行の制御(E8 の後) | 画面(一覧の待ちの理由)・#2173 の知らせ | -| E11 | 利用上限でジョブを終えた(`usage_limit`) | ランナー | 実行の制御(実行を `awaiting_reset`) | -| E12 | リセット時刻の後に再開を起こした | 実行の制御(見回り) | ランナー(`START=resume`) | -| E13 | 利用者がループを止めた(`cancelled`) | 実行の制御(画面の操作) | 実行の制御(`cancelSprintRun` と同じ形で今の実行を止める) | -| E14 | 手元のランナーが同時のジョブの数を申告した | 手元のランナー(#2189 の登録と心拍) | 実行の制御(`claimLocalRunJob` の条件) | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| マイルストーンの持ち分 | 終わりの状態でない開発ループが、リポジトリのマイルストーン 1 つを選定の対象として占めていることを表す実行の枠の行 | 追加(`ndf-factory`) | -| 課題の持ち分 | 終わりの状態でない手動のスプリントの実行が、リポジトリの課題 1 つを持っていることを表す実行の枠の行 | 追加(`ndf-factory`) | -| 手元のランナーの枠 | 1 つの手元のランナーが `running` で持つ `local` の実行ジョブの数を数えた実行の枠の行 | 追加(`ndf-factory`) | -| 区間 | 1 つの実行ジョブの中でランナーが起動する `claude -p` の 1 回分 | 変えない(要求で追加済み) | -| 区間の上限 | 1 つの実行ジョブで起動してよい区間の数の上限(既定 20) | 変えない(要求で追加済み) | -| 同時のジョブの数 | 手元のランナーが同時に動かす実行ジョブの数の上限(`max_jobs`) | 変えない(要求で追加済み) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | リポジトリごとに開発ループを始める(名前・マイルストーン・予算) | 席を持つ人 | -| F2 | ループを止める・予算を変える | 席を持つ人 | -| F3 | ループの周回を、枠が空き次第、自動で続けて起こす(`managed`・`byok`) | 実行の制御 | -| F4 | `local` のループで次の周回を始める | 席を持つ人 | -| F5 | ループの一覧と詳細を見る(周回・止まった理由・人の判断待ち) | 席を持つ人 | -| F6 | 人の判断待ちの課題に答える | 席を持つ人 | -| F7 | 同じリポジトリで並行する実行の取り合い(課題・版・同時の数)を断るか待たせる | 実行の制御 | -| F8 | リポジトリの同時の実行の数を宣言する | 持ち主 | -| F9 | 1 つのジョブの中で `ndf-next` の区間をつなぐ | ランナー | -| F10 | `local` で利用上限に当たったジョブをリセット時刻の後に続ける | ランナー・実行の制御 | -| F11 | 手元のランナーの同時のジョブの数を申告し、目安を超えれば警告する | 手元のランナー(#2189) | - -## 構成要素 - -| 要素 | 場所 | 責務 | -| --- | --- | --- | -| ループの制御(新設) | `factory/api/src/runs/loops.ts`(`LoopControl`) | 始める・止める・予算・答え・次の周回の操作、`startNextLap`(周回の開始の判定とトランザクション)、`wakeWaitingLoops`(待っているループを古い順に起こす)、停止の後の処理から呼ばれるループの状態の写し(決定 4) | -| 実行の制御(変更) | `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` と課題の持ち分へ写し、移行の済みの行を書く(「データ構造」の「移行」・決定 12) | -| 上限の判定(変更) | `factory/api/src/accounts/limits.ts`(`judgeLimits`) | 理由 `concurrent_repo_runs` を足す。入力にリポジトリの枠の数と宣言を足す | -| 取り込み(変更) | `factory/api/src/repositories/service.ts` | 登録の `maxConcurrentRuns` の宣言を書く | -| GitHub の口(変更) | `factory/api/src/github/port.ts`・`app.ts` | 課題の確かめの応答にマイルストーンの題を足す | -| 進捗の記録の読み出し(変更) | `factory/api/src/progress/read.ts`(`ProgressReader`) | ジョブの `loop.lap_closed`・`loop.stopped` を読む。ループの周回の行(詳細の画面)を読む | -| 画面向けの経路(変更) | `factory/api/src/routes/factory.ts` | ループの 7 経路と登録の宣言の経路、開始の新しい 409 | -| ランナー向けの経路(変更) | `factory/api/src/runs/runner-api.ts` | 状態の置き場の鍵を、ループの実行では `loops//ndf-state.tar.gz` にする | -| ランナーの区間(新設) | `factory/runner/src/sections.ts` | 区間をつなぐ繰り返し・区間の上限・周回の締めの検出・利用上限の検出(決定 5・7) | -| `ndf-next` の読み方(新設) | `factory/runner/src/next-block.ts` | 応答の文からブロックと「起動するか」を読む(I55) | -| ループの記録の翻訳(新設) | `factory/runner/src/loop-file.ts` | `.ndf/loop/<名>/loop.json` を読み、`loop.lap_closed`・`loop.stopped` と、答えの書き込みの要否を作る | -| 同時のジョブの数の目安(新設) | `factory/runner/src/capacity.ts` | CPU の数とメモリから目安を出し、申告と比べる(#2189 の手元のランナーが呼ぶ) | -| ランナーの手順(変更) | `factory/runner/src/main.ts`・`runtime.ts`・`state.ts`・`translate.ts` | `START=loop` の手順、答えの書き込み、`NDF_LOOP_EXCLUDE` の受け渡し、状態に `.ndf/handoff/loop-*` を足す。`attention` の行の `step` が `base-sync` なら `reason` を `base_conflict` へ写す(決定 10) | -| 契約(変更・新設) | `factory/contracts/schemas/*.json`・`openapi/runs.yaml`・`openapi/loops.yaml`(新設)・`schemas/local-runner-declaration.schema.json`(新設) | 入出力の契約の文書の形 | -| 画面(新設・変更) | `factory/web/src/pages/LoopsPage.tsx`・`LoopDetailPage.tsx`・`RepositoriesPage.tsx` | ループの一覧と始める・詳細と操作・宣言の欄 | -| NDF の受け渡しの形(新設) | `plugins/ndf/contracts/forms/ndf-next-block.json`・`scripts/contracts_lib/model.py` | `ndf-next` のブロックの読み方と例の組を宣言し、構造チェックが形を確かめる(決定 8) | -| NDF のループの除外(変更) | `plugins/ndf/scripts/lib/sprint_loop_select.py`・`plugins/ndf/contracts/env/NDF_LOOP_EXCLUDE.json` | `loop select` が環境変数の課題をその周回の除外へ足す(決定 3) | - -```mermaid -graph TD - subgraph sg1["画面(factory/web)"] - LP[ループの一覧] - LD[ループの詳細] - AP["承認の依頼の一覧(#2173)"] - end - subgraph sg2["API(factory/api)"] - RT[画面向けの経路] - REPO[取り込み] - GH[GitHub の口] - LC[ループの制御] - RC[実行の制御] - LIM[上限の判定] - TBL[実行の表] - READ[進捗の記録の読み出し] - RAPI[ランナー向けの経路] - SW[停止の知らせと見回り] - end - subgraph sg3["実行ジョブ(ECS か手元)"] - MAIN[ランナーの手順] - SEC[ランナーの区間] - NB[ndf-next の読み方] - LF[ループの記録の翻訳] - CAP[同時のジョブの数の目安] - end - NDF["NDF(sprint-state.py loop・契約の一覧)"] - ING[進捗の受け口] - LP --> RT - LD --> RT - RT --> LC - RT --> REPO - RT --> RC - RC --> GH - LC --> RC - LC --> TBL - RC --> LIM - RC --> TBL - RC --> READ - SW --> RC - RC --> LC - MAIN --> SEC - SEC --> NB - SEC --> LF - MAIN --> NDF - LF --> NDF - MAIN --> RAPI - MAIN --> ING - CAP -.->|"#2189 の登録と心拍"| RC - READ --> ING - AP --> ING -``` - -図に含めない要素: 契約(どの要素も生成した型を読む)と NDF の受け渡しの形(ランナーのテストだけが読む)。 - -**配置は変えない。** ループの制御は既存の API の handler と、停止の知らせと見回りの Lambda の中で動く。常駐のプロセスは足さない -(決定 1)。手元のランナーは利用者の環境で、`max_jobs` の数までジョブを別の作業場所で並べて動かす。 - -### パッケージの構成 - -```text -factory/ -├── api/src/runs/loops.ts # 新設 -├── api/src/runs/backfill.ts # 新設(移行の埋め戻し。一度だけ流す) -├── api/src/runs/{control,table,dynamo-table,memory-table,model,runner-api}.ts -├── api/src/accounts/limits.ts -├── api/src/github/{port,app}.ts -├── api/src/progress/read.ts -├── api/src/routes/factory.ts -├── runner/src/{sections,next-block,loop-file,capacity}.ts # 新設 -├── runner/src/{main,runtime,state,translate}.ts -├── contracts/openapi/{runs,loops}.yaml # loops.yaml は新設 -├── contracts/schemas/{run-job-instruction,progress-event,local-runner-declaration}.schema.json -└── web/src/pages/{LoopsPage,LoopDetailPage,RepositoriesPage}.tsx -plugins/ndf/ -├── contracts/forms/ndf-next-block.json # 新設 -├── contracts/env/NDF_LOOP_EXCLUDE.json # 新設 -└── scripts/lib/sprint_loop_select.py -scripts/contracts_lib/model.py # forms を読む -``` - -## 構造 - -```mermaid -classDiagram - class DevLoop { - +loopId - +repositoryId - +name - +milestones - +budget - +state - +waitReason - +currentRunId - +lapRuns - +stopCondition - +pending - +answers - } - class SprintRun { - +loopId - +lap - +finishReason - +resumeAt - } - class RepoSlot { - +count - +legacyCount - } - DevLoop "1" --> "0..1" SprintRun : 今の実行 - SprintRun "*" --> "0..1" DevLoop : 属する - DevLoop ..> RepoSlot : 周回の開始で照らす -``` - -`SprintRun` の `loopId` が空の実行は今の手動の実行で、振る舞いは変わらない(受け入れ条件 21)。 - -## データ構造 - -実行の表(`RUNS_TABLE_NAME`)に行を足す。**時系列の扱いは既存と同じ「今の値の上書き」である。** 周回ごとの課題・結果・版・費用は -進捗の記録の `loop.lap_closed` が追記で持ち、ループの行の和はそこから作り直せる。 - -```mermaid -erDiagram - "リポジトリの登録" ||--o{ "開発ループ" : "持つ" - "開発ループ" ||--o{ "スプリントの実行" : "周回ごとに起こす" - "開発ループ" ||--|{ "マイルストーンの持ち分" : "占める" - "スプリントの実行" ||--o{ "課題の持ち分" : "占める" - "手元のランナーの枠" ||--o{ "実行ジョブ" : "数える" -``` - -| 行 | `pk` | `sk` | `gsi1pk` / `gsi1sk` | -| --- | --- | --- | --- | -| 開発ループ | `ACCOUNT#` | `LOOP#` | `WAITING_LOOPS` / `#`(`between_laps` で `waitReason` が `user_start` でない間だけ) | -| ループの実行(周回の開始の書き込みで足す。列は `sprintRunId`。ループの詳細がそのループの実行だけを引く) | `ACCOUNT#` | `LOOPRUN##` | — | -| マイルストーンの持ち分 | `ACCOUNT#` | `MILESTONE##<題の sha256 の先頭 32 字>` | — | -| 課題の持ち分 | `ACCOUNT#` | `ISSUE##<番号>` | — | -| 手元のランナーの枠 | `ACCOUNT#` | `RUNNER#` | — | -| 移行の済み(表に 1 つ) | `MIGRATION` | `MIGRATED#2347` | — | - -| 行 | 列 | 型 | 空 | 意味 | -| --- | --- | --- | --- | --- | -| ループ | `loopId` | 文字列 | 不可 | 32 字(実行と同じ作り方) | -| ループ | `repositoryId`・`name` | 数・文字列 | 不可 | 属する登録と、NDF のループ名(`^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$`) | -| ループ | `milestones` | 文字列の並び | 不可 | 1〜10 件。題に `,` を含まない(NDF の `--milestones` の区切り) | -| ループ | `budget` | `{laps, usd, hours}` | 各値は可 | 空は「宣言しない(NDF の `.ndf/pace.json` の既定)」 | -| ループ | `state` | 文字列 | 不可 | `running` / `between_laps` / `awaiting_approval` / `stopped` / `cancelled`。後ろの 2 つが終わりの状態 | -| ループ | `waitReason` | 文字列 | 可 | `between_laps` の理由: `slots`(枠)・`legacy_version`・`user_start`(`local`)。空は「起こす途中」 | -| ループ | `currentRunId` | 文字列 | 可 | 終わりの状態でない実行(I44)。空は「動く実行が無い」 | -| ループ | `lapRuns` | 数 | 不可 | 起こした実行の数。周回の開始の条件(I44)と、実行の `lap` | -| ループ | `lapsClosed`・`closedCostUsd`・`closedSeconds` | 数 | 不可 | `loop.lap_closed` の和(一覧の周回の数・費用・予算の残り) | -| ループ | `stopCondition`・`stopDetail` | 文字列・JSON | 可 | 止まった理由。NDF の条件か、Factory の `run_failed`・`run_stopped`・`limits_exceeded`・`launch_refused` | -| ループ | `pending` | `{number, reason, lap}` の並び | 不可 | 最後に届いた人の判断待ちの写し | -| ループ | `answers` | `{number, decision, pr}` の並び | 不可 | 画面の答え。次のジョブの `LOOP_ANSWERS` に載り、`pending` から消えた番号は外す | -| ループ | `cancelRequested`・`createdBy`・`createdAt`・`updatedAt`・`lastRunAt` | — | `lastRunAt` だけ可 | 取り消しの要求と時刻(`lastRunAt` は最後の実行の `updatedAt`) | -| 持ち分 | `loopId`・`title` / `sprintRunId` | 文字列 | 不可 | 占めるループと題 / 占める実行 | -| ランナーの枠 | `count` | 数 | 不可 | `running` の `local` のジョブの数(I50) | -| 実行 | `loopId`・`lap` | 文字列・数 | 可 | 空は手動の実行 | -| 実行 | `legacy` | 真偽 | 可 | 古い版として `legacyCount` に数えた実行。作成と移行の埋め戻しで書き、終わりへの移りで真のときだけ `legacyCount` を減らす。空は「この課題より前に作られ、まだ埋め戻していない」 | -| 実行 | `finishReason` | 文字列 | 可 | `job.finished` の `reason`(`section_limit` / `usage_limit`) | -| 実行 | `resumeAt` | 文字列 | 可 | `awaiting_reset` の間だけ。リセット時刻(読めなければ 900 秒後) | -| ジョブ | `runnerId` | 文字列 | 可 | `local` で受け取ったランナー。手元のランナーの枠を減らす先 | -| リポジトリの枠 | `legacyCount` | 数 | 不可 | 並行を許す版の下限より古い版の、終わりの状態でない実行の数(I48)。無いのは 0 | -| 登録 | `maxConcurrentRuns` | 数 | 可 | 空は「宣言しない(プランの値)」 | - -**実行の状態に `awaiting_reset` を足す**(終わりの状態でない。枠を数え、承認待ちと同じく今のジョブは空)。 - -| 機能 | ループ | 実行 | ジョブ | 枠・持ち分・ランナーの枠 | 登録 | 進捗の記録 | -| --- | --- | --- | --- | --- | --- | --- | -| F1 始める | C | C | C | C・U | R | — | -| F2 止める | U | U | — | U・D | — | R | -| F2 予算 | U | — | — | — | — | — | -| F3・F4 次の周回 | U | C | C | U・C | R | — | -| F5 一覧と詳細 | R | R | — | — | — | R | -| F6 答える | U | — | — | — | — | — | -| F7 手動の開始 | R | C | C | C・U | R | — | -| F8 宣言 | — | — | — | — | U | — | -| 停止の後の処理 | U | U | U | U・D | — | R・C | -| 見回り(再開・待ち) | U | U | C | U・C | R | — | -| 受け取り(`local`) | — | U | U | U | — | — | - -**移行**: 既存の行の形は変えず、列と行を足すだけにする。`maxConcurrentRuns`・`loopId` などの無い行は空として読む(非機能の移行性)。 -**並行の開始の材料(課題の持ち分・`legacyCount`)は、既存の終わりの状態でない実行から埋め戻してから使う**(決定 12)。手順は次の順で、 -どの時点で止まっても I47・I48 を破らない。 - -1. この課題の版を出す。表に移行の済みの行が無い間、開始はすべての版を古い版として扱い、リポジトリの枠の `count` が 0 のときだけ書く。 - この版より前の実行も `count` に数えられているため、動いている実行があるリポジトリでは 2 本目が断られる(ループは `legacy_version` で待つ) -2. 埋め戻しのコマンド(`factory/api/src/runs/backfill.ts`。一度だけ流し、何度流しても同じ結果になる)が、すべてのアカウントの `legacy` の空の - 終わりの状態でない実行を 1 本ずつ扱う。まず実行の課題の持ち分の行を強い整合(`ConsistentRead`)で読み、他の実行が持つ課題を除く - (どちらの実行も `legacy` で数えられ、終わるまで新しい開始は通らない)。次に 1 回のトランザクションで、実行の `legacy` を真に - (条件は `attribute_not_exists(legacy)` と、実行が終わりの状態でないこと)・リポジトリの枠の `legacyCount` を 1 増やし・残った課題の持ち分を - `attribute_not_exists` で書く。トランザクションは原子的で 1 行だけを飛ばせないため、落ちたら(`TransactionCanceledException`)その実行を - 読み直してやり直す(終わりの状態か `legacy` が空でなければ何もしない)。読んでから書く間に、そのリポジトリで新しい開始が課題の持ち分を - 書くことは無い。1 の間の開始は `count` が 0 のときだけ書き、埋め戻す実行がそのリポジトリの `count` に数えられているためである -3. `legacy` の空の終わりの状態でない実行が表に 0 件になったら、同じコマンドが移行の済みの行(表に 1 つ)を書く。開始はこの行がある間だけ - `NDF_PARALLEL_MIN_VERSION` を読む。開始と周回の開始は、この行をトランザクションの前に強い整合(`ConsistentRead`)で 1 回読むだけにし、 - 行の照合(`attribute_exists`)をトランザクションへ入れない(全アカウントで共有の 1 項目のため、入れると同時の開始どうしが - `TransactionConflict` で取り消し合う。行は一度書けば消さないので、読んで「ある」なら以後もあり、「無い」なら 1 の扱いになる。決定 12)。行はアカウントごとに置かないため、 - 埋め戻しの後に作られたアカウントも同じ行で並行の開始を許される(その実行はすべて 4 で `legacy` を書いており、埋め戻すものが無い) -4. この版で作られた実行は、作成のトランザクションで `legacy` と持ち分を書く。移行の済みの行が無い間(1 の間)は、版によらず `legacy` を真にして - `legacyCount` を 1 増やす(下限と比べない。偽を書くと、1 の間に始めた古い版の実行が移行の後に数えられず I48 を破る)。行がある間は、 - 下限より古い版なら真にして `legacyCount` を 1 増やし、新しい版なら偽にする。埋め戻しは `legacy` の空の実行だけを扱うため、同時に動いても重ならない - -無い値を 0 と読むのは、この順の 1 の間の `legacyCount` だけである(移行の済みの行が無ければ `legacyCount` を条件にしないため、0 と読んでも通さない)。 - -## 処理の流れ - -### ループの状態 - -```mermaid -stateDiagram-v2 - [*] --> running: 始めた(最初の周回が通った) - [*] --> between_laps: 始めた(枠・版で待つ) - running --> between_laps: 実行が done で loop.stopped が無い - running --> awaiting_approval: 実行が awaiting_approval - running --> stopped: loop.stopped(gate2_returned 以外)・実行が failed / stopped - running --> cancelled: 止めた(実行の停止の後) - awaiting_approval --> running: 答え(承認か差し戻し)で再開した - awaiting_approval --> cancelled: 止めた - between_laps --> running: 周回の開始が通った - between_laps --> stopped: 周回の開始が断られた(月の token・キー・導入) - between_laps --> cancelled: 止めた - stopped --> [*] - cancelled --> [*] -``` - -利用上限の `awaiting_reset` の間、ループは `running` のままである(周回は進行中)。 - -### 周回の開始(`startNextLap`) - -```mermaid -sequenceDiagram - participant S as 呼ぶ側(始める・停止の後・見回り・操作) - participant L as ループの制御 - participant C as 実行の制御 - participant T as 実行の表 - S->>L: startNextLap(loopId) - L->>T: ループを読む(between_laps・currentRunId 空) - alt local で自動が許されず、操作でない - L->>T: waitReason = user_start(#2173 へ知らせ) - else - L->>C: 導入・キー・NDF の版を決める - C->>T: 枠・リポジトリの枠・課題の持ち分を読む - C->>C: judgeLimits(concurrent_* / monthly_tokens)と版の照合 - alt 同時の数か古い版で断られた - L->>T: waitReason = slots / legacy_version(GSI へ) - else 月の token・キー・導入で断られた - L->>T: stopped(limits_exceeded / launch_refused)・持ち分を消す - else 許された - L->>T: 1 回のトランザクション: ループ(条件 lapRuns・currentRunId 空)・実行・ジョブ・枠 - alt 条件で落ちた - L->>L: 読み直して 3 回まで。ループが変わっていれば何もしない - else - L->>C: launch(ECS か渡す口) - end - end - end -``` - -- **`EXCLUDE_ISSUES` は、トランザクションの直前に強い整合(`ConsistentRead`)で読んだ同じリポジトリの課題の持ち分の番号である。** 読みと手動の開始の間は、マイルストーンの持ち分の行で直列になる(決定 11)。ループの作成がマイルストーンの持ち分を書いた後に通る手動の開始は、その行の照合で落ちる。作成より前に通った手動の開始の持ち分は、作成より後に行うこの読みに必ず現れる -- **起こすのは 4 か所だけ**: 始めた直後・停止の後の処理(そのループ)・停止の後の処理で枠が減ったとき(同じアカウントの待っているループを `wakeWaitingLoops` が古い順に 1 本ずつ)・5 分ごとの見回り(`WAITING_LOOPS` をすべて)。`local` の `user_start` は操作だけが起こす(I52) - -### 停止の後の処理(`finalize`)へ足すもの - -既存の順(`job.finished` を読む → 実行と枠を移す → 最後にジョブを終わりへ)を変えない。実行と枠を移すトランザクションへ次を足す。 - -| 足すもの | いつ | -| --- | --- | -| 実行を `awaiting_reset`(`resumeAt`)にする | `job.finished` が `stopped`・`usage_limit` で、形態が `local`(I54。ほかの形態では `failed`) | -| 実行の `finishReason` | `job.finished` に `reason` がある | -| 課題の持ち分を消す(`sprintRunId` がその実行の行だけ)・`legacyCount` を減らす(実行の `legacy` が真のときだけ)。実行を移す書き込みに、読んだ時の `legacy` の値を条件として入れる(真なら `legacy = true`、偽なら `legacy = false`、空なら `attribute_not_exists(legacy)`)。落ちたら実行と持ち分を読み直してやり直す(読んだ後に埋め戻しが `legacy` を真にし `legacyCount` と持ち分を書いたとき、減算と削除を漏らして数と持ち分が永久に残らないため) | 実行が終わりの状態へ移る | -| 手元のランナーの枠を減らす | ジョブを終わりへ移す最後の書き込み(`runnerId` がある) | -| ループの写し(決定 4 の表)・`lapsClosed` などの和・`pending`・持ち分の削除 | 実行が `loopId` を持つ。`loop.lap_closed` と `loop.stopped` をジョブの冪等の鍵の範囲で読む | - -トランザクションが通った後に、ループが `between_laps` なら `startNextLap`、枠が減ったなら `wakeWaitingLoops` を呼ぶ。 -どちらも落ちてよく、次の見回りが同じ結果で起こし直す(非機能の可用性)。 - -### ランナーの区間(`START` の 3 つに共通) - -```mermaid -sequenceDiagram - participant M as ランナーの手順 - participant S as ランナーの区間 - participant Cl as claude -p - participant N as NDF(loop.json) - participant I as 進捗の受け口 - M->>S: 最初の指示(new・resume・loop) - loop 区間ごと - S->>I: section.started(番号・コマンド) - S->>Cl: claude -p <指示> - Cl-->>S: 最後の result - alt local で利用上限 - S->>M: usage_limit(リセット時刻) - else ループの周回が締まった・止まる条件が書かれた - S->>M: 終える - else ndf-next が 1 つで結果が判断が要るでない - alt 区間の上限に達している - S->>M: section_limit - else - S->>S: ブロックの中身を次の指示にする - end - else - S->>M: 終える(今の判定のまま) - end - end - M->>N: loop.json を読む(ループの実行だけ) - M->>I: loop.lap_closed・loop.stopped - M->>M: 状態を保存(gate・usage_limit・ループの実行) - M->>I: gate.reached(あれば)・job.finished(結果・理由・リセット時刻) -``` - -- **区間の数には最初の起動を数える。** 区間の上限 20 は 20 回目の `claude -p` の後に次を起動しないことを指す -- **周回の締めの判定は区間ごとに行う。** ジョブの始めの `loop.json` の締めた周回の数より増えたか、`stop` がジョブの始めより後に書かれていれば、`ndf-next` があっても次の区間を起動しない。ループの次の周回は実行の制御が新しい実行で起こす(周回 1 回がスプリントの実行 1 本) -- **使用量(`usage.recorded`)は区間ごとに今のとおり送る。** 打ち切りの時間はジョブの全体に掛かり、区間で延びない - -### `START=loop` のランナーの手順 - -既存の手順 1〜4 と 8 は変えず、5〜7 を次にする。`START=resume` でも `LOOP_NAME` があれば 5・6 の鍵と書き込みは同じである。 - -| 手順 | 中身 | -| --- | --- | -| 5. 状態の復元 | `POST /run-jobs/state`(`get`)。鍵はループの `loops//ndf-state.tar.gz`。404 は最初の周回として空から始める。戻すのは `.ndf/tmp`・`.ndf/loop`・`.ndf/handoff/loop-.md`(I42 の許すパスを 1 つ足す。追跡するファイルは今のとおり断る) | -| 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=` を置く | - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 可用性 | ループの次の周回は、実行の制御のどの処理が途中で止まっても、見回りか知らせの再送で同じ結果へやり直せる(1 つの周回の実行を 2 本起こさない) | 周回の開始は `lapRuns` と `currentRunId` を条件にした 1 回のトランザクション(I44)。ループの写しは実行の終わりと同じトランザクション。起こし直しは見回りの `WAITING_LOOPS` | 開始のトランザクションの後で止めた・2 つの呼び手が同時に `startNextLap` を呼んだテストで、実行が 1 本だけになる | -| 性能・拡張性 | 常駐のプロセスを足さない。手元のランナーの同時のジョブの数は `max_jobs` を上限とする | ループの制御は API と既存の Lambda の中だけで動く。受け取りは手元のランナーの枠を条件に書く(I50) | 新しい常駐の定義が無い(インフラの差分が無い)。`max_jobs` 1 のランナーへ 2 本目を渡さないテスト | -| 運用・保守性 | ループの状態と止まった理由、区間の並び、`usage_limit` の回復の時刻が画面から読める | 一覧の列と詳細の区間・`resumeAt` の表示(入出力の契約の文書の「画面」) | 画面のテスト | -| 移行性 | 既存の実行・指示・事象は互換のまま足す。既存の登録に `max_jobs` が無ければ 1 | 足す欄はすべて省略でき、無い値を 0・空・1 で読む。`START` の enum と `job.finished` の欄は足すだけ。並行の開始は、既存の実行を埋め戻して移行の済みの行を書いた後だけ許す(「データ構造」の「移行」) | 既存の Factory のテストと契約の検査が変えずに通る | -| セキュリティ | サービスは `local` のアカウントと認証を知らない。ループの操作は、そのリポジトリの登録を持つアカウントだけ | 利用上限の事象はリセット時刻だけを持ち、アカウントの名前を載せない。ループの経路は `resolveFromRequest` の後に、ループの `accountId` と登録を照らす(他人は 403、無ければ 404) | 他のアカウントのループを操作して 403。事象の形にアカウントの欄が無い | - -## 決定の記録 - -[issue-2347-design-decisions.md](issue-2347-design-decisions.md) にある。 - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #2347 | 実装する | — | `factory/api/src/runs/`、`factory/api/src/accounts/limits.ts`、`factory/api/src/github/`、`factory/api/src/progress/read.ts`、`factory/api/src/repositories/service.ts`、`factory/api/src/routes/factory.ts`、`factory/runner/src/`、`factory/contracts/`、`factory/web/src/pages/`、`plugins/ndf/contracts/`、`plugins/ndf/scripts/lib/sprint_loop_select.py`、`scripts/contracts_lib/model.py`、`docs/glossary/glossary.json`、`docs/glossary.md` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| 並行を許す版の下限(未決 5) | #2164 のリリースで決まる。設定 `NDF_PARALLEL_MIN_VERSION` が無い間は、どの版も古い版として扱い、同じリポジトリの実行を 1 本に絞る(安全な側) | -| Anthropic への確認(未決 6) | 結果が #2189 に記録されるまで、`LOCAL_LOOP_AUTOSTART` は既定 `false` のまま。設定を変えるのは利用者の判断 | -| `local` の利用上限の後の自動の再開 | 定義 7 のとおり、リセット時刻の後にサービスが `resume` のジョブを積む。手元のランナーが受け取って動かすため、定義 5 の「サービスがジョブを自動で起こさない」の例外になる。承認ゲート 1 で利用者がこの扱いを見る | -| `claude -p` の利用上限の出力の形 | 上限に当たった `claude -p` の stream-json を手元で再現できていない。検出は NDF と同じ文言の規則(`usage limit reached\|`・`resets <時刻> (<時間帯>)`)と、`result` の `is_error` で読む。実物の形は実装で偽の `claude` と、上限に当たった実行の記録で確かめる | -| #2189・#2173 の順 | 手元のランナーの登録と心拍(#2189)と承認の依頼の一覧(#2173)はまだ無い。この課題は `claimLocalRunJob` の条件・申告の形・目安の関数と、`gate.reached` の `loop` の欄を持ち、#2189・#2173 の経路と一覧がそれを使う。先にこの課題が入ったときは、#2189・#2173 の設計がこの形を入力に取る | -| 取り込みの衝突(受け入れ条件 7) | NDF の base-sync は衝突で止まると `attention` の行を書くが、`reason` に `base_conflict` の語は無い(2026-11-02 に `grep -rn base_conflict plugins/ndf/scripts` が 0 件)。翻訳が行の `step` から写す(決定 10)。行が `step` を持つ形は実装で、base-sync を衝突で止めた `progress.jsonl` で確かめる | diff --git a/issues/issue-2347-requirements.md b/issues/issue-2347-requirements.md deleted file mode 100644 index 29281fcea..000000000 --- a/issues/issue-2347-requirements.md +++ /dev/null @@ -1,294 +0,0 @@ -# #2347: NDF Factory: 開発ループ・並行する実行の調整・ジョブの中の区切りと利用上限・手元のランナーの同時実行数・ループの見え方を定義する - -正は課題の本文(#2347)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -親: #2168。前提: #2186(スプリントの実行とランナー)・#2189(手元のランナー)・#2172(一覧と詳細)・#2173(承認と通知)。 - -## 具体例 - -2026-11-01、このリポジトリでは開発ループを 3 本並列で回すと決めた(Factory の `l20261031`・根本解決の `lroot`・開発フローの見直しの `lflow`。どれも同じリポジトリの develop へ入る)。そのとき決めたことは次の 4 つである。 - -- ループごとに選定の対象のマイルストーンを分ける -- 同時に流すプランは 1 本のループにつき 2 本までにする(全体テスト 6 本の同時実行が実測の上限) -- 版の衝突を避けるため、版数を流す時点に決める #2164 が入るまで始めない -- 触る場所の重なり(`plugins/ndf/` を 2 本が触る)は取り込みの時点で解く - -Factory で同じことをしようとすると、どれも置き場が無い。この課題は、それらをすべて Factory の仕様として定義する。 - -## 依頼(原文) - -> Neural Development Factory ではこのあたりの並行開発のオーケストレーションはどのような構想になっていますか?(または未定義ですか?) - -(書き手: 利用者(起票者がこの課題の「由来」に引用)、日付: 2026-11-01) - -> 決まっていないこと、は全て定義する必要があります - -(書き手: 利用者(同上)、日付: 2026-11-01) - -> 恐らく、factory の場合 relay.py に相当するオーケストレーターが低コストで常に動いているのではないでしょうか - -(書き手: 利用者(同上)、日付: 2026-11-01) - -上の 3 つ以外の題・本文(「具体例」「何が決まっていなかったか」の表、「定義」1〜7、元の受け入れ条件 1〜13)は起票者(課題を書いた AI、2026-11-01)の文で、利用者の指示の原文としては扱わない。「定義」の値は NDF の開発ループ(`plugins/ndf/skills/development-workflow/references/loop.md`)と 2026-11-01 の 3 本並列の運用に合わせて起票者が決めた(判断の記録は `ask.jsonl`)。起票者が足した条件は次の「起票者の推定」に並べ、承認ゲート 1 で利用者が見直す。 - -## 起票者の推定 - -承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 開発ループを Factory の新しい集約にし、リポジトリの登録に属させる。周回 1 回がスプリントの実行 1 本に当たる | 定義 1(起票者)。NDF の開発ループの「周回」の定義 | 受け入れ条件 1 | -| S2 | 課題の選定と周回の締めはランナーの中の NDF(`/ndf:development-workflow --loop`・`sprint-state.py loop`)が行い、Factory は選定の規則を持たない | 定義 1(起票者)。全体の契約の腐敗防止層の向き | 受け入れ条件 1・前提 3 | -| S3 | ループの状態は `running`・`between_laps`・`awaiting_approval`・`stopped`・`cancelled` の 5 つ | 定義 1(起票者) | 受け入れ条件 2・前提 4・5 | -| S4 | 予算は NDF の `loop.budget` と同じ 3 つ(周回の数・費用・時間) | 定義 1(起票者)・`.ndf/pace.json` の `loop.budget` | 受け入れ条件 1 | -| S5 | NDF の止まる条件はランナーが進捗の事象 `loop.stopped`(条件と値)で送り、Factory はそれをループの状態へ写す。Factory が足す条件は「実行の枠が空かない」だけで、空くまで `between_laps` で待つ | 定義 1(起票者) | 受け入れ条件 2・3 | -| S6 | 実行の枠を数えるのは動いている実行だけで、`between_laps` のループは枠を使わない | 定義 1(起票者) | 受け入れ条件 3 | -| S7 | 選定の対象のマイルストーンが重なる 2 つのループは同じリポジトリで同時に動かせない(開始で断る) | 定義 1(起票者)。具体例の「ループごとに選定の対象のマイルストーンを分ける」 | 受け入れ条件 4 | -| S8 | 同じ課題を 2 つの動いている実行に入れない。開始の入力とループの選定の両方で外し、ループには `EXCLUDE_ISSUES` を渡して NDF の `loop exclude` で外す | 定義 2(起票者) | 受け入れ条件 5 | -| S9 | 同じリポジトリで並行する実行を許すのは #2164 を含む NDF の版を使う実行だけで、それより古い版では 1 本に絞る | 定義 2(起票者)。具体例の「#2164 が入るまで始めない」 | 受け入れ条件 6・前提 6 | -| S10 | 触るファイルの重なりは止めずに取り込み(base-sync)の時点で NDF が解き、解けなければ `attention`(`reason: base_conflict`)を送る | 定義 2(起票者)。具体例の「取り込みの時点で解く」 | 受け入れ条件 7 | -| S11 | リポジトリごとの同時の実行の数はプランの表が決め、宣言でそれより小さくできる | 定義 2(起票者) | 受け入れ条件 8 | -| S12 | 手元のランナーは登録と心拍で `max_jobs`(既定 1)を申告し、サービスはそれを超えて指示を渡さない。起動時の CPU の数とメモリから目安を出し、申告が超えれば警告する | 定義 3(起票者)。2026-09-25 の実測(8 コア・30 GB で全体テスト 6 本が上限) | 受け入れ条件 9・10 | -| S13 | #2346 の relay.py のホストを手元のランナーとして登録でき、そのときの `max_jobs` はホストの同時のセッションの上限 | 定義 3(起票者) | 含まない(#2346 側の実装)。契約の欄だけを受け入れ条件 9 で持つ | -| S14 | 画面にループの一覧(名前・選定の対象・状態・周回の数・費用・予算の残り・最後に動いた時刻・止まった理由)と詳細(周回ごとの実行・人の判断待ちの課題)を足す。操作は始める・止める・予算を変える・人の判断待ちに答える | 定義 4(起票者) | 受け入れ条件 1・11・12 | -| S15 | 承認の依頼にループの名前と周回の番号を載せ、複数のループの依頼を 1 つの一覧に並べる | 定義 4(起票者) | 受け入れ条件 13 | -| S16 | `local` の形態では、Anthropic に確かめるまでサービスがジョブを自動で起こさず、次の周回を起こす前に利用者の承認を求める。確かめた結果は #2189 の本文に記録し、認められればこの置き換えを外す | 定義 5(起票者)。#2189 の「未確認」 | 受け入れ条件 14・前提 7 | -| S17 | オーケストレーターを 2 層に分ける。実行をまたぐ制御は今の実行の制御(呼ばれたときだけ動くサーバーレス。常駐のプロセスを持たない)が持ち、1 つのジョブの中の区切りはランナーが持つ | 定義 6(起票者)。利用者の 3 つ目の原文(「常に動いているのでは」)への答え | 受け入れ条件 15・前提 8 | -| S18 | ランナーは conductor の最後の `result` の `ndf-next` のブロックを読み、`- 結果:` が `判断が要る` でなければ同じジョブの中で次の区間を起動する。区間の上限は既定 20 で、超えたら `section_limit` で終える。区間ごとに `section.started` を送る | 定義 6(起票者) | 受け入れ条件 15・16 | -| S19 | `ndf-next` のブロックの読み方は NDF の relay.py(`relay_lib/mark.py`)と同じ規則にし、規則は NDF の契約(#2336)に宣言し、ランナーは形だけを写す | 定義 6(起票者)。`plugins/` は `factory/` を参照しない境界 | 受け入れ条件 17 | -| S20 | `local` の形態で利用上限に当たったジョブは `job.finished`(`stopped`、理由 `usage_limit`、回復の時刻)で終え、実行の制御は回復の時刻の後に `resume` を起こす。アカウントの切り替えは #2346 のホストが手元で行い、サービスはアカウントを知らない | 定義 7(起票者)。手元の relay.py のアカウントの切り替え(#1389) | 受け入れ条件 18・19 | -| S21 | 進捗の事象と実行ジョブの指示の追加(`loop.stopped`・`section.started`・`LOOP_NAME`・`EXCLUDE_ISSUES`・`START=loop`・`max_jobs`)は `factory/contracts/` の定義と生成した型に置く | 元の受け入れ条件 13(起票者) | 受け入れ条件 20 | - -## 目的 - -- 同じリポジトリで方針の違う開発ループを複数並べる運用(2026-11-01 の 3 本並列)を、Factory の利用者が Web から同じ形で行えるようにする -- 並行する実行どうしの取り合い(課題・版・触るファイル)と、手元の機械の資源の上限を、機械で守る -- 8 時間のジョブの中で conductor の context window が埋まっても、手元の relay.py と同じく区切りで起動し直して続けられるようにする -- 利用者の 3 つの原文に答える: 並行開発のオーケストレーションの構想を、決まっていない点を残さず仕様として定義する - -## 前提 - -- 前提 1: この課題は設計の課題で、成果は Factory の確定仕様(`docs/specifications/ndf-factory-*.md`)へ入る設計文書と、その設計を実装する子の課題である。受け入れ条件は実装の後に観測する振る舞いとして書き、設計の承認(承認ゲート 1)で範囲を確かめる -- 前提 2: 実行の制御(`factory/api/src/runs/`)とランナー(`factory/runner/src/`)の既存の形(`docs/specifications/ndf-factory-runs-and-runner.md`・`ndf-factory-runs-state-and-data.md`)は変えずに足す。実行の枠の数の行(I34・I35)と開始のトランザクションはそのまま使う -- 前提 3: Factory は NDF のループの記録(`.ndf/loop/<名>/loop.json`)を直接読まず、ランナーが進捗の事象へ翻訳したものだけを読む(腐敗防止層の向き) -- 前提 4: NDF の止まる条件のうち、周回の途中で止まる `gate2_returned` はループの状態の `awaiting_approval` へ写し、`stopped` にしない。答え(承認)で `resume` が続く。ほかの条件(`user_stop`・`mvv_changed`・`same_failure`・`budget_laps`・`budget_usd`・`budget_hours`・`no_issue`・`outside_mvv`・`refused`)は `stopped` へ写し、条件と値を止まった理由に残す。`refused` は元の定義 1 の列挙に無いが、`loop.md` が周回の途中で止まる条件として持つため足す -- ~~前提 5: 利用者が画面から止めたループは `cancelled` にし、ランナーへは NDF の `loop stop` に当たる知らせを渡す。周回の実行が動いていれば、今の取り消し(`cancelSprintRun`)と同じ形で止める~~ → 止めたループは `cancelled` にし、周回の実行が動いていれば今の取り消し(`cancelSprintRun`)で止める。NDF の `loop stop` に当たる知らせは渡さず、ループの `cancelled` そのものを知らせとする(2026-11-02、設計の決定 15。取り消したループには次のジョブが起きず、止める印を書いても読む者がいないため) -- 前提 6: 「#2164 を含む NDF の版」は、#2164 を含んで出た最初の NDF の版数を下限とし、実行が使う NDF の版数(実行ジョブの指示が持つ版)と比べて決める。下限の版数は #2164 のリリースの後に決まり、設計はその版数を設定の値として持つ(コードに埋め込まない) -- 前提 7: `local` の形態の「次の周回を起こしてよいか」の承認は、承認の依頼(#2173)の新しい種類として画面と通知に並ぶ。今の 3 種類(ゲート 1・ゲート 2・判断)のどれかへ寄せるか新しい種類にするかは設計で決める(未決 1) -- 前提 8: 「低コストで常に動いているオーケストレーター」(利用者の 3 つ目の原文)は、常駐のプロセスとしては作らない。実行をまたぐ制御は今の呼ばれたときだけ動く実行の制御(事象と見回りで起きる)に置き、ジョブの中の区切りだけをランナー(ジョブの間だけ動く)に置く。常駐の費用は増えない -- 前提 9: 区間の上限の既定 20 とジョブの打ち切りの時間 8 時間は、今の手元の relay.py の運用の値に合わせた起票者の値で、設計の時点で実測(手元の relay の区間の数の分布)があれば置き換える -- 前提 10: 利用上限の検出は `claude -p` の結果(`result` の文と終了の形)から読む。`managed`・`byok` の形態では上限の切り替えをせず、API の失敗は今の失敗の扱いのまま - - -## 対象範囲 - -含む: -- 開発ループの集約(持つ値・状態・周回の起こし方・止まる条件の写し方・承認ゲート・実行の枠との関係・同じリポジトリの複数のループ) -- 同じリポジトリで並行する実行の調整(同じ課題の取り合い・版・触るファイルの重なり・本数の上限) -- 手元のランナーの同時実行数(`max_jobs` の申告と、資源の目安の警告) -- ループの見え方と承認(一覧・詳細・承認の依頼の並べ方・画面からの操作) -- `local` の形態の自動実行を確かめるまでの扱い -- ジョブの中の区切り(`ndf-next` を読んで区間を起動する)と区間の上限 -- `local` の形態の利用上限での終わり方と回復の後の再開 -- 上の追加が要る契約(進捗の事象・実行ジョブの指示・ランナーの登録と心拍)の定義と型 - -含まない: -- 課題の選定の規則(NDF の開発ループが持つ。Factory は持たない) -- #2164(版数を流す時点に決める)の実装。この課題は #2164 を含む版かどうかで並行を絞るだけである -- #2346(relay.py が複数の claude セッションを統括する)の実装と、手元のアカウントの切り替え。サービスはアカウントを知らない -- Anthropic への確認そのもの(結果は #2189 に記録する) -- 1 本のループの中で同時に流すプランの数(具体例の「1 本のループにつき 2 本まで」)。NDF の実行計画の並列の数が持ち、Factory はジョブの数だけを数える -- `managed`・`byok` の形態の利用上限の切り替え - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 開発ループを始めた | 利用者の画面の操作 | 選定の対象のマイルストーンが同じリポジトリの動いているループと重なる → 理由つきで断る。リポジトリの登録が無い → 断る | — | -| E2 | 周回の実行を起こした(`START=loop`・`LOOP_NAME`・`EXCLUDE_ISSUES`) | E1、または E8 の後の実行の制御 | 実行の枠が空かない → `between_laps` で待ち、枠が空いた知らせで起こし直す。`local` では E10 を先に通す | E1 か E8 | -| E3 | 周回の課題を選んだ(ランナーの中の NDF の `loop select`) | E2 のジョブの conductor | 候補が 0 件 → E7(`no_issue`)。候補がすべて外れた → E7(`outside_mvv`) | E2 | -| E4 | ジョブの中で次の区間を起動した(`section.started`) | conductor の `result` の `ndf-next`(`判断が要る` でない) | 区間の上限を超えた → `job.finished`(`stopped`・`section_limit`) | E2 | -| E5 | 承認ゲート 2 で止まった | NDF の `gate2_returned` | — | E3 | -| E6 | 人の判断待ちの課題ができた(承認ゲート 1 で MVV 判定が「従う」以外) | NDF の承認ゲート 1 | 周回は残りの課題で続く | E3 | -| E7 | ループが止まった(`loop.stopped`) | ランナーが送る NDF の止まる条件 | 事象が届かずにジョブが終わった → 見回りが今のジョブの失敗として扱う(ループは `stopped`、理由は失敗) | E2 | -| E8 | 周回の実行が終わった(`done`・人へ戻した) | `job.finished` と停止の後の処理 | 失敗 → NDF の `same_failure` の数え方に任せ、次の周回を起こす | E2 | -| E9 | 取り込みで衝突を解けなかった(`attention`・`base_conflict`) | ランナーの中の NDF の base-sync | 実行は止まり、画面に並ぶ | E2 | -| E10 | 次の周回を起こす承認を求めた(`local` の形態) | E8 の後、形態が `local` | 利用者が答えない → `between_laps` のまま待つ | E8 | -| E11 | 利用上限でジョブを終えた(`usage_limit`・回復の時刻) | `claude -p` の結果(`local` の形態) | 回復の時刻を読めない → 設計で決める既定の待ち(未決 3) | E2 | -| E12 | 回復の後に再開を起こした(`resume`) | 実行の制御の見回りが回復の時刻を過ぎたと見た | 枠が空かない → 空くまで待つ | E11 | -| E13 | 利用者がループを止めた(`cancelled`) | 利用者の画面の操作 | ループが既に終わりの状態 → 断る | E1 | -| E14 | 手元のランナーが `max_jobs` を申告した | 登録と心拍 | 資源の目安を超える → 警告して申告を受ける | — | - -引き金の無いイベント・失敗の経路の無いイベントは残っていない。E5 は失敗の経路を持たない(NDF の承認ゲートで止まること自体が経路である)。E7 の「事象が届かない」は前提 4 と受け入れ条件 2 で扱う。 - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 開発ループ | スプリントを 1 つ終えるたびに次のスプリントを選んで続けて始める進め方。Factory ではリポジトリの登録に属する集約で、周回ごとにスプリントの実行を 1 本起こす | -| 周回 | 開発ループの 1 回分。Factory ではスプリントの実行 1 本に当たる | -| ループの予算 | 開発ループが使ってよい量の上限(周回の数・費用・経過時間) | -| 止まる条件 | 開発ループを止める、機械で判定できる条件 | -| 区間 | 1 つの実行ジョブの中で、ランナーが起動する `claude -p` の 1 回分。`ndf-next` で次の区間へ移る | -| 区間の上限 | 1 つの実行ジョブで起動してよい区間の数の上限(既定 20) | -| 同時のジョブの数 | 手元のランナーが同時に動かす実行ジョブの数の上限(`max_jobs`)。ランナーが申告する | - -## 定義 - -### 1. 開発ループ(新しい集約) - -**開発ループは、1 つのリポジトリの登録に属し、スプリントの実行を順に起こし続ける集約である。** 周回 1 回がスプリントの実行 1 本に当たる。課題の選定と周回の締めは、ランナーの中の NDF の開発ループ(`/ndf:development-workflow --loop`、`sprint-state.py loop`)がそのまま行い、Factory は選定の規則を持たない(腐敗防止層の向きを変えない)。 - -| 項目 | 定義 | -| --- | --- | -| 持つ値 | 名前(NDF のループ名の規則)・リポジトリの登録・選定の対象のマイルストーン(1 つ以上)・予算(周回の数・費用・時間。NDF の `loop.budget` と同じ 3 つ)・状態・今の周回の実行・止まった理由 | -| 状態 | `running`(周回の実行が動いている)→ `between_laps`(次の実行を起こす前)→ `awaiting_approval`(承認ゲート 2 で止まった)→ `stopped`(止まる条件に当たった)/ `cancelled`(利用者が止めた)。写し方は前提 4・5 | -| 周回の起こし方 | 実行の制御が、前の実行の終わり(`done` か「人へ戻した」)を受けて、同じループの次の実行を `START=loop` で起こす。指示に `LOOP_NAME` を足し、ランナーは `/ndf:development-workflow --loop <名前>` で conductor を起動する | -| 止まる条件 | NDF の止まる条件(`user_stop`・`mvv_changed`・`same_failure`・`budget_laps`・`budget_usd`・`budget_hours`・`no_issue`・`outside_mvv`・`gate2_returned`・`refused`)をランナーが進捗の事象 `loop.stopped`(条件と値)で送り、Factory はそれをループの状態へ写す。Factory が足す条件は「実行の枠が空かない」だけで、空くまで `between_laps` で待つ | -| 承認ゲート | 承認ゲート 1 で MVV 判定が「従う」以外の課題は、NDF の規則どおり人の判断待ちへ移り、周回は残りで続く。人の判断待ちの課題は承認の依頼(#2173)として画面に並ぶ。承認ゲート 2 は今の承認の依頼と同じで、答えで `resume` が続く | -| 実行の枠 | 枠を数えるのは動いている実行だけで、ループそのものは数えない。`between_laps` のループは枠を使わない | -| 同じリポジトリの複数のループ | 選定の対象のマイルストーンが重なるループは同じリポジトリで同時に動かせない(開始で断る)。重ならなければ並べてよい | - -### 2. 同じリポジトリで並行する実行の調整 - -| 観点 | 定義 | -| --- | --- | -| 同じ課題の取り合い | 同じ課題を 2 つの動いている実行に入れない。開始の入力(課題の並び)と、ループの周回の選定の両方で、動いている実行が持つ課題を外す。実行の制御は動いている実行の課題の並びをランナーへ `EXCLUDE_ISSUES` で渡し、NDF の `loop exclude` で選定から外す | -| 版 | 版数は流す時点に NDF が決める(#2164)。同じリポジトリで並行する実行を許すのは、#2164 を含む NDF の版を使う実行だけで、それより古い版では同じリポジトリの実行を 1 本に絞る(前提 6) | -| 触るファイルの重なり | 重なりは止めずに、取り込み(base-sync)の時点で NDF が解く。解けずに止まった実行は `attention`(`reason: base_conflict`)を送り、画面に並ぶ | -| 本数の上限 | リポジトリごとの同時の実行の数はプランの表が決め(今の実行の枠)、宣言でそれより小さくできる | - -### 3. 手元のランナーの同時実行数 - -| 項目 | 定義 | -| --- | --- | -| 上限 | ランナーは登録と心拍で `max_jobs`(同時に動かすジョブの数、既定 1)を申告し、サービスは動いているジョブがその数に達したランナーへ指示を渡さない | -| 資源 | ランナーは起動の時点の CPU の数とメモリから上限の目安を出し、申告がそれを超えれば警告する(2026-09-25 の実測: 8 コア・30 GB で全体テスト 6 本の同時実行が上限) | -| relay のホストとの関係 | #2346(relay.py が複数の claude セッションを統括する)のホストは、手元のランナーとして登録できる。そのときの `max_jobs` はホストの同時のセッションの上限である | - -### 4. 見え方と承認 - -| 項目 | 定義 | -| --- | --- | -| ループの一覧 | 画面(#2172)にリポジトリごとのループの一覧を足す。列は名前・選定の対象・状態・周回の数・費用・予算の残り・最後に動いた時刻・止まった理由 | -| ループの詳細 | 周回ごとの実行(課題・結果・版・費用)と、人の判断待ちの課題の並び | -| 承認の依頼 | 承認の依頼(#2173)にループの名前と周回の番号を載せ、複数のループの依頼を 1 つの一覧で並べる | -| 操作 | 画面から、ループを始める・止める・予算を変える・人の判断待ちに答える(NDF の `loop answer`) | - -### 5. 手元のランナーの自動実行(#2189 の未確認の扱い) - -**確かめるまで、`local` の形態ではサービスがジョブを自動で起こさない。** 開発ループの周回の起こし方(定義 1)は、`local` の形態では「次の周回を起こしてよいか」を利用者に問う承認の依頼に置き換える。Anthropic に確かめた結果は #2189 の本文に記録し、認められればこの置き換えを外す。`managed`・`byok` の形態は今のまま自動で起こす。 - -### 6. ジョブの中の区切り(relay.py の役割をランナーへ) - -**オーケストレーターの役割は 2 層に分ける。** 実行をまたぐ制御(開始・停止の知らせ・見回り・周回の起こし方)は今の実行の制御(呼ばれたときだけ動くサーバーレス。常駐のプロセスを持たない)が持つ。1 つのジョブの中の区切りは、手元の relay.py と同じ役割をランナーが持つ。 - -| 項目 | 定義 | -| --- | --- | -| 区切りの検出 | conductor の最後の `result` の文に `ndf-next` のブロックがあり、`- 結果:` の行が `判断が要る` でないとき、ランナーはそのジョブの中で次の区間を `claude -p <ブロックのコマンド>` で起動する | -| 区間の数 | 1 つのジョブの区間の上限を持ち(既定 20)、超えたら `job.finished`(`stopped`、理由 `section_limit`)で終える。打ち切りの時間(8 時間)はジョブ全体に掛かる | -| 事象 | 区間を起動するたびに `section.started`(区間の番号・コマンド)を送り、画面の詳細に区間の並びが出る | -| 状態の引き継ぎ | 区間は同じコンテナ・同じ作業場所で続くため、S3 への保存は要らない。保存はジョブの終わり(承認ゲート・終わり)だけのまま | -| 共通の部品 | `ndf-next` のブロックの読み方は NDF の relay.py(`relay_lib/mark.py`)と同じ規則にする。規則は NDF の契約(#2336)に宣言し、ランナーは形だけを写す(`plugins/` は `factory/` を参照しない) | - -### 7. 利用上限での切り替え(`local` の形態) - -| 項目 | 定義 | -| --- | --- | -| 検出 | conductor が利用上限で止まったことを、ランナーが `claude -p` の結果から読む | -| 扱い | `local` の形態では、ランナーはそのジョブを `job.finished`(`stopped`、理由 `usage_limit`、回復の時刻)で終え、実行の制御は回復の時刻の後に `resume` のジョブを起こす。複数のアカウントの切り替えは #2346 のホストが手元で行い、サービスはアカウントを知らない | -| `managed`・`byok` | 上限の切り替えは要らない。API の失敗は今の失敗の扱いのまま | - -## 何が決まっていなかったか(確定仕様 `docs/specifications/ndf-factory-*.md` を 2026-11-01 に読んだ) - -| 観点 | 今の仕様 | 足りなかったもの | 埋める定義 | -| --- | --- | --- | --- | -| 開発ループ | 開始のたびに課題の番号を 1〜20 件渡す。状態の保存に `.ndf/loop` は入る(I42) | ループを始める・止める・予算を持つ入口、周回、方針ごとにループを並べる形 | 1・4 | -| 同じリポジトリの並行 | 実行の枠がリポジトリごとの本数だけを数える | 同じ課題の取り合い、版の衝突、触るファイルの重なりの扱い | 2 | -| 手元のランナーの並行 | #2189 は登録・心拍・指示の受け取りだけ | 1 つのランナーが同時に動かすジョブの数 | 3 | -| 見え方と承認 | #2173 が承認の依頼を知らせる | 複数のループのどれが待っているかを並べて見る形 | 4 | -| ジョブの中の区切り | ランナーは 1 つのジョブで `claude -p` を 1 回だけ起動し、conductor の `ndf-next` を読まない(`factory/runner/src/` に語が無い。2026-11-02 に `grep -rn ndf-next factory/runner/src` が 0 件) | 手元の relay.py が持つ「区切りで claude を起動し直す」役割。ジョブは最大 8 時間で、その間に conductor の context window が埋まる | 6 | -| 利用上限での切り替え | `managed`(サービスの Bedrock)と `byok` は上限の切り替えが要らない | `local`(利用者の `claude` ログイン)で利用上限に当たったときの扱い。手元の relay.py はアカウントを替えて続ける(#1389) | 7 | -| 手元のランナーの自動実行 | #2189 の「未確認」: サービスが起動する自動実行が Claude のプラン(Pro・Max)の使い方として認められるか | 確かめるまでの扱い | 5 | - -## 受け入れ条件 - -- [ ] 1. 利用者が画面から、リポジトリの登録ごとに開発ループを始め・止め・予算(周回の数・費用・時間)を変えられ、ループは周回ごとにスプリントの実行を `START=loop`・`LOOP_NAME` つきで起こし続ける。周回の課題の選定と締めはランナーの中の NDF の開発ループが行い、Factory のコードに選定の規則が無い -- [ ] 2. NDF の止まる条件(前提 4 の 10 個)はすべて進捗の事象 `loop.stopped`(条件と値)で届き、前提 4 の写し方どおりにループの状態(`stopped` か `awaiting_approval`)と止まった理由として画面に出る。`loop.stopped` が届かずにジョブが終わったループは、失敗を理由として `stopped` になる -- [ ] 3. 実行の枠が空くまで次の周回を起こさず、`between_laps` のループは枠を使わない(枠の数は動いている実行の数と等しいまま。I34 が崩れない)。枠が空いたら、待っていたループの次の周回が起きる -- [ ] 4. 選定の対象のマイルストーンが重なる 2 つのループを同じリポジトリで始めようとすると、後の方を理由(重なったマイルストーンと相手のループの名前)つきで断る。重ならない 2 つは始められる -- [ ] 5. 同じ課題が 2 つの動いている実行に入らないことを、開始の入力(課題の並びで動いている実行の課題を指すと断るか外す)とループの選定(`EXCLUDE_ISSUES` に動いている実行の課題が並ぶ)の両方で確かめるテストがある -- [ ] 6. #2164 を含まない NDF の版を使う実行は、同じリポジトリで 1 本に絞られる(2 本目の開始が理由つきで断られる)。下限の版数は設定の値で持つ -- [ ] 7. 取り込みで解けない衝突は `attention`(`reason: base_conflict`)として画面に並ぶ -- [ ] 8. リポジトリごとの同時の実行の数は、プランの表の値と、宣言した小さい方の値のうち小さい方で断られる -- [ ] 9. 手元のランナーは登録と心拍で `max_jobs`(既定 1)を申告し、サービスは動いているジョブがその数に達したランナーへ指示を渡さない -- [ ] 10. 手元のランナーは起動の時点の CPU の数とメモリから同時のジョブの数の目安を出し、申告が目安を超えると警告を出す(申告は受ける) -- [ ] 11. 画面にリポジトリごとのループの一覧(名前・選定の対象・状態・周回の数・費用・予算の残り・最後に動いた時刻・止まった理由)がある -- [ ] 12. 画面にループの詳細(周回ごとの実行の課題・結果・版・費用と、人の判断待ちの課題の並び)があり、人の判断待ちの課題に画面から答えると NDF の `loop answer` が働く -- [ ] 13. 承認の依頼にループの名前と周回の番号が載り、複数のループの承認の依頼が 1 つの一覧に並ぶ -- [ ] 14. `local` の形態では、次の周回を起こす前に利用者の承認を求め、承認されるまで次の実行を起こさない。Anthropic の確認の結果が #2189 にあり認められていれば、設定でこの置き換えを外せる。`managed`・`byok` は承認を挟まずに起こす -- [ ] 15. ランナーは conductor の最後の `result` の `ndf-next` のブロックを読み、`- 結果:` が `判断が要る` でないとき同じジョブの中で次の区間を起動する。`判断が要る` のときとブロックが無いときは起動しない。区間ごとに `section.started`(区間の番号・コマンド)が届く -- [ ] 16. 区間の数が上限(既定 20)を超えたジョブは `job.finished`(`stopped`、理由 `section_limit`)で終わる。打ち切りの時間(8 時間)は区間の合計に掛かる -- [ ] 17. `ndf-next` のブロックの読み方の規則は NDF の契約(#2336 の宣言)にあり、ランナーの読み方と NDF の relay.py(`relay_lib/mark.py`)の読み方が同じ入力で同じ答えを返すことを確かめるテストがある。`plugins/` は `factory/` を参照しない(`python3 scripts/check-service-boundary.py` が通る) -- [ ] 18. `local` の形態で利用上限に当たったジョブは `job.finished`(`stopped`、理由 `usage_limit`、回復の時刻)で終わり、実行の制御が回復の時刻の後に `resume` のジョブを起こす -- [ ] 19. `managed`・`byok` の形態では `usage_limit` の扱いをせず、API の失敗は今の失敗の扱いのまま(既存のテストが通る) -- [ ] 20. 進捗の事象と実行ジョブの指示の追加(`loop.stopped`・`section.started`・`LOOP_NAME`・`EXCLUDE_ISSUES`・`START=loop`・`max_jobs`)は `factory/contracts/` の定義と生成した型にあり、契約の検査が通る -- [ ] 21. 既存の開始の入口(課題の番号を 1〜20 件渡すスプリントの実行)の振る舞いは変わらない(既存の Factory のテストが通る) - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 可用性 | ループの次の周回は、実行の制御のどの処理が途中で止まっても、見回りか知らせの再送で同じ結果へやり直せる(1 つの周回の実行を 2 本起こさない) | -| 性能・拡張性 | 常駐のプロセスを足さない(前提 8)。手元のランナーの同時のジョブの数は `max_jobs` を上限とする | -| 運用・保守性 | ループの状態と止まった理由、区間の並び、`usage_limit` の回復の時刻が画面から読める | -| 移行性 | 既存のスプリントの実行と実行ジョブの指示・進捗の事象は互換のまま足す(足す欄は省略できる)。既存のランナーの登録に `max_jobs` が無ければ 1 として扱う | -| セキュリティ | サービスは `local` の形態のアカウントと認証を知らない(アカウントの切り替えは手元で行う)。ループの操作は、そのリポジトリの登録を持つアカウントだけができる | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わる。Factory の API にループの入口を足し、進捗の事象・実行ジョブの指示・ランナーの登録と心拍に欄を足す。どれも足すだけで、既存の欄は変えない(互換あり) | -| データ | 開発ループの集約の表(または行)を足す。既存の実行・実行ジョブ・枠の行の形は変えない | -| 既存の振る舞い | ランナーが 1 つのジョブで `claude -p` を複数回起動するようになる(`ndf-next` があるとき)。`local` の形態で利用上限に当たったジョブの終わり方が `failed` から `stopped`(`usage_limit`)に変わる | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `cd factory && npm test`(vitest。実行の制御・ランナー・契約・画面)、`uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(NDF の契約の宣言と `ndf-next` の読み方) | -| 静的解析・型検査 | 契約の検査(`factory/contracts/` の生成した型と定義の一致)、`python3 scripts/check-service-boundary.py` | -| 手動確認 | 開発版で、`managed` か `byok` の形態のリポジトリに 2 本のループ(マイルストーンが重ならない)を始め、一覧・詳細・承認の依頼の一覧に両方が並ぶことを見る(リリース後テスト) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。`plugins/` と `.claude-plugin/marketplace.json` は `factory/` を参照しない。インフラの定義と秘密はこのリポジトリに入れない | -| コーディング規約 | Factory の既存の規約(`docs/specifications/ndf-factory-service-boundary.md` の集約と持ち主、1 つの処理が書き換える集約は 1 つ)。開発ループの集約を書き換える処理は、スプリントの実行と実行の枠の例外に倣って設計で決める | -| テスト戦略 | 実行の制御の振る舞い(枠・断る条件・周回の起こし方)は API の単体テスト、ランナーの区間と利用上限は偽の `claude` を使うランナーのテスト、契約は契約の検査、画面は既存の画面のテストの階層で持つ | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存の Factory のテストと契約の検査、境界の検査を流す | -| 確認してから行う | 公開インタフェース(API・契約)の追加、データの表の追加。承認ゲート 1・2 で人が見る | -| 行わない | 課題の選定の規則を Factory に持つこと、常駐のプロセスを足すこと、サービスが `local` のアカウントを扱うこと、`plugins/` から `factory/` を参照すること | - -## 未決 - -| # | 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | --- | -| 1 | 「次の周回を起こしてよいか」の承認を、承認の依頼の今の 3 種類のどれかへ寄せるか、新しい種類にするか(前提 7) | 設計(承認ゲート 1 で利用者が見る) | 設計の PR | -| 2 | 人の判断待ちの課題(承認ゲート 1 で MVV 判定が「従う」以外)を、承認の依頼のどの種類で並べるか | 設計 | 設計の PR | -| 3 | 利用上限の回復の時刻を `claude -p` の結果から読めなかったときの既定の待ち | 設計 | 設計の PR | -| 4 | 開発ループの集約の置き場(表の持ち主)と、ループと実行を 1 つの処理で書き換えるかどうか | 設計 | 設計の PR | -| 5 | 「#2164 を含む NDF の版」の下限の版数の値(前提 6) | #2164 のリリース | #2164 のリリース | -| 6 | Anthropic への確認の結果(`local` の自動実行が Claude のプランの使い方として認められるか) | 利用者(#2189 に記録) | 定めない。結果が無い間は定義 5 の置き換えのまま | - -## 関連 - -- #2346(relay.py が同じコンテナの複数の claude セッションを統括する): 手元のランナーと同じ「利用者の機械の上で複数の claude を起動・監視・停止する常駐のプロセス」。起動・一覧・停止の口を Factory の契約の形で揃える -- #2164(版数を流す時点に決める)・#2335(根本解決) From ea8e7244718379fbe071b57fd144230c7d0c103e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 12:31:57 +0000 Subject: [PATCH 002/129] =?UTF-8?q?Docs:=20ndf-factory-ui-components.md=20?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/glossary.md | 14 +- docs/glossary/glossary.json | 14 +- docs/specifications/README.md | 2 + .../ndf-factory-ui-components.md | 67 ++- issues/issue-2382-design-decisions.md | 109 ----- issues/issue-2382-design.md | 440 ------------------ issues/issue-2382-requirements.md | 188 -------- 7 files changed, 82 insertions(+), 752 deletions(-) delete mode 100644 issues/issue-2382-design-decisions.md delete mode 100644 issues/issue-2382-design.md delete mode 100644 issues/issue-2382-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index 267354e16..faa72aee5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -894,10 +894,10 @@ NDF を Web UI から操作できるサービスとして提供するときの | マイルストーンの持ち分 | — | 終わりの状態でない開発ループが、リポジトリのマイルストーン 1 つを選定の対象として占めていることを表す実行の枠の行。同じリポジトリのループどうしで重ならない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 課題の持ち分 | — | 終わりの状態でない手動のスプリントの実行が、リポジトリの課題 1 つを持っていることを表す実行の枠の行。同じ課題を 2 つの実行に入れない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 手元のランナーの枠 | — | 1 つの手元のランナーが running で持つ local の実行ジョブの数を数えた実行の枠の行。同時のジョブの数を超えて受け取らせない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | -| 画面の地図 | — | Factory の全画面の経路・役割ごとの主な動線・確定仕様の機能との対応を 1 つにまとめた表 | — | — | — | -| デザインのトークン | — | 色・文字・余白の値に名前を付け、画面の部品がその名前で参照する値の集合 | — | — | — | -| ダミーの API クライアント | — | 画面が呼ぶ API クライアントのインタフェースを満たし、決まったデータを返す実装。本物の API クライアントと差し替えられる | — | — | — | -| 翻訳のファイル | — | 言語ごとに、画面の文言のキーと文言を持つファイル | — | — | — | -| 見本の画面 | — | 土台の部品だけで組み、ダミーデータで動かす画面の 1 組(スプリントの一覧と詳細)。後続の課題が中身を置き換える出発点 | — | — | — | -| つながりの状態 | — | 画面が今サーバーへつながっているか・最後に受け取った時刻・つながってから確定する操作の数の組 | — | — | — | -| 言語と地域の設定 | — | 画面の文言の言語・日時と数の書式の地域・表示の時刻帯の組。今はブラウザの設定から決める | — | — | — | +| 画面の地図 | — | Factory の全画面の経路・役割ごとの主な動線・確定仕様の機能との対応を 1 つにまとめた表 | — | — | `docs/specifications/ndf-factory-ui-components.md` | +| デザインのトークン | — | 色・文字・余白の値に名前を付け、画面の部品がその名前で参照する値の集合 | — | — | `docs/specifications/ndf-factory-ui-components.md` | +| ダミーの API クライアント | — | 画面が呼ぶ API クライアントのインタフェースを満たし、決まったデータを返す実装。本物の API クライアントと差し替えられる | — | — | `docs/specifications/ndf-factory-ui-components.md` | +| 翻訳のファイル | — | 言語ごとに、画面の文言のキーと文言を持つファイル | — | — | `docs/specifications/ndf-factory-ui-components.md` | +| 見本の画面 | — | 土台の部品だけで組み、ダミーデータで動かす画面の 1 組(スプリントの一覧と詳細)。後続の課題が中身を置き換える出発点 | — | — | `docs/specifications/ndf-factory-ui-components.md` | +| つながりの状態 | — | 画面が今サーバーへつながっているか・最後に受け取った時刻・つながってから確定する操作の数の組 | — | — | `docs/specifications/ndf-factory-ui-components.md` | +| 言語と地域の設定 | — | 画面の文言の言語・日時と数の書式の地域・表示の時刻帯の組。今はブラウザの設定から決める | — | — | `docs/specifications/ndf-factory-ui-components.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 6168dd233..53d908fc7 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4993,43 +4993,43 @@ "term": "画面の地図", "context": "ndf-factory", "meaning": "Factory の全画面の経路・役割ごとの主な動線・確定仕様の機能との対応を 1 つにまとめた表", - "pending_source": "issues/issue-2382-requirements.md" + "source": "docs/specifications/ndf-factory-ui-components.md" }, { "term": "デザインのトークン", "context": "ndf-factory", "meaning": "色・文字・余白の値に名前を付け、画面の部品がその名前で参照する値の集合", - "pending_source": "issues/issue-2382-requirements.md" + "source": "docs/specifications/ndf-factory-ui-components.md" }, { "term": "ダミーの API クライアント", "context": "ndf-factory", "meaning": "画面が呼ぶ API クライアントのインタフェースを満たし、決まったデータを返す実装。本物の API クライアントと差し替えられる", - "pending_source": "issues/issue-2382-requirements.md" + "source": "docs/specifications/ndf-factory-ui-components.md" }, { "term": "翻訳のファイル", "context": "ndf-factory", "meaning": "言語ごとに、画面の文言のキーと文言を持つファイル", - "pending_source": "issues/issue-2382-requirements.md" + "source": "docs/specifications/ndf-factory-ui-components.md" }, { "term": "見本の画面", "context": "ndf-factory", "meaning": "土台の部品だけで組み、ダミーデータで動かす画面の 1 組(スプリントの一覧と詳細)。後続の課題が中身を置き換える出発点", - "pending_source": "issues/issue-2382-design.md" + "source": "docs/specifications/ndf-factory-ui-components.md" }, { "term": "つながりの状態", "context": "ndf-factory", "meaning": "画面が今サーバーへつながっているか・最後に受け取った時刻・つながってから確定する操作の数の組", - "pending_source": "issues/issue-2382-design.md" + "source": "docs/specifications/ndf-factory-ui-components.md" }, { "term": "言語と地域の設定", "context": "ndf-factory", "meaning": "画面の文言の言語・日時と数の書式の地域・表示の時刻帯の組。今はブラウザの設定から決める", - "pending_source": "issues/issue-2382-design.md" + "source": "docs/specifications/ndf-factory-ui-components.md" }, { "term": "差分の棚卸し", diff --git a/docs/specifications/README.md b/docs/specifications/README.md index d9e601112..5ef491184 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -106,6 +106,7 @@ | [ndf-ask-guard.md](ndf-ask-guard.md) | `AskUserQuestion` を PreToolUse の hook(問い合わせの guard)で判定し、区分(`metadata.source`)の無い・許されない問いを止めて AI に決めさせる理由と境界(承認ゲートとレッドラインは宣言で外せない、壊れた宣言は丸ごと既定に戻す、relay の `question open` も同じ判定を使う、止めた問いでは待ちの通知を送らない)、宣言 `.ndf/ask.json` と記録 `ask.jsonl` の形、`ask.py decide` / `summary` の契約。区分と手順は `development-workflow` の `references/asking.md` が正 | | [ndf-factory-runs-and-runner.md](ndf-factory-runs-and-runner.md) | Neural Development Factory: リポジトリの登録(GitHub App の利用者の認可と候補の証明)・キーの持ち込み・課題の候補・実行の制御(開始の判定と実行の枠・起動・停止の知らせと見回り・取り消し・再開の要求・形態 local の渡す口・打ち切りの時間と token の期限)・ランナー(手順・翻訳・送信・状態の保存と復元・形態ごとの claude の呼び方)・runs.yaml の経路。#2186 | | [ndf-factory-runs-state-and-data.md](ndf-factory-runs-state-and-data.md) | Neural Development Factory: スプリントの実行と実行ジョブの状態の移り・処理の流れ・実行の表・置き場の値・環境変数(ndf-factory-runs-and-runner.md から分けた)。#2186 | +| [ndf-factory-screen-map.md](ndf-factory-screen-map.md) | Neural Development Factory: Web の画面の地図(経路・役割・状態・載る機能)・ナビゲーション・役割ごとの主な動線・見本の画面・ダミーで起動する方法。#2382・#2347 | | [ndf-supervise-work-step-web-tools-and-report-stop.md](ndf-supervise-work-step-web-tools-and-report-stop.md) | supervise の work のステップが WebFetch・WebSearch で外部の文書を読み、読めないときと「判断が要る / できなかった」の報告で起こし直さずに止まること | | [ndf-deps-require-groups-check.md](ndf-deps-require-groups-check.md) | 依存の宣言の検査(project-mvv.py propose / vet の deps.require と、構造チェックの require-groups をエントリポイントから関数の中の import までたどること) | | [ndf-supervise-queue-resume-and-gate-evidence.md](ndf-supervise-queue-resume-and-gate-evidence.md) | pace: normal の承認ゲートの後をキュー 1 回で流すこと、queue --resume で止まったプランを続けること、承認ゲートの記録、new sprint --absorb | @@ -120,5 +121,6 @@ | [ndf-worker-stage-skills.md](ndf-worker-stage-skills.md) | プランの worker と ndf:worker へ、ステップの工程に要る規則の Skill だけを届ける仕組み。Skill の対応表(stage-skills.json)と規則の Skill の一覧、プランの skills・skills_add 欄と流す前の検査、本文を system プロンプトへ入れる渡し方と起動指示の「読む Skill」、進捗ログの kind: skills の行、stage-skills.py lookup。規約は supervise_lib/plan.py と agent-layers.md が正 | | [ndf-supervise-verification-mark-after-ok.md](ndf-supervise-verification-mark-after-ok.md) | まとめのプランで「リリース後テスト」の進行の印を、記録を投稿したステップが ok で終わった後に付けること(STAGES_AFTER_OK・record_stage の after_ok・release-verify の worker は進捗記録を打たない)、ほかの工程は始めに付けること、印と記録が食い違いうる経路(#2288・#2356)。プランの外の印の時点は release-verification が正 | | [ndf-factory-dev-loops.md](ndf-factory-dev-loops.md) | Neural Development Factory: 開発ループ(集約・状態の写し方・周回の開始)・同じリポジトリで並行する実行の取り合い(課題・マイルストーンの持ち分・NDF の版・同時の数)・移行の埋め戻し・ランナーの区間と ndf-next・形態 local の利用上限・手元のランナーの同時のジョブの数・loops.yaml の経路。#2347 | +| [ndf-factory-ui-components.md](ndf-factory-ui-components.md) | Neural Development Factory: Web の画面の部品の一覧と使い分け・デザインのトークン・端末の幅・文言と書式(国際化)・API クライアントとダミー・つながりの状態・常に成り立つ条件・検査(check:source・check:bundle・E2E)。#2382 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-factory-ui-components.md b/docs/specifications/ndf-factory-ui-components.md index 697b3df05..02c189884 100644 --- a/docs/specifications/ndf-factory-ui-components.md +++ b/docs/specifications/ndf-factory-ui-components.md @@ -76,11 +76,63 @@ Neural Development Factory の Web の画面を組む部品の一覧と使い分 | `non-ascii` | ASCII の外の文字を含む文字列・テンプレート(JSX の外も) | | `dummy-import` | `src/main.tsx` 以外から `dummy/` を import する | +- API が 400・409・422 で返す `message` は API が日本語で持ち、画面はそのまま出す。翻訳のファイルの対象ではない + ## API クライアントとダミー - 画面は `useServices()` の `api`(`Api` のインタフェース)だけを通してデータを読む。足す操作は配列で受け渡す(1 件のときも要素 1 個の配列) +- 画面は API の応答の形(`factory/contracts/openapi/`)をそのまま受け、自分の語へ翻訳する層を置かない - ダミーの実装は `src/dummy/` にあり、`Api`・`Auth`・つながりの元のダミーとダミーデータを持つ。操作を足したらダミーにも同じ名前の関数を足す -- 起動の組み立て(`src/main.tsx`)が Vite の mode `dummy` のときだけダミーを動的に読む。本番のビルドでは分岐ごと落ち、`npm run check:bundle -w web` が成果物にダミーデータの印 `ndf-factory-dummy-data` が無いことを確かめる +- 起動の組み立て(`src/main.tsx`)が Vite の mode `dummy` のときだけダミーを動的に読む。本番のビルドでは `import.meta.env.MODE` が `"production"` に置き換わって分岐ごと落ち、`npm run check:bundle -w web` が成果物にダミーデータの印 `ndf-factory-dummy-data` が無いことを確かめる(終了コードは 0 無し・1 印を見つけた・2 `dist/` が無い) +- ダミーデータに秘密と実在の個人情報を書かない。利用者の ID は `00000000-…` の形、名前は架空のものにする + +| 操作 | 入力 | 出力 | 失敗の形 | +| --- | --- | --- | --- | +| `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunSummary[]`。見つからない ID の要素は返さない | `ApiFailure`(401・403・5xx) | + +`sprintRunDetails` の本物の実装は、詳細の経路がまだ無いため `GET /accounts/{a}/sprint-runs` の新しい順 50 件から ID で選ぶ。それより古い実行は見つからない扱いになる。詳細の経路ができたら本物の実装だけを差し替え、画面は変えない。 + +## つながりの状態 + +`src/connection.ts` の `ConnectionSource`(`current()` と `subscribe(listener)`)が `ConnectionState` を渡し、枠の `ConnectionStatus` と承認の操作の `ApprovalActions` が `useSyncExternalStore` で読む。 + +| 値 | 型 | 意味 | +| --- | --- | --- | +| `online` | 真偽 | 今サーバーへつながっているか | +| `lastReceivedAt` | UTC の ISO 8601 か `null` | 最後に受け取った時刻 | +| `pendingOperations` | 数 | つながってから確定する操作の数 | + +- 3 つは独立した値で、オフラインで確定待ちがある場面も表せる。`ConnectionStatus` はオンライン・オフライン(最後に受け取った時刻つき)・確定待ちの操作がある、を見分けて出す +- ブラウザの実装は `navigator.onLine` と `online` / `offline` の事象だけを読み、`lastReceivedAt` は `null`、`pendingOperations` は 0 を返す。時刻と確定待ちの数を作る実装は、同じ `ConnectionSource` を差し替えて足す +- `App` は `services` に `connection` が無いときブラウザの実装を、`settings` が無いときブラウザから決めた言語と地域の設定を、`resources` が無いとき日本語の翻訳のファイルを使う + +## 常に成り立つ条件 + +| 条件 | 破れたときに落ちるもの | +| --- | --- | +| 画面のコード(テスト・ダミー・翻訳のファイルを除く)に表示の文言が無い | `check:source`(`npm run check`) | +| 画面のコードが使う翻訳のキーは日本語の翻訳のファイルにある | 型検査 | +| ブラウザの言語に翻訳のファイルが無いとき、文言は日本語で出る | Vitest | +| 日時は UTC の ISO 8601 で受け、設定の時刻帯で整形する | Vitest | +| 金額は通貨と組(`Money`)でだけ整形の関数へ渡せる | 型検査 | +| 部品の見た目の値はトークンの名前で引く | トークンの写しのテスト | +| `src/ui/` の部品は「部品の一覧」に 1 つずつ載る | `docs.test.ts` | +| ログインの後の画面は 360・768・1280 px のどの幅でも横にはみ出さない | E2E | +| 画面は `Api` だけを通してデータを読み、ダミーを import するのは `src/main.tsx` だけ | `check:source` の `dummy-import` | +| 本番のビルドの成果物にダミーの実装とダミーデータが入らない | `check:bundle` | +| 0 件は `EmptyState`、失敗は `ErrorState` を出し、画面は白くならない | 部品のテスト | + +## 設計の判断 + +- **国際化は i18next と react-i18next で行う。** 日本語の翻訳のファイルを `CustomTypeOptions` の `resources` に渡してキーの型を作り、埋め込み・複数形・既定の言語への戻りを i18next に任せる。言語の判定は追加のパッケージを使わず、`navigator.languages` から翻訳のある言語を選ぶ +- **日時・数・金額は `Intl` で整形する。** 必要な整形を `Intl.DateTimeFormat`(`timeZone` を渡す)と `Intl.NumberFormat`(`style: "currency"`)が持ち、日時のライブラリを足すと依存の費用だけが増えるためである +- **直に書いた文言の検査は `oxc-parser` で源を読む自前のスクリプト(`web/scripts/check-source.ts`)にする。** ESLint の TypeScript の構文解析(`typescript-eslint`)は `factory/` の TypeScript 7 と組めず、`oxc-parser` は TypeScript に依存しないためである +- **ダミーと本物の切り替えは Vite の mode `dummy` で `src/main.tsx` の 1 か所が行う。** 画面の設定へ環境変数を足すと画面へ埋め込む変数が増え、mode なら本番のビルドで値が定数になって分岐ごと落とせるためである +- **経路は `screens.ts` の画面の地図から作り、確定仕様の表と識別子の集合を照らす。** 役割ごとの動線と機能との対応はコードに持たない情報のため、文書を生成せず、経路と部品の名前の集合だけをテストで照らす +- **トークンは Design Tokens Format Module の JSON に置き、起動時に CSS の変数へ写す。** ビルドの手順(Style Dictionary など)を足さず、Tailwind CSS や CSS-in-JS のように正を設定ファイルへ移したり実行時の依存を足したりしないためである +- **配置は Playwright の Chromium で測り、E2E は `npm run check` の外の CI の手順にする。** jsdom は配置を計算せず、ブラウザの導入が要るためである。iOS の Safari と Android の Chrome での描かれ方は、リリース後テストで人が見る +- **境は 768 px と 1024 px の 2 つにし、同じ部品が CSS だけで形を変える。** 画面のコードが幅を知らずに済むためである +- **`NavMenu` は `